From 552bd7b932ff26e9dd2e468e633a6a27b1455495 Mon Sep 17 00:00:00 2001 From: Pavel Pogodaev Date: Tue, 21 Jan 2025 15:08:34 +0300 Subject: [PATCH] [#604] Add support of MFADelete argument and x-amz-mfa header Signed-off-by: Pavel Pogodaev --- api/cache/cache_test.go | 5 +- api/data/info.go | 22 ++- api/errors/errors.go | 9 +- api/handler/api.go | 5 +- api/handler/delete.go | 50 +++++ api/handler/handlers_test.go | 5 +- api/handler/put.go | 11 +- api/handler/util.go | 14 ++ api/handler/versioning.go | 42 +++- api/headers.go | 1 + api/layer/locking_test.go | 7 +- api/layer/system_object.go | 5 +- api/layer/versioning_test.go | 22 ++- cmd/s3-gw/app.go | 43 ++++- cmd/s3-gw/app_settings.go | 1 + config/config.env | 1 + config/config.yaml | 1 + creds/accessbox/accessbox.pb.go | 10 +- docs/configuration.md | 2 + go.mod | 2 + go.sum | 5 + internal/frostfs/mfa.go | 183 ++++++++++++++++++ internal/logs/logs.go | 2 + internal/mfa/mfa.go | 161 +++++++++++++++ internal/mfa/mfabox.go | 247 +++++++++++++++++++++++ internal/mfa/mfabox.pb.go | 333 ++++++++++++++++++++++++++++++++ internal/mfa/mfabox.proto | 34 ++++ pkg/service/tree/tree.go | 20 +- pkg/service/tree/tree_test.go | 5 +- 29 files changed, 1212 insertions(+), 36 deletions(-) create mode 100644 internal/frostfs/mfa.go create mode 100644 internal/mfa/mfa.go create mode 100644 internal/mfa/mfabox.go create mode 100644 internal/mfa/mfabox.pb.go create mode 100644 internal/mfa/mfabox.proto diff --git a/api/cache/cache_test.go b/api/cache/cache_test.go index 36230d93..70e128a0 100644 --- a/api/cache/cache_test.go +++ b/api/cache/cache_test.go @@ -173,7 +173,10 @@ func TestSettingsCacheType(t *testing.T) { cache := NewSystemCache(DefaultSystemConfig(logger)) key := "key" - settings := &data.BucketSettings{Versioning: data.VersioningEnabled} + settings := &data.BucketSettings{Versioning: data.Versioning{ + VersioningStatus: data.VersioningEnabled, + MFADeleteStatus: data.MFADeleteEnabled, + }} err := cache.PutSettings(key, settings) require.NoError(t, err) diff --git a/api/data/info.go b/api/data/info.go index 9dd17cbd..a6e9077f 100644 --- a/api/data/info.go +++ b/api/data/info.go @@ -20,6 +20,9 @@ const ( VersioningUnversioned = "Unversioned" VersioningEnabled = "Enabled" VersioningSuspended = "Suspended" + + MFADeleteDisabled = "Disabled" + MFADeleteEnabled = "Enabled" ) type ( @@ -55,12 +58,19 @@ type ( // BucketSettings stores settings such as versioning. BucketSettings struct { - Versioning string + Versioning Versioning LockConfiguration *ObjectLockConfiguration CannedACL string OwnerKey *keys.PublicKey } + // Versioning stores bucket versioning settings. + Versioning struct { + VersioningStatus string + MFADeleteStatus string + MFASerialNumber string + } + // CORSConfiguration stores CORS configuration of a request. CORSConfiguration struct { XMLName xml.Name `xml:"http://s3.amazonaws.com/doc/2006-03-01/ CORSConfiguration" json:"-"` @@ -130,15 +140,19 @@ func (o *ObjectInfo) ETag(md5Enabled bool) string { } func (b BucketSettings) Unversioned() bool { - return b.Versioning == VersioningUnversioned + return b.Versioning.VersioningStatus == VersioningUnversioned } func (b BucketSettings) VersioningEnabled() bool { - return b.Versioning == VersioningEnabled + return b.Versioning.VersioningStatus == VersioningEnabled } func (b BucketSettings) VersioningSuspended() bool { - return b.Versioning == VersioningSuspended + return b.Versioning.VersioningStatus == VersioningSuspended +} + +func (b BucketSettings) MFADeleteEnabled() bool { + return b.Versioning.MFADeleteStatus == MFADeleteEnabled } func Quote(val string) string { diff --git a/api/errors/errors.go b/api/errors/errors.go index d35750c4..bc3e6237 100644 --- a/api/errors/errors.go +++ b/api/errors/errors.go @@ -143,6 +143,7 @@ const ( ErrInvalidTagDirective // Add new error codes here. ErrNotSupported + ErrMFAAuthNeeded // SSE-S3 related API errors. ErrInvalidEncryptionMethod @@ -287,7 +288,7 @@ const ( ErrPostPolicyConditionInvalidFormat - //CORS configuration errors. + // CORS configuration errors. ErrCORSUnsupportedMethod ErrCORSWildcardExposeHeaders @@ -388,6 +389,12 @@ var errorCodes = errorCodeMap{ Description: "Access Denied.", HTTPStatusCode: http.StatusForbidden, }, + ErrMFAAuthNeeded: { + ErrCode: ErrMFAAuthNeeded, + Code: "AccessDenied", + Description: "Mfa Authentication must be used for this request", + HTTPStatusCode: http.StatusForbidden, + }, ErrAccessControlListNotSupported: { ErrCode: ErrAccessControlListNotSupported, Code: "AccessControlListNotSupported", diff --git a/api/handler/api.go b/api/handler/api.go index 19e2c4e8..fdb1ac4c 100644 --- a/api/handler/api.go +++ b/api/handler/api.go @@ -11,6 +11,7 @@ import ( "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/api" "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/api/layer" + "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/internal/mfa" cid "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/container/id" "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/netmap" "git.frostfs.info/TrueCloudLab/policy-engine/pkg/chain" @@ -24,6 +25,7 @@ type ( cfg Config ape APE frostfsid FrostFSID + mfa *mfa.MFA } // Config contains data which handler needs to keep. @@ -68,7 +70,7 @@ const ( var _ api.Handler = (*handler)(nil) // New creates new api.Handler using given logger and client. -func New(log *zap.Logger, obj *layer.Layer, cfg Config, storage APE, ffsid FrostFSID) (api.Handler, error) { +func New(log *zap.Logger, obj *layer.Layer, cfg Config, storage APE, ffsid FrostFSID, mfaCli *mfa.MFA) (api.Handler, error) { switch { case obj == nil: return nil, errors.New("empty FrostFS Object Layer") @@ -86,6 +88,7 @@ func New(log *zap.Logger, obj *layer.Layer, cfg Config, storage APE, ffsid Frost cfg: cfg, ape: storage, frostfsid: ffsid, + mfa: mfaCli, }, nil } diff --git a/api/handler/delete.go b/api/handler/delete.go index 67b025d5..3b27b1a0 100644 --- a/api/handler/delete.go +++ b/api/handler/delete.go @@ -14,6 +14,7 @@ import ( apistatus "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/client/status" "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/session" "git.frostfs.info/TrueCloudLab/policy-engine/pkg/chain" + "github.com/pquerna/otp/totp" ) // limitation of AWS https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteObjects.html @@ -81,6 +82,26 @@ func (h *handler) DeleteObjectHandler(w http.ResponseWriter, r *http.Request) { return } + if len(versionID) > 0 && bktSettings.MFADeleteEnabled() { + serialNumber, token, err := h.getMFAHeader(r) + if err != nil { + h.logAndSendError(ctx, w, "could not get mfa header", reqInfo, err) + return + } + + device, err := h.mfa.GetMFADevice(ctx, reqInfo.Namespace, nameFromArn(serialNumber)) + if err != nil { + h.logAndSendError(ctx, w, "could not get mfa device", reqInfo, err) + return + } + + validate := totp.Validate(token, device.Key.Secret()) + if !validate { + h.logAndSendError(ctx, w, "could not validate token", reqInfo, fmt.Errorf("mfa Authentication must be used for this request")) + return + } + } + networkInfo, err := h.obj.GetNetworkInfo(ctx) if err != nil { h.logAndSendError(ctx, w, "could not get network info", reqInfo, err) @@ -188,6 +209,26 @@ func (h *handler) DeleteMultipleObjectsHandler(w http.ResponseWriter, r *http.Re return } + if haveVersionedObjects(requested.Objects) && bktSettings.MFADeleteEnabled() { + serialNumber, token, err := h.getMFAHeader(r) + if err != nil { + h.logAndSendError(ctx, w, "could not get mfa header", reqInfo, err) + return + } + + device, err := h.mfa.GetMFADevice(ctx, reqInfo.Namespace, nameFromArn(serialNumber)) + if err != nil { + h.logAndSendError(ctx, w, "could not get mfa device", reqInfo, err) + return + } + + validate := totp.Validate(token, device.Key.Secret()) + if !validate { + h.logAndSendError(ctx, w, "could not validate token", reqInfo, fmt.Errorf("mfa Authentication must be used for this request")) + return + } + } + networkInfo, err := h.obj.GetNetworkInfo(ctx) if err != nil { h.logAndSendError(ctx, w, "could not get network info", reqInfo, err) @@ -287,3 +328,12 @@ func (h *handler) DeleteBucketHandler(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } + +func haveVersionedObjects(objects []ObjectIdentifier) bool { + for _, o := range objects { + if len(o.VersionID) > 0 { + return true + } + } + return false +} diff --git a/api/handler/handlers_test.go b/api/handler/handlers_test.go index 78de4393..9d4c7162 100644 --- a/api/handler/handlers_test.go +++ b/api/handler/handlers_test.go @@ -437,7 +437,10 @@ func createTestBucketWithLock(hc *handlerContext, bktName string, conf *data.Obj sp := &layer.PutSettingsParams{ BktInfo: bktInfo, Settings: &data.BucketSettings{ - Versioning: data.VersioningEnabled, + Versioning: data.Versioning{ + VersioningStatus: data.VersioningEnabled, + MFADeleteStatus: data.MFADeleteDisabled, + }, LockConfiguration: conf, OwnerKey: key.PublicKey(), }, diff --git a/api/handler/put.go b/api/handler/put.go index c3776f79..f75d91ed 100644 --- a/api/handler/put.go +++ b/api/handler/put.go @@ -824,14 +824,17 @@ func (h *handler) createBucketHandlerPolicy(w http.ResponseWriter, r *http.Reque sp := &layer.PutSettingsParams{ BktInfo: bktInfo, Settings: &data.BucketSettings{ - CannedACL: cannedACL, - OwnerKey: key, - Versioning: data.VersioningUnversioned, + CannedACL: cannedACL, + OwnerKey: key, + Versioning: data.Versioning{ + VersioningStatus: data.VersioningUnversioned, + MFADeleteStatus: data.MFADeleteDisabled, + }, }, } if p.ObjectLockEnabled { - sp.Settings.Versioning = data.VersioningEnabled + sp.Settings.Versioning.VersioningStatus = data.VersioningEnabled } err = retryer.MakeWithRetry(ctx, func() error { diff --git a/api/handler/util.go b/api/handler/util.go index bc2d279c..5d280694 100644 --- a/api/handler/util.go +++ b/api/handler/util.go @@ -137,3 +137,17 @@ func parseRange(s string) (*layer.RangeParams, error) { End: values[1], }, nil } + +func nameFromArn(arn string) string { + pts := strings.Split(arn, "/") + return pts[len(pts)-1] +} + +func (h *handler) getMFAHeader(r *http.Request) (string, string, error) { + parts := strings.Split(r.Header.Get(api.AmzMFA), " ") + if len(parts) != 2 { + return "", "", fmt.Errorf("%w: invalid mfa header", apierr.GetAPIError(apierr.ErrMFAAuthNeeded)) + } + + return parts[0], parts[1], nil +} diff --git a/api/handler/versioning.go b/api/handler/versioning.go index bf9c895c..d1a0acc8 100644 --- a/api/handler/versioning.go +++ b/api/handler/versioning.go @@ -7,6 +7,7 @@ import ( "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/api/errors" "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/api/layer" "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/api/middleware" + "github.com/pquerna/otp/totp" ) func (h *handler) PutBucketVersioningHandler(w http.ResponseWriter, r *http.Request) { @@ -31,14 +32,47 @@ func (h *handler) PutBucketVersioningHandler(w http.ResponseWriter, r *http.Requ return } + // settings pointer is stored in the cache, so modify a copy of the settings + newSettings := *settings + + if len(configuration.MfaDelete) > 0 { + serialNumber, token, err := h.getMFAHeader(r) + if err != nil { + h.logAndSendError(ctx, w, "invalid x-amz-mfa header", reqInfo, errors.GetAPIError(errors.ErrBadRequest)) + return + } + + name := nameFromArn(serialNumber) + device, err := h.mfa.GetMFADevice(ctx, reqInfo.Namespace, name) + if err != nil { + h.logAndSendError(ctx, w, "get mfa device", reqInfo, err) + return + } + + ok := totp.Validate(token, device.Key.Secret()) + if !ok { + h.logAndSendError(ctx, w, "validation error", reqInfo, nil) + return + } + + switch configuration.MfaDelete { + case data.MFADeleteEnabled: + newSettings.Versioning.MFADeleteStatus = data.MFADeleteEnabled + newSettings.Versioning.MFASerialNumber = serialNumber + case data.MFADeleteDisabled: + newSettings.Versioning.MFADeleteStatus = data.MFADeleteDisabled + default: + h.logAndSendError(ctx, w, "failed to get mfa configuration", reqInfo, nil) + return + } + } + if configuration.Status != data.VersioningEnabled && configuration.Status != data.VersioningSuspended { h.logAndSendError(ctx, w, "invalid versioning configuration", reqInfo, errors.GetAPIError(errors.ErrMalformedXML)) return } - // settings pointer is stored in the cache, so modify a copy of the settings - newSettings := *settings - newSettings.Versioning = configuration.Status + newSettings.Versioning.VersioningStatus = configuration.Status p := &layer.PutSettingsParams{ BktInfo: bktInfo, @@ -80,7 +114,7 @@ func (h *handler) GetBucketVersioningHandler(w http.ResponseWriter, r *http.Requ func formVersioningConfiguration(settings *data.BucketSettings) *VersioningConfiguration { res := &VersioningConfiguration{} if !settings.Unversioned() { - res.Status = settings.Versioning + res.Status = settings.Versioning.VersioningStatus } return res diff --git a/api/headers.go b/api/headers.go index bfcb2952..615c3a73 100644 --- a/api/headers.go +++ b/api/headers.go @@ -15,6 +15,7 @@ const ( AmzCopySource = "X-Amz-Copy-Source" AmzCopySourceRange = "X-Amz-Copy-Source-Range" AmzDate = "X-Amz-Date" + AmzMFA = "X-Amz-Mfa" LastModified = "Last-Modified" Date = "Date" diff --git a/api/layer/locking_test.go b/api/layer/locking_test.go index 56cb8107..5de3f7e4 100644 --- a/api/layer/locking_test.go +++ b/api/layer/locking_test.go @@ -12,8 +12,11 @@ import ( func TestObjectLockAttributes(t *testing.T) { tc := prepareContext(t) err := tc.layer.PutBucketSettings(tc.ctx, &PutSettingsParams{ - BktInfo: tc.bktInfo, - Settings: &data.BucketSettings{Versioning: data.VersioningEnabled}, + BktInfo: tc.bktInfo, + Settings: &data.BucketSettings{Versioning: data.Versioning{ + VersioningStatus: data.VersioningEnabled, + MFADeleteStatus: data.MFADeleteDisabled, + }}, }) require.NoError(t, err) diff --git a/api/layer/system_object.go b/api/layer/system_object.go index 4f7f40ec..3e33fa52 100644 --- a/api/layer/system_object.go +++ b/api/layer/system_object.go @@ -216,7 +216,10 @@ func (n *Layer) GetBucketSettings(ctx context.Context, bktInfo *data.BucketInfo) if !errors.Is(err, tree.ErrNodeNotFound) { return nil, err } - settings = &data.BucketSettings{Versioning: data.VersioningUnversioned} + settings = &data.BucketSettings{Versioning: data.Versioning{ + VersioningStatus: data.VersioningUnversioned, + MFADeleteStatus: data.MFADeleteDisabled, + }} n.reqLogger(ctx).Debug(logs.BucketSettingsNotFoundUseDefaults) } diff --git a/api/layer/versioning_test.go b/api/layer/versioning_test.go index 12e1e27a..8c55a6a6 100644 --- a/api/layer/versioning_test.go +++ b/api/layer/versioning_test.go @@ -196,8 +196,11 @@ func prepareContext(t *testing.T, cachesConfig ...*CachesConfig) *testContext { func TestSimpleVersioning(t *testing.T) { tc := prepareContext(t) err := tc.layer.PutBucketSettings(tc.ctx, &PutSettingsParams{ - BktInfo: tc.bktInfo, - Settings: &data.BucketSettings{Versioning: data.VersioningEnabled}, + BktInfo: tc.bktInfo, + Settings: &data.BucketSettings{Versioning: data.Versioning{ + VersioningStatus: data.VersioningEnabled, + MFADeleteStatus: data.MFADeleteDisabled, + }}, }) require.NoError(t, err) @@ -234,7 +237,10 @@ func TestSimpleNoVersioning(t *testing.T) { func TestVersioningDeleteObject(t *testing.T) { tc := prepareContext(t) - settings := &data.BucketSettings{Versioning: data.VersioningEnabled} + settings := &data.BucketSettings{Versioning: data.Versioning{ + VersioningStatus: data.VersioningEnabled, + MFADeleteStatus: data.MFADeleteDisabled, + }} err := tc.layer.PutBucketSettings(tc.ctx, &PutSettingsParams{ BktInfo: tc.bktInfo, Settings: settings, @@ -256,7 +262,10 @@ func TestGetUnversioned(t *testing.T) { objContent := []byte("content obj1 v1") objInfo := tc.putObject(objContent) - settings := &data.BucketSettings{Versioning: data.VersioningUnversioned} + settings := &data.BucketSettings{Versioning: data.Versioning{ + VersioningStatus: data.VersioningUnversioned, + MFADeleteStatus: data.MFADeleteDisabled, + }} err := tc.layer.PutBucketSettings(tc.ctx, &PutSettingsParams{ BktInfo: tc.bktInfo, Settings: settings, @@ -270,7 +279,10 @@ func TestGetUnversioned(t *testing.T) { func TestVersioningDeleteSpecificObjectVersion(t *testing.T) { tc := prepareContext(t) - settings := &data.BucketSettings{Versioning: data.VersioningEnabled} + settings := &data.BucketSettings{Versioning: data.Versioning{ + VersioningStatus: data.VersioningEnabled, + MFADeleteStatus: data.MFADeleteDisabled, + }} err := tc.layer.PutBucketSettings(tc.ctx, &PutSettingsParams{ BktInfo: tc.bktInfo, Settings: settings, diff --git a/cmd/s3-gw/app.go b/cmd/s3-gw/app.go index 14f9b17e..0a3035e7 100644 --- a/cmd/s3-gw/app.go +++ b/cmd/s3-gw/app.go @@ -36,6 +36,7 @@ import ( "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/internal/frostfs/policy/contract" "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/internal/frostfs/services" "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/internal/logs" + "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/internal/mfa" internalnet "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/internal/net" "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/internal/version" "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/internal/wallet" @@ -595,7 +596,7 @@ func (s *appSettings) TombstoneLifetime() uint64 { func (a *App) initAPI(ctx context.Context) { a.initLayer(ctx) - a.initHandler() + a.initHandler(ctx) } func (a *App) initMetrics() { @@ -1134,15 +1135,51 @@ func getFrostfsIDCacheConfig(v *viper.Viper, l *zap.Logger) *cache.Config { return cacheCfg } -func (a *App) initHandler() { +func (a *App) initHandler(ctx context.Context) { var err error - a.api, err = handler.New(a.log, a.obj, a.settings, a.policyStorage, a.frostfsid) + var mfaCnrInfo *data.BucketInfo + if a.cfg.IsSet(cfgContainersMFA) { + mfaCnrInfo, err = a.fetchContainerInfo(ctx, cfgContainersMFA) + if err != nil { + a.log.Fatal(logs.CouldNotFetchMFAContainerInfo, zap.Error(err)) + } + } + + mfaConfig, err := a.fetchMFAConfig(mfaCnrInfo.CID) + if err != nil { + a.log.Fatal(logs.CouldNotInitMFAClient, zap.Error(err)) + } + + mfaCli, err := mfa.NewMFA(mfaConfig) + if err != nil { + a.log.Fatal(logs.CouldNotInitMFAClient, zap.Error(err)) + } + + a.api, err = handler.New(a.log, a.obj, a.settings, a.policyStorage, a.frostfsid, mfaCli) if err != nil { a.log.Fatal(logs.CouldNotInitializeAPIHandler, zap.Error(err)) } } +func (a *App) fetchMFAConfig(id cid.ID) (mfa.Config, error) { + mfaFrostFS := frostfs.NewMFAFrostFS(frostfs.MFAFrostFSConfig{ + Pool: a.pool, + TreePool: a.treePool, + Key: a.key, + Logger: a.log, + }) + + config := mfa.Config{ + Storage: mfaFrostFS, + Key: a.key, + Container: id, + Logger: a.log, + } + + return config, nil +} + func (a *App) getServer(address string) Server { for i := range a.servers { if a.servers[i].Address() == address { diff --git a/cmd/s3-gw/app_settings.go b/cmd/s3-gw/app_settings.go index a3c225e9..fad734fb 100644 --- a/cmd/s3-gw/app_settings.go +++ b/cmd/s3-gw/app_settings.go @@ -222,6 +222,7 @@ const ( // Settings. cfgContainersCORS = "containers.cors" cfgContainersLifecycle = "containers.lifecycle" cfgContainersAccessBox = "containers.accessbox" + cfgContainersMFA = "containers.mfa" // Multinet. cfgMultinetEnabled = "multinet.enabled" diff --git a/config/config.env b/config/config.env index 1e3268ed..ae77fb46 100644 --- a/config/config.env +++ b/config/config.env @@ -262,6 +262,7 @@ S3_GW_RETRY_STRATEGY=exponential # Containers properties S3_GW_CONTAINERS_CORS=AZjLTXfK4vs4ovxMic2xEJKSymMNLqdwq9JT64ASFCRj S3_GW_CONTAINERS_LIFECYCLE=AZjLTXfK4vs4ovxMic2xEJKSymMNLqdwq9JT64ASFCRj +S3_GW_CONTAINERS_MFA=HV9h4zbp7Dti2VXef2oFSsBSRyJUR6NfMeswuv12fjZu # Multinet properties # Enable multinet support diff --git a/config/config.yaml b/config/config.yaml index b3f86f8b..32cb22c9 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -310,6 +310,7 @@ retry: containers: cors: AZjLTXfK4vs4ovxMic2xEJKSymMNLqdwq9JT64ASFCRj lifecycle: AZjLTXfK4vs4ovxMic2xEJKSymMNLqdwq9JT64ASFCRj + mfa: HV9h4zbp7Dti2VXef2oFSsBSRyJUR6NfMeswuv12fjZu # Multinet properties multinet: diff --git a/creds/accessbox/accessbox.pb.go b/creds/accessbox/accessbox.pb.go index 0dc9be37..c8d37eeb 100644 --- a/creds/accessbox/accessbox.pb.go +++ b/creds/accessbox/accessbox.pb.go @@ -330,7 +330,7 @@ func file_creds_accessbox_accessbox_proto_rawDescGZIP() []byte { } var file_creds_accessbox_accessbox_proto_msgTypes = make([]protoimpl.MessageInfo, 4) -var file_creds_accessbox_accessbox_proto_goTypes = []any{ +var file_creds_accessbox_accessbox_proto_goTypes = []interface{}{ (*AccessBox)(nil), // 0: accessbox.AccessBox (*Tokens)(nil), // 1: accessbox.Tokens (*AccessBox_Gate)(nil), // 2: accessbox.AccessBox.Gate @@ -352,7 +352,7 @@ func file_creds_accessbox_accessbox_proto_init() { return } if !protoimpl.UnsafeEnabled { - file_creds_accessbox_accessbox_proto_msgTypes[0].Exporter = func(v any, i int) any { + file_creds_accessbox_accessbox_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*AccessBox); i { case 0: return &v.state @@ -364,7 +364,7 @@ func file_creds_accessbox_accessbox_proto_init() { return nil } } - file_creds_accessbox_accessbox_proto_msgTypes[1].Exporter = func(v any, i int) any { + file_creds_accessbox_accessbox_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*Tokens); i { case 0: return &v.state @@ -376,7 +376,7 @@ func file_creds_accessbox_accessbox_proto_init() { return nil } } - file_creds_accessbox_accessbox_proto_msgTypes[2].Exporter = func(v any, i int) any { + file_creds_accessbox_accessbox_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*AccessBox_Gate); i { case 0: return &v.state @@ -388,7 +388,7 @@ func file_creds_accessbox_accessbox_proto_init() { return nil } } - file_creds_accessbox_accessbox_proto_msgTypes[3].Exporter = func(v any, i int) any { + file_creds_accessbox_accessbox_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*AccessBox_ContainerPolicy); i { case 0: return &v.state diff --git a/docs/configuration.md b/docs/configuration.md index aa0db197..28146b96 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -822,6 +822,7 @@ containers: cors: AZjLTXfK4vs4ovxMic2xEJKSymMNLqdwq9JT64ASFCRj lifecycle: AZjLTXfK4vs4ovxMic2xEJKSymMNLqdwq9JT64ASFCRj accessbox: ExnA1gSY3kzgomi2wJxNyWo1ytWv9VAKXRE55fNXEPL2 + mfa: HV9h4zbp7Dti2VXef2oFSsBSRyJUR6NfMeswuv12fjZu ``` | Parameter | Type | SIGHUP reload | Default value | Description | @@ -829,6 +830,7 @@ containers: | `cors` | `string` | no | | Container name for CORS configurations. If not set, container of the bucket is used. | | `lifecycle` | `string` | no | | Container name for lifecycle configurations. If not set, container of the bucket is used. | | `accessbox` | `string` | no | | Container name to lookup accessbox if custom aws credentials is used. If not set, custom credentials are not supported. | +| `mfa` | `string` | no | | Container name for virtual MFA devices. If not set, MFADeleteBucket are not supported. | # `vhs` section diff --git a/go.mod b/go.mod index 797656ac..7eb96fec 100644 --- a/go.mod +++ b/go.mod @@ -21,6 +21,7 @@ require ( github.com/mr-tron/base58 v1.2.0 github.com/nspcc-dev/neo-go v0.106.3 github.com/panjf2000/ants/v2 v2.5.0 + github.com/pquerna/otp v1.4.0 github.com/prometheus/client_golang v1.19.0 github.com/prometheus/client_model v0.5.0 github.com/spf13/cobra v1.8.1 @@ -64,6 +65,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.6 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.30.6 // indirect github.com/beorn7/perks v1.0.1 // indirect + github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.4 // indirect diff --git a/go.sum b/go.sum index 3cccb9c3..ef224da5 100644 --- a/go.sum +++ b/go.sum @@ -104,6 +104,8 @@ github.com/bits-and-blooms/bitset v1.8.0 h1:FD+XqgOZDUxxZ8hzoBFuV9+cGWY9CslN6d5M github.com/bits-and-blooms/bitset v1.8.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= github.com/bluele/gcache v0.0.2 h1:WcbfdXICg7G/DGBh1PFfcirkWOQV+v077yF1pSy3DGw= github.com/bluele/gcache v0.0.2/go.mod h1:m15KV+ECjptwSPxKhOhQoAFQVtUFjTVkc3H8o0t/fp0= +github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI= +github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= @@ -309,6 +311,8 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE github.com/pkg/sftp v1.13.1/go.mod h1:3HaPG6Dq1ILlpPZRO0HVMrsydcdLt6HRDccSgb87qRg= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pquerna/otp v1.4.0 h1:wZvl1TIVxKRThZIBiwOOHOGP/1+nZyWBil9Y2XNEDzg= +github.com/pquerna/otp v1.4.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= github.com/prometheus/client_golang v1.19.0 h1:ygXvpU1AoN1MhdzckN+PyD9QJOSD4x7kmXYlnfbA6JU= github.com/prometheus/client_golang v1.19.0/go.mod h1:ZRM9uEAypZakd+q/x7+gmsvXdURP+DABIEIjnmDdp+k= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= @@ -345,6 +349,7 @@ github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpE github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= diff --git a/internal/frostfs/mfa.go b/internal/frostfs/mfa.go new file mode 100644 index 00000000..54dee8de --- /dev/null +++ b/internal/frostfs/mfa.go @@ -0,0 +1,183 @@ +package frostfs + +import ( + "context" + "errors" + "fmt" + "io" + "strings" + + "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/api/layer/frostfs" + "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/api/middleware" + "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/internal/mfa" + apitree "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/api/tree" + cid "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/container/id" + oid "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/object/id" + "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/pool" + treepool "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/pool/tree" + "github.com/nspcc-dev/neo-go/pkg/crypto/keys" + "go.uber.org/zap" +) + +// MFAFrostFS is a mediator which implements mfa.Storage through pool.Pool and treepool.Pool. +type MFAFrostFS struct { + frostFS *FrostFS + treePool *treepool.Pool + log *zap.Logger +} + +type MFAFrostFSConfig struct { + Pool *pool.Pool + TreePool *treepool.Pool + Key *keys.PrivateKey + Logger *zap.Logger +} + +type treeNode struct { + ID uint64 + ParentID uint64 + TimeStamp uint64 + Meta map[string]string +} + +type multiSystemNode struct { + // the first element is latest + nodes []*treeNode +} + +const ( + fileNameKey = "FileName" + mfaTreeName = "mfa" +) + +// NewMFAFrostFS creates new MFAFrostFS using provided pool.Pool. +func NewMFAFrostFS(cfg MFAFrostFSConfig) *MFAFrostFS { + return &MFAFrostFS{ + frostFS: NewFrostFS(cfg.Pool, cfg.Key), + treePool: cfg.TreePool, + log: cfg.Logger, + } +} + +func (m *MFAFrostFS) GetObject(ctx context.Context, addr oid.Address) ([]byte, error) { + res, err := m.frostFS.GetObject(ctx, frostfs.PrmObjectGet{ + Container: addr.Container(), + Object: addr.Object(), + }) + if err != nil { + return nil, err + } + + defer func() { + if closeErr := res.Payload.Close(); closeErr != nil { + // TODO add log + // middleware.GetReqLog(ctx).Warn(logs.CloseMFAObjectPayload, zap.Error(closeErr)) + middleware.GetReqLog(ctx).Warn("logs.CloseMFAObjectPayload", zap.Error(closeErr)) + } + }() + + return io.ReadAll(res.Payload) +} + +func (m *MFAFrostFS) GetTreeNode(ctx context.Context, cnrID cid.ID, name string) (*mfa.TreeMultiNode, error) { + multiNode, err := m.getTreeNode(ctx, cnrID, pathFromName(name)) + if err != nil { + return nil, fmt.Errorf("couldn't get node: %w", err) + } + + return multiNode.ToMFAMultiNode(), nil +} + +func (m *MFAFrostFS) getTreeNode(ctx context.Context, cnrID cid.ID, path []string) (*multiSystemNode, error) { + prmGetNodes := treepool.GetNodesParams{ + CID: cnrID, + TreeID: mfaTreeName, + Path: path, + PathAttribute: fileNameKey, + LatestOnly: true, + AllAttrs: true, + } + + nodes, err := m.treePool.GetNodes(ctx, prmGetNodes) + if err != nil { + if errors.Is(err, treepool.ErrNodeNotFound) { + return nil, fmt.Errorf("%s: %s", "mfa.ErrTreeNodeNotFound", err.Error()) + // return nil, fmt.Errorf("%w: %s", mfa.ErrTreeNodeNotFound, err.Error()) + } + return nil, fmt.Errorf("get nodes: %w", err) + } + + if len(nodes) == 0 { + // return nil, mfa.ErrTreeNodeNotFound + return nil, err + } + // if len(nodes) != 1 { + // // m.log.Warn("logs.FoundMultiNode", zap.Strings("path", path)) + // } + + return newMultiNode(nodes) +} + +func newMultiNode(nodes []*apitree.GetNodeByPathResponseInfo) (*multiSystemNode, error) { + var ( + err error + index int + maxTimestamp uint64 + ) + + if len(nodes) == 0 { + return nil, errors.New("multi node must have at least one node") + } + + treeNodes := make([]*treeNode, len(nodes)) + + for i, node := range nodes { + if treeNodes[i] = newTreeNode(node); err != nil { + return nil, fmt.Errorf("parse tree node response: %w", err) + } + + if maxTimestamp < node.GetTimestamp() { + index = i + maxTimestamp = node.GetTimestamp() + } + } + + treeNodes[0], treeNodes[index] = treeNodes[index], treeNodes[0] + + return &multiSystemNode{ + nodes: treeNodes, + }, nil +} + +func (m *multiSystemNode) ToMFAMultiNode() *mfa.TreeMultiNode { + res := &mfa.TreeMultiNode{ + Current: mfa.TreeNode{Meta: m.nodes[0].Meta}, + Old: make([]*mfa.TreeNode, len(m.nodes[1:])), + } + + for i, node := range m.nodes[1:] { + res.Old[i] = &mfa.TreeNode{Meta: node.Meta} + } + + return res +} + +func newTreeNode(nodeInfo *apitree.GetNodeByPathResponseInfo) *treeNode { + tNode := &treeNode{ + ID: nodeInfo.GetNodeID(), + ParentID: nodeInfo.GetParentID(), + TimeStamp: nodeInfo.GetTimestamp(), + Meta: make(map[string]string, len(nodeInfo.GetMeta())), + } + + for _, kv := range nodeInfo.GetMeta() { + tNode.Meta[kv.GetKey()] = string(kv.GetValue()) + } + + return tNode +} + +// pathFromName splits name by '/'. +func pathFromName(name string) []string { + return strings.Split(name, "/") +} diff --git a/internal/logs/logs.go b/internal/logs/logs.go index 933837aa..05ff2756 100644 --- a/internal/logs/logs.go +++ b/internal/logs/logs.go @@ -164,6 +164,8 @@ const ( CouldntDeleteLifecycleObject = "couldn't delete lifecycle configuration object" CouldntCacheLifecycleConfiguration = "couldn't cache lifecycle configuration" CouldNotFetchLifecycleContainerInfo = "couldn't fetch lifecycle container info" + CouldNotFetchMFAContainerInfo = "couldn't fetch mfa container info" + CouldNotInitMFAClient = "couldn't init MFA client" BucketLifecycleNodeHasMultipleIDs = "bucket lifecycle node has multiple ids" GetBucketLifecycle = "get bucket lifecycle" WarnDuplicateNamespaceVHS = "duplicate namespace with enabled VHS, config value skipped" diff --git a/internal/mfa/mfa.go b/internal/mfa/mfa.go new file mode 100644 index 00000000..796951b2 --- /dev/null +++ b/internal/mfa/mfa.go @@ -0,0 +1,161 @@ +package mfa + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/pquerna/otp" + + cid "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/container/id" + oid "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/object/id" + "github.com/nspcc-dev/neo-go/pkg/crypto/keys" + "go.uber.org/zap" +) + +const ( + FilePathKey = "FilePath" + OIDKey = "OID" + EnableDateKey = "EnableDate" + EnabledKey = "EnabledKey" + UserIDKey = "UserIDKey" + TagPrefix = "tag-" +) + +type ( + Storage interface { + GetObject(context.Context, oid.Address) ([]byte, error) + GetTreeNode(ctx context.Context, cnrID cid.ID, name string) (*TreeMultiNode, error) + } + + TreeNode struct { + Meta map[string]string + } + + TreeMultiNode struct { + Current TreeNode + Old []*TreeNode + } +) + +type Device struct { + Namespace string + Name string + OID oid.ID + Meta map[string]string +} + +type SecretDevice struct { + Device + Key *otp.Key +} + +type MFA struct { + storage Storage + iamKey *keys.PrivateKey + container cid.ID + logger *zap.Logger + // settings Settings +} + +type Config struct { + Storage Storage + Key *keys.PrivateKey + Container cid.ID + Logger *zap.Logger + // Settings Settings +} + +func NewMFA(cfg Config) (*MFA, error) { + if cfg.Storage == nil { + return nil, errors.New("mfa storage is nil") + } + if cfg.Logger == nil { + return nil, errors.New("mfa logger is nil") + } + if cfg.Key == nil { + return nil, errors.New("mfa iam key is nil") + } + + return &MFA{ + storage: cfg.Storage, + container: cfg.Container, + iamKey: cfg.Key, + logger: cfg.Logger, + }, nil +} + +type Settings interface { + ServicePubKeys() []*keys.PublicKey +} + +func (m *MFA) GetMFADevice(ctx context.Context, ns, mfaName string) (*SecretDevice, error) { + node, err := m.storage.GetTreeNode(ctx, m.container, getTreePath(ns, mfaName)) + if err != nil { + return nil, fmt.Errorf("get mfa nodes: %w", err) + } + + var objID oid.ID + if err = objID.DecodeString(node.Current.Meta[OIDKey]); err != nil { + return nil, fmt.Errorf("decode oid '%s': %w", node.Current.Meta[OIDKey], err) + } + + var addr oid.Address + addr.SetContainer(m.container) + addr.SetObject(objID) + + boxData, err := m.storage.GetObject(ctx, addr) + if err != nil { + return nil, fmt.Errorf("get object '%s': %w", addr.EncodeToString(), err) + } + + mfaBox := new(MFABox) + if err = mfaBox.Unmarshal(boxData); err != nil { + return nil, fmt.Errorf("unmarshal box data: %w", err) + } + + secrets, err := UnpackMFABox(m.iamKey, mfaBox) + if err != nil { + return nil, fmt.Errorf("unpack mfa box: %w", err) + } + + key, err := otp.NewKeyFromURL(secrets.GetMFAURL()) + if err != nil { + return nil, err + } + + dev, err := newDevice(&node.Current) + if err != nil { + return nil, err + } + + return &SecretDevice{ + Device: *dev, + Key: key, + }, nil +} + +func newDevice(node *TreeNode) (*Device, error) { + meta := node.Meta + filepathArr := strings.Split(meta[FilePathKey], "/") + if len(filepathArr) != 2 { + return nil, fmt.Errorf("invalid device filepath: '%s'", meta[FilePathKey]) + } + + var objID oid.ID + if err := objID.DecodeString(meta[OIDKey]); err != nil { + return nil, fmt.Errorf("decode oid '%s': %w", meta[OIDKey], err) + } + + return &Device{ + Namespace: filepathArr[0], + Name: filepathArr[1], + OID: objID, + Meta: meta, + }, nil +} + +func getTreePath(ns, mfaName string) string { + return ns + "/" + mfaName +} diff --git a/internal/mfa/mfabox.go b/internal/mfa/mfabox.go new file mode 100644 index 00000000..59c3a611 --- /dev/null +++ b/internal/mfa/mfabox.go @@ -0,0 +1,247 @@ +package mfa + +import ( + "bytes" + "crypto/cipher" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha256" + "errors" + "fmt" + "io" + + "github.com/nspcc-dev/neo-go/pkg/crypto/keys" + "github.com/pquerna/otp" + "golang.org/x/crypto/chacha20poly1305" + "golang.org/x/crypto/hkdf" + "google.golang.org/protobuf/proto" +) + +const ( + secretLength = 32 + saltLength = 16 +) + +// Marshal returns the wire-format of MFABox. +func (x *MFABox) Marshal() ([]byte, error) { + return proto.Marshal(x) +} + +// Unmarshal parses the wire-format message and put data to x. +func (x *MFABox) Unmarshal(data []byte) error { + return proto.Unmarshal(data, x) +} + +func PackMFABox(key *otp.Key, iamSvcKeys []*keys.PublicKey, useSalt bool) (*MFABox, error) { + if len(iamSvcKeys) == 0 { + return nil, errors.New("list of public keys to encrypt box must not be empty") + } + + symmetricKey, err := generateRandomBytes(secretLength) + if err != nil { + return nil, fmt.Errorf("generate symmetric key: %w", err) + } + + data, err := proto.Marshal(&Secrets{MFAURL: key.URL()}) + if err != nil { + return nil, fmt.Errorf("marshal secrets: %w", err) + } + + var salt []byte + if useSalt { + salt, err = generateRandomBytes(saltLength) + if err != nil { + return nil, fmt.Errorf("generate salt for mfa secrets: %w", err) + } + } + + encryptedSecrets, err := encryptSymmetric(symmetricKey, data, salt) + if err != nil { + return nil, fmt.Errorf("failed to encrypt mfa secrets: %w", err) + } + + boxKey, err := keys.NewPrivateKey() + if err != nil { + return nil, fmt.Errorf("create ephemeral key: %w", err) + } + + iamSvcEncrypted, err := encryptedSymmetricKeyForEachIAMSvc(boxKey, iamSvcKeys, symmetricKey, useSalt) + if err != nil { + return nil, fmt.Errorf("failed to add tokens to mfabox: %w", err) + } + + return &MFABox{ + IAMServices: iamSvcEncrypted, + SeedPublicKey: boxKey.PublicKey().Bytes(), + EncryptedSecrets: encryptedSecrets, + SecretsSalt: salt, + }, err +} + +func UnpackMFABox(iamPrivateKey *keys.PrivateKey, box *MFABox) (*Secrets, error) { + iamPublicKeyBytes := iamPrivateKey.PublicKey().Bytes() + boxPublicKey, err := keys.NewPublicKeyFromBytes(box.SeedPublicKey, elliptic.P256()) + if err != nil { + return nil, err + } + + for _, iamSvc := range box.IAMServices { + if !bytes.Equal(iamPublicKeyBytes, iamSvc.GetSvcPublicKey()) { + continue + } + + symmetricKey, err := decryptECDH(iamPrivateKey, boxPublicKey, iamSvc.GetEncryptedSymmetricKey(), iamSvc.SymmetricKeySalt) + if err != nil { + return nil, fmt.Errorf("decrypt symmetric key: %w", err) + } + + secretsData, err := decryptSymmetric(symmetricKey, box.GetEncryptedSecrets(), box.SecretsSalt) + if err != nil { + return nil, fmt.Errorf("decrypt secrets: %w", err) + } + + secrets := new(Secrets) + if err = proto.Unmarshal(secretsData, secrets); err != nil { + return nil, fmt.Errorf("unmarshal secrets: %w", err) + } + return secrets, nil + } + + return nil, fmt.Errorf("no box data for key '%x' was found", iamPublicKeyBytes) +} + +func encryptedSymmetricKeyForEachIAMSvc(boxKey *keys.PrivateKey, iamSvcKeys []*keys.PublicKey, symmetricKey []byte, useSalt bool) ([]*MFABox_IAMService, error) { + var err error + + res := make([]*MFABox_IAMService, len(iamSvcKeys)) + for i, iamKey := range iamSvcKeys { + var salt []byte + if useSalt { + salt, err = generateRandomBytes(saltLength) + if err != nil { + return nil, fmt.Errorf("generate salt for symmetric key: %w", err) + } + } + + res[i], err = encryptSymmetricKeyForIAMSvc(boxKey, iamKey, symmetricKey, salt) + if err != nil { + return nil, fmt.Errorf("encode symmetric key for iam svc: %w", err) + } + } + + return res, nil +} + +func encryptSymmetricKeyForIAMSvc(boxKey *keys.PrivateKey, iamPublicKey *keys.PublicKey, symmetricKey, salt []byte) (*MFABox_IAMService, error) { + encrypted, err := encryptECDH(boxKey, iamPublicKey, symmetricKey, salt) + if err != nil { + return nil, fmt.Errorf("encrypt symmetricKey: %w", err) + } + + s3svc := new(MFABox_IAMService) + s3svc.SvcPublicKey = iamPublicKey.Bytes() + s3svc.EncryptedSymmetricKey = encrypted + s3svc.SymmetricKeySalt = salt + return s3svc, nil +} + +func encryptECDH(boxPrivateKey *keys.PrivateKey, iamPublicKey *keys.PublicKey, data, salt []byte) ([]byte, error) { + enc, err := getCipherECDH(boxPrivateKey, iamPublicKey, salt) + if err != nil { + return nil, fmt.Errorf("get chiper ecdh: %w", err) + } + + return encrypt(enc, data) +} + +func decryptECDH(iamPrivateKey *keys.PrivateKey, boxPublicKey *keys.PublicKey, data, salt []byte) ([]byte, error) { + dec, err := getCipherECDH(iamPrivateKey, boxPublicKey, salt) + if err != nil { + return nil, fmt.Errorf("get chiper ecdh: %w", err) + } + + return decrypt(dec, data) +} + +func encryptSymmetric(secret, data, salt []byte) ([]byte, error) { + enc, err := getCipher(secret, salt) + if err != nil { + return nil, fmt.Errorf("get chiper: %w", err) + } + + return encrypt(enc, data) +} + +func decryptSymmetric(secret, data, salt []byte) ([]byte, error) { + dec, err := getCipher(secret, salt) + if err != nil { + return nil, fmt.Errorf("get chiper: %w", err) + } + + return decrypt(dec, data) +} + +func encrypt(enc cipher.AEAD, data []byte) ([]byte, error) { + nonce := make([]byte, enc.NonceSize()) + if _, err := rand.Read(nonce); err != nil { + return nil, fmt.Errorf("generate random nonce: %w", err) + } + + return enc.Seal(nonce, nonce, data, nil), nil +} + +func decrypt(dec cipher.AEAD, data []byte) ([]byte, error) { + if ld, ns := len(data), dec.NonceSize(); ld < ns { + return nil, fmt.Errorf("wrong data size (%d), should be greater than %d", ld, ns) + } + + nonce, cypher := data[:dec.NonceSize()], data[dec.NonceSize():] + return dec.Open(nil, nonce, cypher, nil) +} + +func getCipherECDH(owner *keys.PrivateKey, sender *keys.PublicKey, salt []byte) (cipher.AEAD, error) { + secret, err := generateECDH(owner, sender) + if err != nil { + return nil, fmt.Errorf("generate shared key: %w", err) + } + + return getCipher(secret, salt) +} + +func getCipher(secret, salt []byte) (cipher.AEAD, error) { + key, err := deriveKey(secret, salt) + if err != nil { + return nil, fmt.Errorf("derive key: %w", err) + } + + return chacha20poly1305.NewX(key) +} + +func generateECDH(prv *keys.PrivateKey, pub *keys.PublicKey) (sk []byte, err error) { + prvECDH, err := prv.ECDH() + if err != nil { + return nil, fmt.Errorf("invalid ECDH private key: %w", err) + } + + pubECDH, err := (*ecdsa.PublicKey)(pub).ECDH() + if err != nil { + return nil, fmt.Errorf("invalid ECDH public key: %w", err) + } + + return prvECDH.ECDH(pubECDH) +} + +func deriveKey(secret, salt []byte) ([]byte, error) { + hash := sha256.New + kdf := hkdf.New(hash, secret, salt, nil) + key := make([]byte, 32) + _, err := io.ReadFull(kdf, key) + return key, err +} + +func generateRandomBytes(length int) ([]byte, error) { + b := make([]byte, length) + _, err := rand.Read(b) + return b, err +} diff --git a/internal/mfa/mfabox.pb.go b/internal/mfa/mfabox.pb.go new file mode 100644 index 00000000..bc040bcd --- /dev/null +++ b/internal/mfa/mfabox.pb.go @@ -0,0 +1,333 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.34.2 +// protoc v3.21.9 +// source: internal/mfa/mfabox.proto + +package mfa + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type MFABox struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // iam-service, contains iam keys and encrypted data for that keys + IAMServices []*MFABox_IAMService `protobuf:"bytes,1,rep,name=IAMServices,json=iamServices,proto3" json:"IAMServices,omitempty"` + // Seed public key for asymmetric encryption of IAMServicesKey + SeedPublicKey []byte `protobuf:"bytes,2,opt,name=seedPublicKey,proto3" json:"seedPublicKey,omitempty"` + // MFA secrets, which are encrypted by symmetric cipher + EncryptedSecrets []byte `protobuf:"bytes,3,opt,name=encryptedSecrets,proto3" json:"encryptedSecrets,omitempty"` + // salt used to derivation encrypted key to encrypt/decrypt MFA secrets + SecretsSalt []byte `protobuf:"bytes,4,opt,name=secretsSalt,proto3" json:"secretsSalt,omitempty"` +} + +func (x *MFABox) Reset() { + *x = MFABox{} + if protoimpl.UnsafeEnabled { + mi := &file_internal_mfa_mfabox_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *MFABox) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MFABox) ProtoMessage() {} + +func (x *MFABox) ProtoReflect() protoreflect.Message { + mi := &file_internal_mfa_mfabox_proto_msgTypes[0] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use MFABox.ProtoReflect.Descriptor instead. +func (*MFABox) Descriptor() ([]byte, []int) { + return file_internal_mfa_mfabox_proto_rawDescGZIP(), []int{0} +} + +func (x *MFABox) GetIAMServices() []*MFABox_IAMService { + if x != nil { + return x.IAMServices + } + return nil +} + +func (x *MFABox) GetSeedPublicKey() []byte { + if x != nil { + return x.SeedPublicKey + } + return nil +} + +func (x *MFABox) GetEncryptedSecrets() []byte { + if x != nil { + return x.EncryptedSecrets + } + return nil +} + +func (x *MFABox) GetSecretsSalt() []byte { + if x != nil { + return x.SecretsSalt + } + return nil +} + +type Secrets struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // MFA URL + MFAURL string `protobuf:"bytes,2,opt,name=MFAURL,json=mfaURL,proto3" json:"MFAURL,omitempty"` +} + +func (x *Secrets) Reset() { + *x = Secrets{} + if protoimpl.UnsafeEnabled { + mi := &file_internal_mfa_mfabox_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *Secrets) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Secrets) ProtoMessage() {} + +func (x *Secrets) ProtoReflect() protoreflect.Message { + mi := &file_internal_mfa_mfabox_proto_msgTypes[1] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Secrets.ProtoReflect.Descriptor instead. +func (*Secrets) Descriptor() ([]byte, []int) { + return file_internal_mfa_mfabox_proto_rawDescGZIP(), []int{1} +} + +func (x *Secrets) GetMFAURL() string { + if x != nil { + return x.MFAURL + } + return "" +} + +type MFABox_IAMService struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // iam-service public key + SvcPublicKey []byte `protobuf:"bytes,1,opt,name=svcPublicKey,proto3" json:"svcPublicKey,omitempty"` + // encrypted symmetric key used to encrypt/decrypt mfa secrets + EncryptedSymmetricKey []byte `protobuf:"bytes,2,opt,name=encryptedSymmetricKey,proto3" json:"encryptedSymmetricKey,omitempty"` + // salt used to derivation encrypted key to encrypt/decrypt symmetricKey + SymmetricKeySalt []byte `protobuf:"bytes,3,opt,name=symmetricKeySalt,proto3" json:"symmetricKeySalt,omitempty"` +} + +func (x *MFABox_IAMService) Reset() { + *x = MFABox_IAMService{} + if protoimpl.UnsafeEnabled { + mi := &file_internal_mfa_mfabox_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *MFABox_IAMService) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MFABox_IAMService) ProtoMessage() {} + +func (x *MFABox_IAMService) ProtoReflect() protoreflect.Message { + mi := &file_internal_mfa_mfabox_proto_msgTypes[2] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use MFABox_IAMService.ProtoReflect.Descriptor instead. +func (*MFABox_IAMService) Descriptor() ([]byte, []int) { + return file_internal_mfa_mfabox_proto_rawDescGZIP(), []int{0, 0} +} + +func (x *MFABox_IAMService) GetSvcPublicKey() []byte { + if x != nil { + return x.SvcPublicKey + } + return nil +} + +func (x *MFABox_IAMService) GetEncryptedSymmetricKey() []byte { + if x != nil { + return x.EncryptedSymmetricKey + } + return nil +} + +func (x *MFABox_IAMService) GetSymmetricKeySalt() []byte { + if x != nil { + return x.SymmetricKeySalt + } + return nil +} + +var File_internal_mfa_mfabox_proto protoreflect.FileDescriptor + +var file_internal_mfa_mfabox_proto_rawDesc = []byte{ + 0x0a, 0x19, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x6d, 0x66, 0x61, 0x2f, 0x6d, + 0x66, 0x61, 0x62, 0x6f, 0x78, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x03, 0x6d, 0x66, 0x61, + 0x22, 0xcb, 0x02, 0x0a, 0x06, 0x4d, 0x46, 0x41, 0x42, 0x6f, 0x78, 0x12, 0x38, 0x0a, 0x0b, 0x49, + 0x41, 0x4d, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, + 0x32, 0x16, 0x2e, 0x6d, 0x66, 0x61, 0x2e, 0x4d, 0x46, 0x41, 0x42, 0x6f, 0x78, 0x2e, 0x49, 0x41, + 0x4d, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x52, 0x0b, 0x69, 0x61, 0x6d, 0x53, 0x65, 0x72, + 0x76, 0x69, 0x63, 0x65, 0x73, 0x12, 0x24, 0x0a, 0x0d, 0x73, 0x65, 0x65, 0x64, 0x50, 0x75, 0x62, + 0x6c, 0x69, 0x63, 0x4b, 0x65, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0d, 0x73, 0x65, + 0x65, 0x64, 0x50, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x4b, 0x65, 0x79, 0x12, 0x2a, 0x0a, 0x10, 0x65, + 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x53, 0x65, 0x63, 0x72, 0x65, 0x74, 0x73, 0x18, + 0x03, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x10, 0x65, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, + 0x53, 0x65, 0x63, 0x72, 0x65, 0x74, 0x73, 0x12, 0x20, 0x0a, 0x0b, 0x73, 0x65, 0x63, 0x72, 0x65, + 0x74, 0x73, 0x53, 0x61, 0x6c, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0b, 0x73, 0x65, + 0x63, 0x72, 0x65, 0x74, 0x73, 0x53, 0x61, 0x6c, 0x74, 0x1a, 0x92, 0x01, 0x0a, 0x0a, 0x49, 0x41, + 0x4d, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x22, 0x0a, 0x0c, 0x73, 0x76, 0x63, 0x50, + 0x75, 0x62, 0x6c, 0x69, 0x63, 0x4b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0c, + 0x73, 0x76, 0x63, 0x50, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x4b, 0x65, 0x79, 0x12, 0x34, 0x0a, 0x15, + 0x65, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x53, 0x79, 0x6d, 0x6d, 0x65, 0x74, 0x72, + 0x69, 0x63, 0x4b, 0x65, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x15, 0x65, 0x6e, 0x63, + 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x53, 0x79, 0x6d, 0x6d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x4b, + 0x65, 0x79, 0x12, 0x2a, 0x0a, 0x10, 0x73, 0x79, 0x6d, 0x6d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x4b, + 0x65, 0x79, 0x53, 0x61, 0x6c, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x10, 0x73, 0x79, + 0x6d, 0x6d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x4b, 0x65, 0x79, 0x53, 0x61, 0x6c, 0x74, 0x22, 0x21, + 0x0a, 0x07, 0x53, 0x65, 0x63, 0x72, 0x65, 0x74, 0x73, 0x12, 0x16, 0x0a, 0x06, 0x4d, 0x46, 0x41, + 0x55, 0x52, 0x4c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x6d, 0x66, 0x61, 0x55, 0x52, + 0x4c, 0x42, 0x19, 0x5a, 0x17, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x6d, 0x66, + 0x61, 0x2f, 0x6d, 0x66, 0x61, 0x62, 0x6f, 0x78, 0x3b, 0x6d, 0x66, 0x61, 0x62, 0x06, 0x70, 0x72, + 0x6f, 0x74, 0x6f, 0x33, +} + +var ( + file_internal_mfa_mfabox_proto_rawDescOnce sync.Once + file_internal_mfa_mfabox_proto_rawDescData = file_internal_mfa_mfabox_proto_rawDesc +) + +func file_internal_mfa_mfabox_proto_rawDescGZIP() []byte { + file_internal_mfa_mfabox_proto_rawDescOnce.Do(func() { + file_internal_mfa_mfabox_proto_rawDescData = protoimpl.X.CompressGZIP(file_internal_mfa_mfabox_proto_rawDescData) + }) + return file_internal_mfa_mfabox_proto_rawDescData +} + +var file_internal_mfa_mfabox_proto_msgTypes = make([]protoimpl.MessageInfo, 3) +var file_internal_mfa_mfabox_proto_goTypes = []interface{}{ + (*MFABox)(nil), // 0: mfa.MFABox + (*Secrets)(nil), // 1: mfa.Secrets + (*MFABox_IAMService)(nil), // 2: mfa.MFABox.IAMService +} +var file_internal_mfa_mfabox_proto_depIdxs = []int32{ + 2, // 0: mfa.MFABox.IAMServices:type_name -> mfa.MFABox.IAMService + 1, // [1:1] is the sub-list for method output_type + 1, // [1:1] is the sub-list for method input_type + 1, // [1:1] is the sub-list for extension type_name + 1, // [1:1] is the sub-list for extension extendee + 0, // [0:1] is the sub-list for field type_name +} + +func init() { file_internal_mfa_mfabox_proto_init() } +func file_internal_mfa_mfabox_proto_init() { + if File_internal_mfa_mfabox_proto != nil { + return + } + if !protoimpl.UnsafeEnabled { + file_internal_mfa_mfabox_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*MFABox); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_internal_mfa_mfabox_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*Secrets); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_internal_mfa_mfabox_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*MFABox_IAMService); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: file_internal_mfa_mfabox_proto_rawDesc, + NumEnums: 0, + NumMessages: 3, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_internal_mfa_mfabox_proto_goTypes, + DependencyIndexes: file_internal_mfa_mfabox_proto_depIdxs, + MessageInfos: file_internal_mfa_mfabox_proto_msgTypes, + }.Build() + File_internal_mfa_mfabox_proto = out.File + file_internal_mfa_mfabox_proto_rawDesc = nil + file_internal_mfa_mfabox_proto_goTypes = nil + file_internal_mfa_mfabox_proto_depIdxs = nil +} diff --git a/internal/mfa/mfabox.proto b/internal/mfa/mfabox.proto new file mode 100644 index 00000000..9d2762d2 --- /dev/null +++ b/internal/mfa/mfabox.proto @@ -0,0 +1,34 @@ +syntax = "proto3"; + +package mfa; + +option go_package = "internal/mfa/mfabox;mfa"; + +message MFABox { + + message IAMService { + // iam-service public key + bytes svcPublicKey = 1 [json_name = "svcPublicKey"]; + // encrypted symmetric key used to encrypt/decrypt mfa secrets + bytes encryptedSymmetricKey = 2 [json_name = "encryptedSymmetricKey"]; + // salt used to derivation encrypted key to encrypt/decrypt symmetricKey + bytes symmetricKeySalt = 3 [json_name = "symmetricKeySalt"]; + } + + // iam-service, contains iam keys and encrypted data for that keys + repeated IAMService IAMServices = 1 [json_name = "iamServices"]; + + // Seed public key for asymmetric encryption of IAMServicesKey + bytes seedPublicKey = 2 [json_name = "seedPublicKey"]; + + // MFA secrets, which are encrypted by symmetric cipher + bytes encryptedSecrets = 3 [json_name = "encryptedSecrets"]; + + // salt used to derivation encrypted key to encrypt/decrypt MFA secrets + bytes secretsSalt = 4 [json_name = "secretsSalt"]; +} + +message Secrets { + // MFA URL + string MFAURL = 2 [json_name = "mfaURL"]; +} diff --git a/pkg/service/tree/tree.go b/pkg/service/tree/tree.go index 862c0a3c..bbe716b9 100644 --- a/pkg/service/tree/tree.go +++ b/pkg/service/tree/tree.go @@ -89,6 +89,8 @@ var ( const ( versioningKV = "Versioning" + mfaDeleteEnabledKV = "MFADelete" + mfaSerialNumberKV = "SerialNumber" cannedACLKV = "cannedACL" ownerKeyKV = "ownerKey" lockConfigurationKV = "LockConfiguration" @@ -500,9 +502,20 @@ func (c *Tree) GetSettingsNode(ctx context.Context, bktInfo *data.BucketInfo) (* node := multiNode.Latest() - settings := &data.BucketSettings{Versioning: data.VersioningUnversioned} + settings := &data.BucketSettings{Versioning: data.Versioning{ + VersioningStatus: data.VersioningUnversioned, + MFADeleteStatus: data.MFADeleteDisabled, + }} if versioningValue, ok := node.Get(versioningKV); ok { - settings.Versioning = versioningValue + settings.Versioning.VersioningStatus = versioningValue + } + if mfaDeleteValue, ok := node.Get(mfaDeleteEnabledKV); ok { + settings.Versioning.MFADeleteStatus = mfaDeleteValue + if settings.MFADeleteEnabled() { + if mfaSerialNumberValue, ok := node.Get(mfaSerialNumberKV); ok { + settings.Versioning.MFASerialNumber = mfaSerialNumberValue + } + } } if lockConfigurationValue, ok := node.Get(lockConfigurationKV); ok { @@ -1789,7 +1802,8 @@ func metaFromSettings(settings *data.BucketSettings) map[string]string { results := make(map[string]string, 3) results[FileNameKey] = settingsFileName - results[versioningKV] = settings.Versioning + results[versioningKV] = settings.Versioning.VersioningStatus + results[mfaDeleteEnabledKV] = settings.Versioning.MFADeleteStatus results[lockConfigurationKV] = encodeLockConfiguration(settings.LockConfiguration) results[cannedACLKV] = settings.CannedACL if settings.OwnerKey != nil { diff --git a/pkg/service/tree/tree_test.go b/pkg/service/tree/tree_test.go index 5b7b6656..e4af5d36 100644 --- a/pkg/service/tree/tree_test.go +++ b/pkg/service/tree/tree_test.go @@ -119,7 +119,10 @@ func TestTreeServiceSettings(t *testing.T) { require.NoError(t, err) settings := &data.BucketSettings{ - Versioning: "Versioning", + Versioning: data.Versioning{ + VersioningStatus: data.VersioningEnabled, + MFADeleteStatus: data.MFADeleteDisabled, + }, LockConfiguration: &data.ObjectLockConfiguration{ ObjectLockEnabled: "Enabled", Rule: &data.ObjectLockRule{