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{