From 9462aea03df330582baac4f7b9e92fee83bc37da Mon Sep 17 00:00:00 2001 From: Alex Vanin Date: Thu, 13 Mar 2025 11:57:16 +0300 Subject: [PATCH 1/5] [#1] Add initial implementation of MFA library Signed-off-by: Alex Vanin --- Makefile | 34 ++++ go.mod | 46 ++++++ go.sum | 307 +++++++++++++++++++++++++++++++++++ mfa/box.go | 15 ++ mfa/device.go | 130 +++++++++++++++ mfa/errors.go | 8 + mfa/mfa.go | 410 +++++++++++++++++++++++++++++++++++++++++++++++ mfa/mfabox.pb.go | 289 +++++++++++++++++++++++++++++++++ mfa/mfabox.proto | 44 +++++ mfa/pack.go | 229 ++++++++++++++++++++++++++ mfa/pack_test.go | 105 ++++++++++++ mfa/storage.go | 56 +++++++ 12 files changed, 1673 insertions(+) create mode 100644 Makefile create mode 100644 go.mod create mode 100644 go.sum create mode 100644 mfa/box.go create mode 100644 mfa/device.go create mode 100644 mfa/errors.go create mode 100644 mfa/mfa.go create mode 100644 mfa/mfabox.pb.go create mode 100644 mfa/mfabox.proto create mode 100644 mfa/pack.go create mode 100644 mfa/pack_test.go create mode 100644 mfa/storage.go diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..4d6c63a --- /dev/null +++ b/Makefile @@ -0,0 +1,34 @@ +GOBIN ?= $(shell go env GOPATH)/bin + +PROTOC_VERSION ?= 25.6 +PROTOC_ARCH = linux-x86_64 +PROTOC = ./bin/protoc/bin/protoc + +UNAME = "$(shell uname)/$(shell uname -m)" +ifeq ($(UNAME), "Darwin/arm64") + PROTOC_ARCH = osx-aarch_64 +endif +ifeq ($(UNAME), "Darwin/x86_64") + PROTOC_ARCH = osx-x86_64 +endif + +PROTOC_URL ?= "https://github.com/protocolbuffers/protobuf/releases/download/v$(PROTOC_VERSION)/protoc-$(PROTOC_VERSION)-$(PROTOC_ARCH).zip" + +# Prepare binaries to generate protobuf files +.PHONY: protoc-bin +protoc-bin: +ifeq (,$(wildcard ./bin/protoc)) + curl --create-dirs -o ./bin/protoc.zip -L'#' $(PROTOC_URL) + unzip ./bin/protoc.zip -d ./bin/protoc + rm -r ./bin/protoc.zip ./bin/protoc/include ./bin/protoc/readme.txt +endif + +# Generate protobuf file +.PHONY: protoc +protoc: protoc-bin + @go list -f '{{.Path}}/...@{{.Version}}' -m google.golang.org/protobuf | xargs echo go install -v + @for f in `find . -type f -name '*.proto'`; do \ + echo "> Processing $$f "; \ + $(PROTOC) --experimental_editions --plugin=protoc-gen-go=$(GOBIN)/protoc-gen-go --go_out=. --go_opt=paths=source_relative $$f \ + --plugin=protoc-gen-go-grpc=$(GOBIN)/protoc-gen-go-grpc --go-grpc_out=. --go-grpc_opt=require_unimplemented_servers=false,paths=source_relative $$f; \ + done diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..1e04df1 --- /dev/null +++ b/go.mod @@ -0,0 +1,46 @@ +module git.frostfs.info/truecloudlab/frostfs-mfa + +go 1.22.0 + +require ( + git.frostfs.info/TrueCloudLab/frostfs-observability v0.0.0-20250212111929-d34e1329c824 + git.frostfs.info/TrueCloudLab/frostfs-sdk-go v0.0.0-20250310162458-a262a0038f7d + github.com/nspcc-dev/neo-go v0.106.3 + github.com/pquerna/otp v1.4.0 + github.com/stretchr/testify v1.10.0 + go.uber.org/zap v1.27.0 + golang.org/x/crypto v0.33.0 + google.golang.org/protobuf v1.36.5 +) + +require ( + git.frostfs.info/TrueCloudLab/frostfs-crypto v0.6.0 // indirect + git.frostfs.info/TrueCloudLab/rfc6979 v0.4.0 // indirect + github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect + github.com/go-logr/logr v1.4.2 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0 // indirect + github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect + github.com/mr-tron/base58 v1.2.0 // indirect + github.com/nspcc-dev/rfc6979 v0.2.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + go.opentelemetry.io/otel v1.31.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.28.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.28.0 // indirect + go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.28.0 // indirect + go.opentelemetry.io/otel/metric v1.31.0 // indirect + go.opentelemetry.io/otel/sdk v1.31.0 // indirect + go.opentelemetry.io/otel/trace v1.31.0 // indirect + go.opentelemetry.io/proto/otlp v1.3.1 // indirect + go.uber.org/multierr v1.11.0 // indirect + golang.org/x/net v0.30.0 // indirect + golang.org/x/sys v0.30.0 // indirect + golang.org/x/text v0.22.0 // indirect + google.golang.org/genproto v0.0.0-20230306155012-7f2fa6fef1f4 // indirect + google.golang.org/grpc v1.69.2 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a15c87a --- /dev/null +++ b/go.sum @@ -0,0 +1,307 @@ +cel.dev/expr v0.16.2/go.mod h1:gXngZQMkWJoSbE8mOzehJlXQyubn/Vg0vR9/F3W7iw8= +cloud.google.com/go v0.110.0/go.mod h1:SJnCLqQ0FCFGSZMUNUf84MV3Aia54kn7pi8st7tMzaY= +cloud.google.com/go/accessapproval v1.6.0/go.mod h1:R0EiYnwV5fsRFiKZkPHr6mwyk2wxUJ30nL4j2pcFY2E= +cloud.google.com/go/accesscontextmanager v1.6.0/go.mod h1:8XCvZWfYw3K/ji0iVnp+6pu7huxoQTLmxAbVjbloTtM= +cloud.google.com/go/aiplatform v1.35.0/go.mod h1:7MFT/vCaOyZT/4IIFfxH4ErVg/4ku6lKv3w0+tFTgXQ= +cloud.google.com/go/analytics v0.18.0/go.mod h1:ZkeHGQlcIPkw0R/GW+boWHhCOR43xz9RN/jn7WcqfIE= +cloud.google.com/go/apigateway v1.5.0/go.mod h1:GpnZR3Q4rR7LVu5951qfXPJCHquZt02jf7xQx7kpqN8= +cloud.google.com/go/apigeeconnect v1.5.0/go.mod h1:KFaCqvBRU6idyhSNyn3vlHXc8VMDJdRmwDF6JyFRqZ8= +cloud.google.com/go/apigeeregistry v0.5.0/go.mod h1:YR5+s0BVNZfVOUkMa5pAR2xGd0A473vA5M7j247o1wM= +cloud.google.com/go/apikeys v0.5.0/go.mod h1:5aQfwY4D+ewMMWScd3hm2en3hCj+BROlyrt3ytS7KLI= +cloud.google.com/go/appengine v1.6.0/go.mod h1:hg6i0J/BD2cKmDJbaFSYHFyZkgBEfQrDg/X0V5fJn84= +cloud.google.com/go/area120 v0.7.1/go.mod h1:j84i4E1RboTWjKtZVWXPqvK5VHQFJRF2c1Nm69pWm9k= +cloud.google.com/go/artifactregistry v1.11.2/go.mod h1:nLZns771ZGAwVLzTX/7Al6R9ehma4WUEhZGWV6CeQNQ= +cloud.google.com/go/asset v1.11.1/go.mod h1:fSwLhbRvC9p9CXQHJ3BgFeQNM4c9x10lqlrdEUYXlJo= +cloud.google.com/go/assuredworkloads v1.10.0/go.mod h1:kwdUQuXcedVdsIaKgKTp9t0UJkE5+PAVNhdQm4ZVq2E= +cloud.google.com/go/automl v1.12.0/go.mod h1:tWDcHDp86aMIuHmyvjuKeeHEGq76lD7ZqfGLN6B0NuU= +cloud.google.com/go/baremetalsolution v0.5.0/go.mod h1:dXGxEkmR9BMwxhzBhV0AioD0ULBmuLZI8CdwalUxuss= +cloud.google.com/go/batch v0.7.0/go.mod h1:vLZN95s6teRUqRQ4s3RLDsH8PvboqBK+rn1oevL159g= +cloud.google.com/go/beyondcorp v0.4.0/go.mod h1:3ApA0mbhHx6YImmuubf5pyW8srKnCEPON32/5hj+RmM= +cloud.google.com/go/bigquery v1.48.0/go.mod h1:QAwSz+ipNgfL5jxiaK7weyOhzdoAy1zFm0Nf1fysJac= +cloud.google.com/go/billing v1.12.0/go.mod h1:yKrZio/eu+okO/2McZEbch17O5CB5NpZhhXG6Z766ss= +cloud.google.com/go/binaryauthorization v1.5.0/go.mod h1:OSe4OU1nN/VswXKRBmciKpo9LulY41gch5c68htf3/Q= +cloud.google.com/go/certificatemanager v1.6.0/go.mod h1:3Hh64rCKjRAX8dXgRAyOcY5vQ/fE1sh8o+Mdd6KPgY8= +cloud.google.com/go/channel v1.11.0/go.mod h1:IdtI0uWGqhEeatSB62VOoJ8FSUhJ9/+iGkJVqp74CGE= +cloud.google.com/go/cloudbuild v1.7.0/go.mod h1:zb5tWh2XI6lR9zQmsm1VRA+7OCuve5d8S+zJUul8KTg= +cloud.google.com/go/clouddms v1.5.0/go.mod h1:QSxQnhikCLUw13iAbffF2CZxAER3xDGNHjsTAkQJcQA= +cloud.google.com/go/cloudtasks v1.9.0/go.mod h1:w+EyLsVkLWHcOaqNEyvcKAsWp9p29dL6uL9Nst1cI7Y= +cloud.google.com/go/compute v1.18.0/go.mod h1:1X7yHxec2Ga+Ss6jPyjxRxpu2uu7PLgsOVXvgU0yacs= +cloud.google.com/go/compute/metadata v0.5.2/go.mod h1:C66sj2AluDcIqakBq/M8lw8/ybHgOZqin2obFxa/E5k= +cloud.google.com/go/contactcenterinsights v1.6.0/go.mod h1:IIDlT6CLcDoyv79kDv8iWxMSTZhLxSCofVV5W6YFM/w= +cloud.google.com/go/container v1.13.1/go.mod h1:6wgbMPeQRw9rSnKBCAJXnds3Pzj03C4JHamr8asWKy4= +cloud.google.com/go/containeranalysis v0.7.0/go.mod h1:9aUL+/vZ55P2CXfuZjS4UjQ9AgXoSw8Ts6lemfmxBxI= +cloud.google.com/go/datacatalog v1.12.0/go.mod h1:CWae8rFkfp6LzLumKOnmVh4+Zle4A3NXLzVJ1d1mRm0= +cloud.google.com/go/dataflow v0.8.0/go.mod h1:Rcf5YgTKPtQyYz8bLYhFoIV/vP39eL7fWNcSOyFfLJE= +cloud.google.com/go/dataform v0.6.0/go.mod h1:QPflImQy33e29VuapFdf19oPbE4aYTJxr31OAPV+ulA= +cloud.google.com/go/datafusion v1.6.0/go.mod h1:WBsMF8F1RhSXvVM8rCV3AeyWVxcC2xY6vith3iw3S+8= +cloud.google.com/go/datalabeling v0.7.0/go.mod h1:WPQb1y08RJbmpM3ww0CSUAGweL0SxByuW2E+FU+wXcM= +cloud.google.com/go/dataplex v1.5.2/go.mod h1:cVMgQHsmfRoI5KFYq4JtIBEUbYwc3c7tXmIDhRmNNVQ= +cloud.google.com/go/dataproc v1.12.0/go.mod h1:zrF3aX0uV3ikkMz6z4uBbIKyhRITnxvr4i3IjKsKrw4= +cloud.google.com/go/dataqna v0.7.0/go.mod h1:Lx9OcIIeqCrw1a6KdO3/5KMP1wAmTc0slZWwP12Qq3c= +cloud.google.com/go/datastore v1.10.0/go.mod h1:PC5UzAmDEkAmkfaknstTYbNpgE49HAgW2J1gcgUfmdM= +cloud.google.com/go/datastream v1.6.0/go.mod h1:6LQSuswqLa7S4rPAOZFVjHIG3wJIjZcZrw8JDEDJuIs= +cloud.google.com/go/deploy v1.6.0/go.mod h1:f9PTHehG/DjCom3QH0cntOVRm93uGBDt2vKzAPwpXQI= +cloud.google.com/go/dialogflow v1.31.0/go.mod h1:cuoUccuL1Z+HADhyIA7dci3N5zUssgpBJmCzI6fNRB4= +cloud.google.com/go/dlp v1.9.0/go.mod h1:qdgmqgTyReTz5/YNSSuueR8pl7hO0o9bQ39ZhtgkWp4= +cloud.google.com/go/documentai v1.16.0/go.mod h1:o0o0DLTEZ+YnJZ+J4wNfTxmDVyrkzFvttBXXtYRMHkM= +cloud.google.com/go/domains v0.8.0/go.mod h1:M9i3MMDzGFXsydri9/vW+EWz9sWb4I6WyHqdlAk0idE= +cloud.google.com/go/edgecontainer v0.3.0/go.mod h1:FLDpP4nykgwwIfcLt6zInhprzw0lEi2P1fjO6Ie0qbc= +cloud.google.com/go/errorreporting v0.3.0/go.mod h1:xsP2yaAp+OAW4OIm60An2bbLpqIhKXdWR/tawvl7QzU= +cloud.google.com/go/essentialcontacts v1.5.0/go.mod h1:ay29Z4zODTuwliK7SnX8E86aUF2CTzdNtvv42niCX0M= +cloud.google.com/go/eventarc v1.10.0/go.mod h1:u3R35tmZ9HvswGRBnF48IlYgYeBcPUCjkr4BTdem2Kw= +cloud.google.com/go/filestore v1.5.0/go.mod h1:FqBXDWBp4YLHqRnVGveOkHDf8svj9r5+mUDLupOWEDs= +cloud.google.com/go/firestore v1.9.0/go.mod h1:HMkjKHNTtRyZNiMzu7YAsLr9K3X2udY2AMwDaMEQiiE= +cloud.google.com/go/functions v1.10.0/go.mod h1:0D3hEOe3DbEvCXtYOZHQZmD+SzYsi1YbI7dGvHfldXw= +cloud.google.com/go/gaming v1.9.0/go.mod h1:Fc7kEmCObylSWLO334NcO+O9QMDyz+TKC4v1D7X+Bc0= +cloud.google.com/go/gkebackup v0.4.0/go.mod h1:byAyBGUwYGEEww7xsbnUTBHIYcOPy/PgUWUtOeRm9Vg= +cloud.google.com/go/gkeconnect v0.7.0/go.mod h1:SNfmVqPkaEi3bF/B3CNZOAYPYdg7sU+obZ+QTky2Myw= +cloud.google.com/go/gkehub v0.11.0/go.mod h1:JOWHlmN+GHyIbuWQPl47/C2RFhnFKH38jH9Ascu3n0E= +cloud.google.com/go/gkemulticloud v0.5.0/go.mod h1:W0JDkiyi3Tqh0TJr//y19wyb1yf8llHVto2Htf2Ja3Y= +cloud.google.com/go/gsuiteaddons v1.5.0/go.mod h1:TFCClYLd64Eaa12sFVmUyG62tk4mdIsI7pAnSXRkcFo= +cloud.google.com/go/iam v0.12.0/go.mod h1:knyHGviacl11zrtZUoDuYpDgLjvr28sLQaG0YB2GYAY= +cloud.google.com/go/iap v1.6.0/go.mod h1:NSuvI9C/j7UdjGjIde7t7HBz+QTwBcapPE07+sSRcLk= +cloud.google.com/go/ids v1.3.0/go.mod h1:JBdTYwANikFKaDP6LtW5JAi4gubs57SVNQjemdt6xV4= +cloud.google.com/go/iot v1.5.0/go.mod h1:mpz5259PDl3XJthEmh9+ap0affn/MqNSP4My77Qql9o= +cloud.google.com/go/kms v1.9.0/go.mod h1:qb1tPTgfF9RQP8e1wq4cLFErVuTJv7UsSC915J8dh3w= +cloud.google.com/go/language v1.9.0/go.mod h1:Ns15WooPM5Ad/5no/0n81yUetis74g3zrbeJBE+ptUY= +cloud.google.com/go/lifesciences v0.8.0/go.mod h1:lFxiEOMqII6XggGbOnKiyZ7IBwoIqA84ClvoezaA/bo= +cloud.google.com/go/logging v1.7.0/go.mod h1:3xjP2CjkM3ZkO73aj4ASA5wRPGGCRrPIAeNqVNkzY8M= +cloud.google.com/go/longrunning v0.4.1/go.mod h1:4iWDqhBZ70CvZ6BfETbvam3T8FMvLK+eFj0E6AaRQTo= +cloud.google.com/go/managedidentities v1.5.0/go.mod h1:+dWcZ0JlUmpuxpIDfyP5pP5y0bLdRwOS4Lp7gMni/LA= +cloud.google.com/go/maps v0.6.0/go.mod h1:o6DAMMfb+aINHz/p/jbcY+mYeXBoZoxTfdSQ8VAJaCw= +cloud.google.com/go/mediatranslation v0.7.0/go.mod h1:LCnB/gZr90ONOIQLgSXagp8XUW1ODs2UmUMvcgMfI2I= +cloud.google.com/go/memcache v1.9.0/go.mod h1:8oEyzXCu+zo9RzlEaEjHl4KkgjlNDaXbCQeQWlzNFJM= +cloud.google.com/go/metastore v1.10.0/go.mod h1:fPEnH3g4JJAk+gMRnrAnoqyv2lpUCqJPWOodSaf45Eo= +cloud.google.com/go/monitoring v1.12.0/go.mod h1:yx8Jj2fZNEkL/GYZyTLS4ZtZEZN8WtDEiEqG4kLK50w= +cloud.google.com/go/networkconnectivity v1.10.0/go.mod h1:UP4O4sWXJG13AqrTdQCD9TnLGEbtNRqjuaaA7bNjF5E= +cloud.google.com/go/networkmanagement v1.6.0/go.mod h1:5pKPqyXjB/sgtvB5xqOemumoQNB7y95Q7S+4rjSOPYY= +cloud.google.com/go/networksecurity v0.7.0/go.mod h1:mAnzoxx/8TBSyXEeESMy9OOYwo1v+gZ5eMRnsT5bC8k= +cloud.google.com/go/notebooks v1.7.0/go.mod h1:PVlaDGfJgj1fl1S3dUwhFMXFgfYGhYQt2164xOMONmE= +cloud.google.com/go/optimization v1.3.1/go.mod h1:IvUSefKiwd1a5p0RgHDbWCIbDFgKuEdB+fPPuP0IDLI= +cloud.google.com/go/orchestration v1.6.0/go.mod h1:M62Bevp7pkxStDfFfTuCOaXgaaqRAga1yKyoMtEoWPQ= +cloud.google.com/go/orgpolicy v1.10.0/go.mod h1:w1fo8b7rRqlXlIJbVhOMPrwVljyuW5mqssvBtU18ONc= +cloud.google.com/go/osconfig v1.11.0/go.mod h1:aDICxrur2ogRd9zY5ytBLV89KEgT2MKB2L/n6x1ooPw= +cloud.google.com/go/oslogin v1.9.0/go.mod h1:HNavntnH8nzrn8JCTT5fj18FuJLFJc4NaZJtBnQtKFs= +cloud.google.com/go/phishingprotection v0.7.0/go.mod h1:8qJI4QKHoda/sb/7/YmMQ2omRLSLYSu9bU0EKCNI+Lk= +cloud.google.com/go/policytroubleshooter v1.5.0/go.mod h1:Rz1WfV+1oIpPdN2VvvuboLVRsB1Hclg3CKQ53j9l8vw= +cloud.google.com/go/privatecatalog v0.7.0/go.mod h1:2s5ssIFO69F5csTXcwBP7NPFTZvps26xGzvQ2PQaBYg= +cloud.google.com/go/pubsub v1.28.0/go.mod h1:vuXFpwaVoIPQMGXqRyUQigu/AX1S3IWugR9xznmcXX8= +cloud.google.com/go/pubsublite v1.6.0/go.mod h1:1eFCS0U11xlOuMFV/0iBqw3zP12kddMeCbj/F3FSj9k= +cloud.google.com/go/recaptchaenterprise/v2 v2.6.0/go.mod h1:RPauz9jeLtB3JVzg6nCbe12qNoaa8pXc4d/YukAmcnA= +cloud.google.com/go/recommendationengine v0.7.0/go.mod h1:1reUcE3GIu6MeBz/h5xZJqNLuuVjNg1lmWMPyjatzac= +cloud.google.com/go/recommender v1.9.0/go.mod h1:PnSsnZY7q+VL1uax2JWkt/UegHssxjUVVCrX52CuEmQ= +cloud.google.com/go/redis v1.11.0/go.mod h1:/X6eicana+BWcUda5PpwZC48o37SiFVTFSs0fWAJ7uQ= +cloud.google.com/go/resourcemanager v1.5.0/go.mod h1:eQoXNAiAvCf5PXxWxXjhKQoTMaUSNrEfg+6qdf/wots= +cloud.google.com/go/resourcesettings v1.5.0/go.mod h1:+xJF7QSG6undsQDfsCJyqWXyBwUoJLhetkRMDRnIoXA= +cloud.google.com/go/retail v1.12.0/go.mod h1:UMkelN/0Z8XvKymXFbD4EhFJlYKRx1FGhQkVPU5kF14= +cloud.google.com/go/run v0.8.0/go.mod h1:VniEnuBwqjigv0A7ONfQUaEItaiCRVujlMqerPPiktM= +cloud.google.com/go/scheduler v1.8.0/go.mod h1:TCET+Y5Gp1YgHT8py4nlg2Sew8nUHMqcpousDgXJVQc= +cloud.google.com/go/secretmanager v1.10.0/go.mod h1:MfnrdvKMPNra9aZtQFvBcvRU54hbPD8/HayQdlUgJpU= +cloud.google.com/go/security v1.12.0/go.mod h1:rV6EhrpbNHrrxqlvW0BWAIawFWq3X90SduMJdFwtLB8= +cloud.google.com/go/securitycenter v1.18.1/go.mod h1:0/25gAzCM/9OL9vVx4ChPeM/+DlfGQJDwBy/UC8AKK0= +cloud.google.com/go/servicecontrol v1.11.0/go.mod h1:kFmTzYzTUIuZs0ycVqRHNaNhgR+UMUpw9n02l/pY+mc= +cloud.google.com/go/servicedirectory v1.8.0/go.mod h1:srXodfhY1GFIPvltunswqXpVxFPpZjf8nkKQT7XcXaY= +cloud.google.com/go/servicemanagement v1.6.0/go.mod h1:aWns7EeeCOtGEX4OvZUWCCJONRZeFKiptqKf1D0l/Jc= +cloud.google.com/go/serviceusage v1.5.0/go.mod h1:w8U1JvqUqwJNPEOTQjrMHkw3IaIFLoLsPLvsE3xueec= +cloud.google.com/go/shell v1.6.0/go.mod h1:oHO8QACS90luWgxP3N9iZVuEiSF84zNyLytb+qE2f9A= +cloud.google.com/go/spanner v1.44.0/go.mod h1:G8XIgYdOK+Fbcpbs7p2fiprDw4CaZX63whnSMLVBxjk= +cloud.google.com/go/speech v1.14.1/go.mod h1:gEosVRPJ9waG7zqqnsHpYTOoAS4KouMRLDFMekpJ0J0= +cloud.google.com/go/storagetransfer v1.7.0/go.mod h1:8Giuj1QNb1kfLAiWM1bN6dHzfdlDAVC9rv9abHot2W4= +cloud.google.com/go/talent v1.5.0/go.mod h1:G+ODMj9bsasAEJkQSzO2uHQWXHHXUomArjWQQYkqK6c= +cloud.google.com/go/texttospeech v1.6.0/go.mod h1:YmwmFT8pj1aBblQOI3TfKmwibnsfvhIBzPXcW4EBovc= +cloud.google.com/go/tpu v1.5.0/go.mod h1:8zVo1rYDFuW2l4yZVY0R0fb/v44xLh3llq7RuV61fPM= +cloud.google.com/go/trace v1.8.0/go.mod h1:zH7vcsbAhklH8hWFig58HvxcxyQbaIqMarMg9hn5ECA= +cloud.google.com/go/translate v1.6.0/go.mod h1:lMGRudH1pu7I3n3PETiOB2507gf3HnfLV8qlkHZEyos= +cloud.google.com/go/video v1.13.0/go.mod h1:ulzkYlYgCp15N2AokzKjy7MQ9ejuynOJdf1tR5lGthk= +cloud.google.com/go/videointelligence v1.10.0/go.mod h1:LHZngX1liVtUhZvi2uNS0VQuOzNi2TkY1OakiuoUOjU= +cloud.google.com/go/vision/v2 v2.6.0/go.mod h1:158Hes0MvOS9Z/bDMSFpjwsUrZ5fPrdwuyyvKSGAGMY= +cloud.google.com/go/vmmigration v1.5.0/go.mod h1:E4YQ8q7/4W9gobHjQg4JJSgXXSgY21nA5r8swQV+Xxc= +cloud.google.com/go/vmwareengine v0.2.2/go.mod h1:sKdctNJxb3KLZkE/6Oui94iw/xs9PRNC2wnNLXsHvH8= +cloud.google.com/go/vpcaccess v1.6.0/go.mod h1:wX2ILaNhe7TlVa4vC5xce1bCnqE3AeH27RV31lnmZes= +cloud.google.com/go/webrisk v1.8.0/go.mod h1:oJPDuamzHXgUc+b8SiHRcVInZQuybnvEW72PqTc7sSg= +cloud.google.com/go/websecurityscanner v1.5.0/go.mod h1:Y6xdCPy81yi0SQnDY1xdNTNpfY1oAgXUlcfN3B3eSng= +cloud.google.com/go/workflows v1.10.0/go.mod h1:fZ8LmRmZQWacon9UCX1r/g/DfAXx5VcPALq2CxzdePw= +git.frostfs.info/TrueCloudLab/frostfs-contract v0.19.3-0.20240621131249-49e5270f673e/go.mod h1:F/fe1OoIDKr5Bz99q4sriuHDuf3aZefZy9ZsCqEtgxc= +git.frostfs.info/TrueCloudLab/frostfs-crypto v0.6.0 h1:FxqFDhQYYgpe41qsIHVOcdzSVCB8JNSfPG7Uk4r2oSk= +git.frostfs.info/TrueCloudLab/frostfs-crypto v0.6.0/go.mod h1:RUIKZATQLJ+TaYQa60X2fTDwfuhMfm8Ar60bQ5fr+vU= +git.frostfs.info/TrueCloudLab/frostfs-observability v0.0.0-20250212111929-d34e1329c824 h1:Mxw1c/8t96vFIUOffl28lFaHKi413oCBfLMGJmF9cFA= +git.frostfs.info/TrueCloudLab/frostfs-observability v0.0.0-20250212111929-d34e1329c824/go.mod h1:kbwB4v2o6RyOfCo9kEFeUDZIX3LKhmS0yXPrtvzkQ1g= +git.frostfs.info/TrueCloudLab/frostfs-sdk-go v0.0.0-20250310162458-a262a0038f7d h1:YrBjEkuc+q9RdqWmpxRL3DjYrO+9/YOYYIbeq0EVnQs= +git.frostfs.info/TrueCloudLab/frostfs-sdk-go v0.0.0-20250310162458-a262a0038f7d/go.mod h1:aQpPWfG8oyfJ2X+FenPTJpSRWZjwcP5/RAtkW+/VEX8= +git.frostfs.info/TrueCloudLab/hrw v1.2.1/go.mod h1:C1Ygde2n843yTZEQ0FP69jYiuaYV0kriLvP4zm8JuvM= +git.frostfs.info/TrueCloudLab/rfc6979 v0.4.0 h1:M2KR3iBj7WpY3hP10IevfIB9MURr4O9mwVfJ+SjT3HA= +git.frostfs.info/TrueCloudLab/rfc6979 v0.4.0/go.mod h1:okpbKfVYf/BpejtfFTfhZqFP+sZ8rsHrP8Rr/jYPNRc= +git.frostfs.info/TrueCloudLab/tzhash v1.8.0/go.mod h1:dhY+oy274hV8wGvGL4MwwMpdL3GYvaX1a8GQZQHvlF8= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.24.2/go.mod h1:itPGVDKf9cC/ov4MdvJ2QZ0khw4bfoo9jzwTJlaxy2k= +github.com/VictoriaMetrics/easyproto v0.1.4 h1:r8cNvo8o6sR4QShBXQd1bKw/VVLSQma/V2KhTBPf+Sc= +github.com/VictoriaMetrics/easyproto v0.1.4/go.mod h1:QlGlzaJnDfFd8Lk6Ci/fuLxfTo3/GThPs2KH23mv710= +github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= +github.com/antlr/antlr4/runtime/Go/antlr/v4 v4.0.0-20221202181307-76fa05c21b12/go.mod h1:pSwJ0fSY5KhvocuWSx4fz3BA8OrA1bQn+K1Eli3BRwM= +github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bits-and-blooms/bitset v1.8.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= +github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= +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.4.1/go.mod h1:4T9NM4+4Vw91VeyqjLS6ao50K5bOcLKN6Q42XnYaRYw= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk= +github.com/cncf/xds/go v0.0.0-20240905190251-b4127c9b8d78/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8= +github.com/consensys/bavard v0.1.13/go.mod h1:9ItSMtA/dXMAiL7BG6bqW2m3NdSEObYWoH223nGHukI= +github.com/consensys/gnark v0.9.1/go.mod h1:udWvWGXnfBE7mn7BsNoGAvZDnUhcONBEtNijvVjfY80= +github.com/consensys/gnark-crypto v0.12.2-0.20231013160410-1f65e75b6dfb/go.mod h1:v2Gy7L/4ZRosZ7Ivs+9SfUDr0f5UlG+EM5t7MPHiLuY= +github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/decred/dcrd/crypto/blake256 v1.0.1/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 h1:8UrgZ3GkP4i/CLijOJx79Yu+etlyjdBU4sfcs2WYQMs= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0= +github.com/envoyproxy/go-control-plane v0.13.1/go.mod h1:X45hY0mufo6Fd0KW3rqsGvQMw58jvjymeCzBU3mWyHw= +github.com/envoyproxy/protoc-gen-validate v1.1.0/go.mod h1:sXRDRVmzEbkM7CVcM06s9shE/m23dg3wzjl0UWqJ2q4= +github.com/frankban/quicktest v1.14.5/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fxamacker/cbor/v2 v2.5.0/go.mod h1:TA1xS00nchWmaBnEIxPSE5oHLuJBAVvqrtAnWBwBCVo= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/golang/glog v1.2.2/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/pprof v0.0.0-20230817174616-7a8ec2ada47b/go.mod h1:czg5+yv1E0ZGTi6S6vVK1mke0fV+FaUhNGcd6VRS9Ik= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY= +github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.0.0-rc.0/go.mod h1:kdXbOySqcQeTxiqglW7aahTmWZy3Pgi6SYL36yvKeyA= +github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.0.0-rc.3/go.mod h1:hTxjzRcX49ogbTGVJ1sM5mz5s+SSgiGIyL3jjPxl32E= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0 h1:bkypFPDjIYGfCYD5mRBvpqxfYX1YCS1PXdKYWi8FsN0= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0/go.mod h1:P+Lt/0by1T8bfcF3z737NnSbmxQAppXMRziHUxPOC8k= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/holiman/uint256 v1.2.4/go.mod h1:EOMSn4q6Nyt9P6efbI3bueV4e1b3dGlUCXeiRV4ng7E= +github.com/ipfs/go-cid v0.0.7/go.mod h1:6Ux9z5e+HpkQdckYoX1PG/6xqKspzlEIR5SDmgqgC/I= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= +github.com/klauspost/cpuid/v2 v2.2.6/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= +github.com/klauspost/reedsolomon v1.12.1/go.mod h1:nEi5Kjb6QqtbofI6s+cbG/j1da11c96IBYBSnVGtuBs= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= +github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8= +github.com/mmcloughlin/addchain v0.4.0/go.mod h1:A86O+tHqZLMNO4w6ZZ4FlVQEadcoqkyU72HC5wJ4RlU= +github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o= +github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc= +github.com/multiformats/go-base32 v0.1.0/go.mod h1:Kj3tFY6zNr+ABYMqeUNeGvkIC/UYgtWibDcT0rExnbI= +github.com/multiformats/go-base36 v0.2.0/go.mod h1:qvnKE++v+2MWCfePClUEjE78Z7P2a1UV0xHgWc0hkp4= +github.com/multiformats/go-multiaddr v0.14.0/go.mod h1:6EkVAxtznq2yC3QT5CM1UTAwG0GTP3EWAIcjHuzQ+r4= +github.com/multiformats/go-multibase v0.2.0/go.mod h1:bFBZX4lKCA/2lyOFSAoKH5SS6oPyjtnzK/XTFDPkNuk= +github.com/multiformats/go-multihash v0.2.3/go.mod h1:dXgKXCXjBzdscBLk9JkjINiEsCKRVch90MdaGiKsvSM= +github.com/multiformats/go-varint v0.0.7/go.mod h1:r8PUYw/fD/SjBCiKOoDlGF6QawOELpZAu9eioSos/OU= +github.com/nspcc-dev/dbft v0.2.0/go.mod h1:oFE6paSC/yfFh9mcNU6MheMGOYXK9+sPiRk3YMoz49o= +github.com/nspcc-dev/go-ordered-json v0.0.0-20240301084351-0246b013f8b2/go.mod h1:U5VfmPNM88P4RORFb6KSUVBdJBDhlqggJZYGXGPxOcc= +github.com/nspcc-dev/hrw/v2 v2.0.1/go.mod h1:iZAs5hT2q47EGq6AZ0FjaUI6ggntOi7vrY4utfzk5VA= +github.com/nspcc-dev/neo-go v0.106.3 h1:HEyhgkjQY+HfBzotMJ12xx2VuOUphkngZ4kEkjvXDtE= +github.com/nspcc-dev/neo-go v0.106.3/go.mod h1:3vEwJ2ld12N7HRGCaH/l/7EwopplC/+8XdIdPDNmD/M= +github.com/nspcc-dev/neo-go/pkg/interop v0.0.0-20240727093519-1a48f1ce43ec/go.mod h1:/vrbWSHc7YS1KSYhVOyyeucXW/e+1DkVBOgnBEXUCeY= +github.com/nspcc-dev/neofs-api-go/v2 v2.14.1-0.20240305074711-35bc78d84dc4/go.mod h1:7Tm1NKEoUVVIUlkVwFrPh7GG5+Lmta2m7EGr4oVpBd8= +github.com/nspcc-dev/neofs-sdk-go v1.0.0-rc.12/go.mod h1:JdsEM1qgNukrWqgOBDChcYp8oY4XUzidcKaxY4hNJvQ= +github.com/nspcc-dev/rfc6979 v0.2.1 h1:8wWxkamHWFmO790GsewSoKUSJjVnL1fmdRpokU/RgRM= +github.com/nspcc-dev/rfc6979 v0.2.1/go.mod h1:Tk7h5kyUWkhjyO3zUgFFhy1v2vQv3BvQEntakdtqrWc= +github.com/nspcc-dev/tzhash v1.7.2/go.mod h1:oHiH0qwmTsZkeVs7pvCS5cVXUaLhXxSFvnmnZ++ijm4= +github.com/pierrec/lz4 v2.6.1+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= +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/go.mod h1:ZRM9uEAypZakd+q/x7+gmsvXdURP+DABIEIjnmDdp+k= +github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI= +github.com/prometheus/common v0.48.0/go.mod h1:0/KsvlIEfPQCQ5I2iNSAWKPZziNCvRs5EC6ILDTlAPc= +github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo= +github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= +github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= +github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= +github.com/rs/zerolog v1.30.0/go.mod h1:/tk+P47gFdPXq4QYjvCmT5/Gsug2nagsFWBWhAiSi1w= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/syndtr/goleveldb v1.0.1-0.20210305035536-64b5b1c73954/go.mod h1:u2MKkTVTVJWe5D1rCvame8WqhBd88EuIwODJZ1VHCPM= +github.com/twmb/murmur3 v1.1.8/go.mod h1:Qq/R7NUyOfr65zD+6Q5IHKsJLwP7exErjN6lyyq3OSQ= +github.com/urfave/cli/v2 v2.27.2/go.mod h1:g0+79LmHHATl7DAcHO99smiR/T7uGLw84w8Y42x+4eM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/xrash/smetrics v0.0.0-20240312152122-5f08fbb34913/go.mod h1:4aEEwZQutDLsQv2Deui4iYQ6DWTxR14g6m8Wv88+Xqk= +go.etcd.io/bbolt v1.3.9/go.mod h1:zaO32+Ti0PK1ivdPtgMESzuzL2VPoIG1PCQNvOdo/dE= +go.opentelemetry.io/contrib/detectors/gcp v1.31.0/go.mod h1:tzQL6E1l+iV44YFTkcAeNQqzXUiekSYP9jjJjXwEd00= +go.opentelemetry.io/otel v1.31.0 h1:NsJcKPIW0D0H3NgzPDHmo0WW6SptzPdqg/L1zsIm2hY= +go.opentelemetry.io/otel v1.31.0/go.mod h1:O0C14Yl9FgkjqcCZAsE053C13OaddMYr/hz6clDkEJE= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.28.0 h1:3Q/xZUyC1BBkualc9ROb4G8qkH90LXEIICcs5zv1OYY= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.28.0/go.mod h1:s75jGIWA9OfCMzF0xr+ZgfrB5FEbbV7UuYo32ahUiFI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.28.0 h1:R3X6ZXmNPRR8ul6i3WgFURCHzaXjHdm0karRG/+dj3s= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.28.0/go.mod h1:QWFXnDavXWwMx2EEcZsf3yxgEKAqsxQ+Syjp+seyInw= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.28.0 h1:EVSnY9JbEEW92bEkIYOVMw4q1WJxIAGoFTrtYOzWuRQ= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.28.0/go.mod h1:Ea1N1QQryNXpCD0I1fdLibBAIpQuBkznMmkdKrapk1Y= +go.opentelemetry.io/otel/metric v1.31.0 h1:FSErL0ATQAmYHUIzSezZibnyVlft1ybhy4ozRPcF2fE= +go.opentelemetry.io/otel/metric v1.31.0/go.mod h1:C3dEloVbLuYoX41KpmAhOqNriGbA+qqH6PQ5E5mUfnY= +go.opentelemetry.io/otel/sdk v1.31.0 h1:xLY3abVHYZ5HSfOg3l2E5LUj2Cwva5Y7yGxnSW9H5Gk= +go.opentelemetry.io/otel/sdk v1.31.0/go.mod h1:TfRbMdhvxIIr/B2N2LQW2S5v9m3gOQ/08KsbbO5BPT0= +go.opentelemetry.io/otel/sdk/metric v1.31.0 h1:i9hxxLJF/9kkvfHppyLL55aW7iIJz4JjxTeYusH7zMc= +go.opentelemetry.io/otel/sdk/metric v1.31.0/go.mod h1:CRInTMVvNhUKgSAMbKyTMxqOBC0zgyxzW55lZzX43Y8= +go.opentelemetry.io/otel/trace v1.31.0 h1:ffjsj1aRouKewfr85U2aGagJ46+MvodynlQ1HYdmJys= +go.opentelemetry.io/otel/trace v1.31.0/go.mod h1:TXZkRk7SM2ZQLtR6eoAWQFIHPvzQ06FJAsO1tJg480A= +go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0= +go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= +golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= +golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4= +golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU= +golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= +golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s= +golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= +golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/genproto v0.0.0-20230306155012-7f2fa6fef1f4 h1:DdoeryqhaXp1LtT/emMP1BRJPHHKFi5akj/nbx/zNTA= +google.golang.org/genproto v0.0.0-20230306155012-7f2fa6fef1f4/go.mod h1:NWraEVixdDnqcqQ30jipen1STv2r/n24Wb7twVTGR4s= +google.golang.org/genproto/googleapis/api v0.0.0-20241015192408-796eee8c2d53/go.mod h1:riSXTwQ4+nqmPGtobMFyW5FqVAmIs0St6VPp4Ug7CE4= +google.golang.org/genproto/googleapis/rpc v0.0.0-20241015192408-796eee8c2d53/go.mod h1:GX3210XPVPUjJbTUbvwI8f2IpZDMZuPJWDzDuebbviI= +google.golang.org/grpc v1.69.2 h1:U3S9QEtbXC0bYNvRtcoklF3xGtLViumSYxWykJS+7AU= +google.golang.org/grpc v1.69.2/go.mod h1:vyjdE6jLBI76dgpDojsFGNaHlxdjXN9ghpnd2o7JGZ4= +google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= +google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +lukechampine.com/blake3 v1.2.1/go.mod h1:0OFRp7fBtAylGVCO40o87sbupkyIGgbpv1+M1k1LM6k= +rsc.io/tmplfunc v0.0.3/go.mod h1:AG3sTPzElb1Io3Yg4voV9AGZJuleGAwaVRxL9M49PhA= diff --git a/mfa/box.go b/mfa/box.go new file mode 100644 index 0000000..86bd22a --- /dev/null +++ b/mfa/box.go @@ -0,0 +1,15 @@ +package mfa + +import ( + "google.golang.org/protobuf/proto" +) + +// 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 into MFABox. +func (x *MFABox) Unmarshal(data []byte) error { + return proto.Unmarshal(data, x) +} diff --git a/mfa/device.go b/mfa/device.go new file mode 100644 index 0000000..973c9fb --- /dev/null +++ b/mfa/device.go @@ -0,0 +1,130 @@ +package mfa + +import ( + "strconv" + "strings" + "time" + + oid "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/object/id" + "github.com/pquerna/otp" +) + +type ( + // Device defines MFA Device metadata. + Device struct { + Namespace string + Name string + OID oid.ID + Meta map[string]string + } + + // SecretDevice is an MFA device metadata with decoded OTP Key. + SecretDevice struct { + Device + Key *otp.Key + } +) + +const ( + FilePathKey = "FilePath" + OIDKey = "OID" + + PathKey = "Path" + EnableDateKey = "EnableDate" + EnabledKey = "EnabledKey" + UserIDKey = "UserIDKey" + TagPrefix = "tag-" +) + +// NewDevice returns new device metadata. Device is disabled by default. +func NewDevice(namespace, name, path string) *Device { + return &Device{ + Namespace: namespace, + Name: name, + Meta: map[string]string{ + EnabledKey: "false", + PathKey: path, + }, + } +} + +// String returns string representation of device. +func (d *Device) String() string { + return d.Namespace + "/" + d.Name +} + +// EnableStatus returns true if device is enabled. +func (d *Device) EnableStatus() bool { + return d.Meta[EnabledKey] == "true" +} + +// SetEnableStatus updates enable status of device. +// It does not modify enable date. +func (d *Device) SetEnableStatus(enabled bool) { + d.Meta[EnabledKey] = strconv.FormatBool(enabled) +} + +// EnableDate returns date when device was enabled. +func (d *Device) EnableDate() *time.Time { + return dateFromString(d.Meta[EnableDateKey]) +} + +// SetEnableDate sets date when device was enabled. +// It is not affected by 'SetEnableStatus'. +func (d *Device) SetEnableDate(date *time.Time) { + d.Meta[EnableDateKey] = convertDate(date) +} + +// SetUserID sets id of the device owner. Use neo wallet address representation. +func (d *Device) SetUserID(addr string) { + d.Meta[UserIDKey] = addr +} + +// UserID returns id of the device owner. +func (d *Device) UserID() string { + return d.Meta[UserIDKey] +} + +// AddTags adds new set of tags to the device. +// It does not remove existing set of tags. +func (d *Device) AddTags(tags [][2]string) { + for _, kv := range tags { + k, v := kv[0], kv[1] + d.Meta[TagPrefix+k] = v + } +} + +// DeleteTags removes all tags with provided keys. +func (d *Device) DeleteTags(keys []string) { + for _, k := range keys { + delete(d.Meta, TagPrefix+k) + } +} + +// Tags returns available set of tags of the device. +func (d *Device) Tags() map[string]string { + tags := make(map[string]string) + for k, v := range d.Meta { + if after, ok := strings.CutPrefix(k, TagPrefix); ok { + tags[after] = v + } + } + + return tags +} + +func convertDate(t *time.Time) string { + if t == nil { + return "" + } + return t.UTC().Format(time.RFC3339) +} + +func dateFromString(timeString string) *time.Time { + var date *time.Time + enableD, err := time.Parse(time.RFC3339, timeString) + if err == nil { + date = &enableD + } + return date +} diff --git a/mfa/errors.go b/mfa/errors.go new file mode 100644 index 0000000..87ae994 --- /dev/null +++ b/mfa/errors.go @@ -0,0 +1,8 @@ +package mfa + +import "errors" + +var ( + // ErrMFAEntityAlreadyExists returned during MFA device creation by MFA Manager. + ErrMFAEntityAlreadyExists = errors.New("mfa entity already exists") +) diff --git a/mfa/mfa.go b/mfa/mfa.go new file mode 100644 index 0000000..6b7aebb --- /dev/null +++ b/mfa/mfa.go @@ -0,0 +1,410 @@ +package mfa + +import ( + "bytes" + "context" + "errors" + "fmt" + "strings" + + "git.frostfs.info/TrueCloudLab/frostfs-observability/tracing" + 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/user" + "github.com/nspcc-dev/neo-go/pkg/crypto/keys" + "github.com/pquerna/otp" + "go.uber.org/zap" +) + +type ( + // Manager provides interface to manage MFA devices in FrostFS container. + // It should be provided with Storage interface to manage FrostFS objects + // and KeyStore interface to encode and decode OTP keys inside FrostFS + // objects. + Manager struct { + storage Storage + unlocker KeyStore + container cid.ID + logger *zap.Logger + } + + // KeyStore is an interface for Manager to provide keys to encode and decode + // OTP keys of MFA devices. + KeyStore interface { + // PrivateKey returns private key of this Manager. + PrivateKey() *keys.PrivateKey + // PublicKeys returns list of public keys for all managers, including + // this Manager. + PublicKeys() []*keys.PublicKey + } + + // Config contains parameters for Manager constructor. + Config struct { + Storage Storage + Unlocker KeyStore + Container cid.ID + Logger *zap.Logger + } +) + +// NewManager creates new instance of Manager. +func NewManager(cfg Config) (*Manager, 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.Unlocker == nil { + return nil, errors.New("mfa key store is nil") + } + + return &Manager{ + storage: cfg.Storage, + container: cfg.Container, + unlocker: cfg.Unlocker, + logger: cfg.Logger, + }, nil +} + +// CreateMFADevice creates new FrostFS object with encoded MFA device inside and stores it in MFA container. +func (m *Manager) CreateMFADevice(ctx context.Context, device SecretDevice) error { + ctx, span := tracing.StartSpanFromContext(ctx, "mfa.CreateMFADevice") + defer span.End() + + filePath := getTreePath(device.Namespace, device.Name) + if _, err := m.storage.GetTreeNode(ctx, m.container, filePath); err == nil { + return ErrMFAEntityAlreadyExists // return well known error here + } else if !errors.Is(err, ErrTreeNodeNotFound) { + return fmt.Errorf("get mfa node '%s' to check: %w", filePath, err) + } + + return m.putMFADevice(ctx, device) +} + +// GetMFADevice returns decoded MFA device from MFA container. +func (m *Manager) GetMFADevice(ctx context.Context, ns, mfaName string) (*SecretDevice, error) { + ctx, span := tracing.StartSpanFromContext(ctx, "mfa.GetMFADevice") + defer span.End() + + 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(mfaBox, m.unlocker.PrivateKey()) + if err != nil { + return nil, fmt.Errorf("unpack mfa box: %w", err) + } + + key, err := otp.NewKeyFromURL(secrets.URL()) + if err != nil { + return nil, err + } + + dev, err := newDevice(&node.Current) + if err != nil { + return nil, err + } + + return &SecretDevice{ + Device: *dev, + Key: key, + }, nil +} + +// GetTinyMFADevice returns MFA device metadata without OTP key from the tree of MFA container. +func (m *Manager) GetTinyMFADevice(ctx context.Context, ns, mfaName string) (*Device, error) { + ctx, span := tracing.StartSpanFromContext(ctx, "mfa.GetTinyMFADevice") + defer span.End() + + node, err := m.storage.GetTreeNode(ctx, m.container, getTreePath(ns, mfaName)) + if err != nil { + return nil, fmt.Errorf("get mfa nodes: %w", err) + } + + dev, err := newDevice(&node.Current) + if err != nil { + return nil, err + } + + return dev, nil +} + +// ListMFADevices lists all available MFA device metadata with specified device namespace from the tree of MFA container. +func (m *Manager) ListMFADevices(ctx context.Context, ns string) ([]*Device, error) { + ctx, span := tracing.StartSpanFromContext(ctx, "mfa.ListMFADevices") + defer span.End() + + list, err := m.storage.GetTreeNodes(ctx, m.container, ns) + if err != nil { + return nil, err + } + + return m.formDevices(list) +} + +// ListAllMFADevices lists all available MFA device metadata from the tree of MFA container. +func (m *Manager) ListAllMFADevices(ctx context.Context) ([]*Device, error) { + ctx, span := tracing.StartSpanFromContext(ctx, "mfa.ListAllMFADevices") + defer span.End() + + list, err := m.storage.GetTreeNodes(ctx, m.container, "") + if err != nil { + return nil, err + } + + return m.formDevices(list) +} + +// UpdateMFADevice updates MFA device metadata in the tree of MFA container. +func (m *Manager) UpdateMFADevice(ctx context.Context, device *Device) error { + ctx, span := tracing.StartSpanFromContext(ctx, "mfa.UpdateMFADevice") + defer span.End() + + node, err := m.storage.SetTreeNode(ctx, m.container, getTreePath(device.Namespace, device.Name), device.Meta) + if err != nil { + return fmt.Errorf("set mfa tree node : %w", err) + } + + m.deleteObjects(ctx, node.Old) + + return nil +} + +// DeleteMFADevice removes FrostFS object and metadata from the tree of MFA container related to device. +func (m *Manager) DeleteMFADevice(ctx context.Context, device *Device) error { + ctx, span := tracing.StartSpanFromContext(ctx, "mfa.DeleteMFADevice") + defer span.End() + + meta := device.Meta + var objID oid.ID + if err := objID.DecodeString(meta[OIDKey]); err != nil { + return fmt.Errorf("decode oid '%s': %w", meta[OIDKey], err) + } + + address := oid.Address{} + address.SetObject(objID) + address.SetContainer(m.container) + + if err := m.storage.DeleteObject(ctx, address); err != nil { + return fmt.Errorf("failed to delete from storage: %w", err) + } + + old, err := m.storage.DeleteTreeNode(ctx, m.container, getTreePath(device.Namespace, device.Name)) + if err != nil { + m.deleteObjects(ctx, old) + return fmt.Errorf("failed to delete from tree: %w", err) + } + + return nil +} + +// RecipherDevice creates new FrostFS object with encoded OTP key for new set +// of public keys from KeyManager. If existing FrostFS object already contains +// all public keys from KeyManager, then does nothing. +func (m *Manager) RecipherDevice(ctx context.Context, dev *Device) error { + ctx, span := tracing.StartSpanFromContext(ctx, "mfa.RecipherDevice") + defer span.End() + + node, err := m.storage.GetTreeNode(ctx, m.container, getTreePath(dev.Namespace, dev.Name)) + if err != nil { + return fmt.Errorf("get mfa nodes: %w", err) + } + + if dev.OID.EncodeToString() != node.Current.Meta[OIDKey] { + // This can happen in the following cases: + // * device was reciphered by other Manager + // * device was removed and newly created with the same name + // in both cases it must be already encrypted for new list of keys + return nil + } + + var addr oid.Address + addr.SetContainer(m.container) + addr.SetObject(dev.OID) + + boxData, err := m.storage.GetObject(ctx, addr) + if err != nil { + return fmt.Errorf("get object '%s': %w", addr.EncodeToString(), err) + } + + mfaBox := new(MFABox) + if err = mfaBox.Unmarshal(boxData); err != nil { + return fmt.Errorf("unmarshal box data: %w", err) + } + + newKeys := m.unlocker.PublicKeys() + oldKeys := mfaBox.GetUnlockers() + if equalKeys(newKeys, oldKeys) { + m.logger.Info("mfabox has already been reciphered", + zap.String("device", dev.String()), + zap.String("OID", dev.OID.EncodeToString())) + return nil + } + + secrets, err := UnpackMFABox(mfaBox, m.unlocker.PrivateKey()) + if err != nil { + return fmt.Errorf("unpack mfa box: %w", err) + } + + key, err := otp.NewKeyFromURL(secrets.URL()) + if err != nil { + return err + } + + return m.putMFADevice(ctx, SecretDevice{ + Device: *dev, + Key: key, + }) +} + +func (m *Manager) putMFADevice(ctx context.Context, device SecretDevice) error { + filePath := "/" + device.Namespace + "/" + device.Name + + box, err := PackMFABox(device.Key, m.unlocker.PublicKeys()) + if err != nil { + return fmt.Errorf("pack mfa box: %w", err) + } + + boxData, err := box.Marshal() + if err != nil { + return fmt.Errorf("marshal mfa box: %w", err) + } + + var owner user.ID + user.IDFromKey(&owner, m.unlocker.PrivateKey().PrivateKey.PublicKey) + + objID, err := m.storage.CreateObject(ctx, PrmObjectCreate{ + Container: m.container, + Owner: owner, + FilePath: filePath, + Payload: boxData, + }) + if err != nil { + return fmt.Errorf("put mfa box object: %w", err) + } + + meta := map[string]string{ + FilePathKey: filePath, + OIDKey: objID.EncodeToString(), + } + + for k, v := range device.Meta { + if k != FilePathKey && k != OIDKey { + meta[k] = v + } + } + + node, err := m.storage.SetTreeNode(ctx, m.container, filePath, meta) + if err != nil { + return fmt.Errorf("set mfa tree node : %w", err) + } + + m.deleteObjects(ctx, node.Old) + + m.deleteObject(ctx, device.OID) + + return nil +} + +func (m *Manager) formDevices(list []*TreeNode) ([]*Device, error) { + res := make([]*Device, 0, len(list)) + for _, item := range list { + dev, err := newDevice(item) + if err != nil { + m.logger.Warn("invalid mfa device tree node", zap.Error(err)) + continue + } + res = append(res, dev) + } + + return res, nil +} + +func (m *Manager) deleteObjects(ctx context.Context, nodes []*TreeNode) { + var addr oid.Address + addr.SetContainer(m.container) + + for _, node := range nodes { + var objID oid.ID + if err := objID.DecodeString(node.Meta[OIDKey]); err != nil { + m.logger.Warn("failed to decode old multinode oid", zap.String("oid", node.Meta[OIDKey]), zap.Error(err)) + } + + addr.SetObject(objID) + if err := m.storage.DeleteObject(ctx, addr); err != nil { + m.logger.Warn("failed to delete old multinode object", zap.String("address", addr.EncodeToString()), zap.Error(err)) + } + } +} + +func (m *Manager) deleteObject(ctx context.Context, objID oid.ID) { + if objID.Equals(oid.ID{}) { + return + } + + var addr oid.Address + addr.SetContainer(m.container) + addr.SetObject(objID) + if err := m.storage.DeleteObject(ctx, addr); err != nil { + m.logger.Warn("failed to delete object", zap.String("address", addr.EncodeToString()), zap.Error(err)) + } +} + +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 +} + +// equalKeys returns true if 'got' contains all public keys from 'expected' slice. +func equalKeys(expected []*keys.PublicKey, got []*Unlocker) bool { +loop: + for _, newKey := range expected { + for _, svcKey := range got { + if bytes.Equal(newKey.Bytes(), svcKey.GetPublicKey()) { + continue loop + } + } + return false + } + return true +} + +func getTreePath(ns, mfaName string) string { + return ns + "/" + mfaName +} diff --git a/mfa/mfabox.pb.go b/mfa/mfabox.pb.go new file mode 100644 index 0000000..48ac49b --- /dev/null +++ b/mfa/mfabox.pb.go @@ -0,0 +1,289 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.4 +// protoc v4.25.6 +// source: mfa/mfabox.proto + +package mfa + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +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) +) + +// Unlocker is a message that contains encrypted key which has been used during +// encryption of 'Secrets' message in 'EncryptedSecrets' field of MFABox. +type Unlocker struct { + state protoimpl.MessageState `protogen:"open.v1"` + // PublicKeys is 33-byte ECDSA P-256 curve public key which identifies + // unlocker who can decrypt 'Secrets'. + PublicKey []byte `protobuf:"bytes,1,opt,name=PublicKey,json=publicKey" json:"PublicKey,omitempty"` + // EncryptedSecretsKey is a binary encoded encryption key of MFA Secrets, + // encrypted by ChaCha20-Poly1305 AEAD algorithm. + EncryptedSecretsKey []byte `protobuf:"bytes,2,opt,name=EncryptedSecretsKey,json=encryptedSecretsKey" json:"EncryptedSecretsKey,omitempty"` + // Salt for HKDF function to derive key for encryption of 'EncryptedSecreteKey'. + Salt []byte `protobuf:"bytes,3,opt,name=Salt,json=salt" json:"Salt,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Unlocker) Reset() { + *x = Unlocker{} + mi := &file_mfa_mfabox_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Unlocker) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Unlocker) ProtoMessage() {} + +func (x *Unlocker) ProtoReflect() protoreflect.Message { + mi := &file_mfa_mfabox_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Unlocker.ProtoReflect.Descriptor instead. +func (*Unlocker) Descriptor() ([]byte, []int) { + return file_mfa_mfabox_proto_rawDescGZIP(), []int{0} +} + +func (x *Unlocker) GetPublicKey() []byte { + if x != nil { + return x.PublicKey + } + return nil +} + +func (x *Unlocker) GetEncryptedSecretsKey() []byte { + if x != nil { + return x.EncryptedSecretsKey + } + return nil +} + +func (x *Unlocker) GetSalt() []byte { + if x != nil { + return x.Salt + } + return nil +} + +type MFABox struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Unlockers are the set of messages contain key that has been used + // to encrypt 'Secrets' message in 'EncrytedSecrets' field. + Unlockers []*Unlocker `protobuf:"bytes,1,rep,name=Unlockers,json=unlockers" json:"Unlockers,omitempty"` + // ECDHPublicKey is 33-byte ECDSA P-256 curve key to derive + // unique encryption keys for every unlocker with ECDH algorithm + ECDHPublicKey []byte `protobuf:"bytes,2,opt,name=ECDHPublicKey,json=ecdhPublicKey" json:"ECDHPublicKey,omitempty"` + // EncryptedSecrets is a binary encoded 'Secrets' message, encrypted by + // ChaCha20-Poly1305 AEAD algorithm. + EncryptedSecrets []byte `protobuf:"bytes,3,opt,name=EncryptedSecrets,json=encryptedSecrets" json:"EncryptedSecrets,omitempty"` + // Salt for HKDF function to derive key for encryption of 'EncryptedSecrets'. + Salt []byte `protobuf:"bytes,4,opt,name=Salt,json=salt" json:"Salt,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MFABox) Reset() { + *x = MFABox{} + mi := &file_mfa_mfabox_proto_msgTypes[1] + 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_mfa_mfabox_proto_msgTypes[1] + if 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_mfa_mfabox_proto_rawDescGZIP(), []int{1} +} + +func (x *MFABox) GetUnlockers() []*Unlocker { + if x != nil { + return x.Unlockers + } + return nil +} + +func (x *MFABox) GetECDHPublicKey() []byte { + if x != nil { + return x.ECDHPublicKey + } + return nil +} + +func (x *MFABox) GetEncryptedSecrets() []byte { + if x != nil { + return x.EncryptedSecrets + } + return nil +} + +func (x *MFABox) GetSalt() []byte { + if x != nil { + return x.Salt + } + return nil +} + +// Secrets is a message that contains private data about MFA Device +type Secrets struct { + state protoimpl.MessageState `protogen:"open.v1"` + // MFAURL is a seed for virtual authenticator device. + // Format is described in https://github.com/google/google-authenticator/wiki/Key-Uri-Format + MFAURL *string `protobuf:"bytes,2,opt,name=MFAURL,json=mfaURL" json:"MFAURL,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Secrets) Reset() { + *x = Secrets{} + mi := &file_mfa_mfabox_proto_msgTypes[2] + 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_mfa_mfabox_proto_msgTypes[2] + if 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_mfa_mfabox_proto_rawDescGZIP(), []int{2} +} + +func (x *Secrets) GetMFAURL() string { + if x != nil && x.MFAURL != nil { + return *x.MFAURL + } + return "" +} + +var File_mfa_mfabox_proto protoreflect.FileDescriptor + +var file_mfa_mfabox_proto_rawDesc = string([]byte{ + 0x0a, 0x10, 0x6d, 0x66, 0x61, 0x2f, 0x6d, 0x66, 0x61, 0x62, 0x6f, 0x78, 0x2e, 0x70, 0x72, 0x6f, + 0x74, 0x6f, 0x12, 0x03, 0x6d, 0x66, 0x61, 0x22, 0x6e, 0x0a, 0x08, 0x55, 0x6e, 0x6c, 0x6f, 0x63, + 0x6b, 0x65, 0x72, 0x12, 0x1c, 0x0a, 0x09, 0x50, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x4b, 0x65, 0x79, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x09, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x4b, 0x65, + 0x79, 0x12, 0x30, 0x0a, 0x13, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x53, 0x65, + 0x63, 0x72, 0x65, 0x74, 0x73, 0x4b, 0x65, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x13, + 0x65, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x53, 0x65, 0x63, 0x72, 0x65, 0x74, 0x73, + 0x4b, 0x65, 0x79, 0x12, 0x12, 0x0a, 0x04, 0x53, 0x61, 0x6c, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, + 0x0c, 0x52, 0x04, 0x73, 0x61, 0x6c, 0x74, 0x22, 0x9b, 0x01, 0x0a, 0x06, 0x4d, 0x46, 0x41, 0x42, + 0x6f, 0x78, 0x12, 0x2b, 0x0a, 0x09, 0x55, 0x6e, 0x6c, 0x6f, 0x63, 0x6b, 0x65, 0x72, 0x73, 0x18, + 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x0d, 0x2e, 0x6d, 0x66, 0x61, 0x2e, 0x55, 0x6e, 0x6c, 0x6f, + 0x63, 0x6b, 0x65, 0x72, 0x52, 0x09, 0x75, 0x6e, 0x6c, 0x6f, 0x63, 0x6b, 0x65, 0x72, 0x73, 0x12, + 0x24, 0x0a, 0x0d, 0x45, 0x43, 0x44, 0x48, 0x50, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x4b, 0x65, 0x79, + 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0d, 0x65, 0x63, 0x64, 0x68, 0x50, 0x75, 0x62, 0x6c, + 0x69, 0x63, 0x4b, 0x65, 0x79, 0x12, 0x2a, 0x0a, 0x10, 0x45, 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, 0x12, 0x0a, 0x04, 0x53, 0x61, 0x6c, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0c, 0x52, + 0x04, 0x73, 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, 0x06, 0x5a, 0x04, 0x2f, 0x6d, 0x66, 0x61, + 0x62, 0x08, 0x65, 0x64, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x70, 0xe8, 0x07, +}) + +var ( + file_mfa_mfabox_proto_rawDescOnce sync.Once + file_mfa_mfabox_proto_rawDescData []byte +) + +func file_mfa_mfabox_proto_rawDescGZIP() []byte { + file_mfa_mfabox_proto_rawDescOnce.Do(func() { + file_mfa_mfabox_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_mfa_mfabox_proto_rawDesc), len(file_mfa_mfabox_proto_rawDesc))) + }) + return file_mfa_mfabox_proto_rawDescData +} + +var file_mfa_mfabox_proto_msgTypes = make([]protoimpl.MessageInfo, 3) +var file_mfa_mfabox_proto_goTypes = []any{ + (*Unlocker)(nil), // 0: mfa.Unlocker + (*MFABox)(nil), // 1: mfa.MFABox + (*Secrets)(nil), // 2: mfa.Secrets +} +var file_mfa_mfabox_proto_depIdxs = []int32{ + 0, // 0: mfa.MFABox.Unlockers:type_name -> mfa.Unlocker + 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_mfa_mfabox_proto_init() } +func file_mfa_mfabox_proto_init() { + if File_mfa_mfabox_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_mfa_mfabox_proto_rawDesc), len(file_mfa_mfabox_proto_rawDesc)), + NumEnums: 0, + NumMessages: 3, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_mfa_mfabox_proto_goTypes, + DependencyIndexes: file_mfa_mfabox_proto_depIdxs, + MessageInfos: file_mfa_mfabox_proto_msgTypes, + }.Build() + File_mfa_mfabox_proto = out.File + file_mfa_mfabox_proto_goTypes = nil + file_mfa_mfabox_proto_depIdxs = nil +} diff --git a/mfa/mfabox.proto b/mfa/mfabox.proto new file mode 100644 index 0000000..2cdd9fe --- /dev/null +++ b/mfa/mfabox.proto @@ -0,0 +1,44 @@ +edition = "2023"; + +package mfa; + +option go_package = "/mfa"; + +// Unlocker is a message that contains encrypted key which has been used during +// encryption of 'Secrets' message in 'EncryptedSecrets' field of MFABox. +message Unlocker { + // PublicKeys is 33-byte ECDSA P-256 curve public key which identifies + // unlocker who can decrypt 'Secrets'. + bytes PublicKey = 1 [json_name = "publicKey"]; + + // EncryptedSecretsKey is a binary encoded encryption key of MFA Secrets, + // encrypted by ChaCha20-Poly1305 AEAD algorithm. + bytes EncryptedSecretsKey = 2 [json_name = "encryptedSecretsKey"]; + + // Salt for HKDF function to derive key for encryption of 'EncryptedSecreteKey'. + bytes Salt = 3 [json_name = "salt"]; +} + +message MFABox { + // Unlockers are the set of messages contain key that has been used + // to encrypt 'Secrets' message in 'EncrytedSecrets' field. + repeated Unlocker Unlockers = 1 [json_name = "unlockers"]; + + // ECDHPublicKey is 33-byte ECDSA P-256 curve key to derive + // unique encryption keys for every unlocker with ECDH algorithm + bytes ECDHPublicKey = 2 [json_name = "ecdhPublicKey"]; + + // EncryptedSecrets is a binary encoded 'Secrets' message, encrypted by + // ChaCha20-Poly1305 AEAD algorithm. + bytes EncryptedSecrets = 3 [json_name = "encryptedSecrets"]; + + // Salt for HKDF function to derive key for encryption of 'EncryptedSecrets'. + bytes Salt = 4 [json_name = "salt"]; +} + +// Secrets is a message that contains private data about MFA Device +message Secrets { + // MFAURL is a seed for virtual authenticator device. + // Format is described in https://github.com/google/google-authenticator/wiki/Key-Uri-Format + string MFAURL = 2 [json_name = "mfaURL"]; +} diff --git a/mfa/pack.go b/mfa/pack.go new file mode 100644 index 0000000..9373abe --- /dev/null +++ b/mfa/pack.go @@ -0,0 +1,229 @@ +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 +) + +// PackMFABox encrypts OTP Key in a MFABox. Holders of unlocker private keys +// can unpack this object and decrypt OTP Key. +func PackMFABox(secret *otp.Key, unlockerKeys []*keys.PublicKey) (*MFABox, error) { + if len(unlockerKeys) == 0 { + return nil, errors.New("no unlocker keys provided") + } + + // First step: generate encryption key and encrypt secret data with it. + secretUrl := secret.URL() + + // prepare MFA secret for encryption + data, err := proto.Marshal(&Secrets{MFAURL: &secretUrl}) + if err != nil { + return nil, fmt.Errorf("marshal secrets: %w", err) + } + + // generate symmetric key to encrypt MFA secret + secretEncryptionKey, err := generateRandomBytes(secretLength) + if err != nil { + return nil, fmt.Errorf("generate secrets encryption key: %w", err) + } + + // encrypt MFA secret with ChaCha20-Poly1305 AEAD algorithm + encryptedSecrets, hkdfsalt, err := encryptData(data, secretEncryptionKey) + if err != nil { + return nil, fmt.Errorf("encrypt secrets: %w", err) + } + + // Second step: for each unlocker, encrypt secret encryption key, so + // each unlocker could decrypt encryption key and then decrypt MFA secret with it. + + // generate ECDSA P-256 curve private key to derive unique encryption + // key for every unlocker with ECDH algorithm. + ecdhKey, err := keys.NewPrivateKey() + if err != nil { + return nil, fmt.Errorf("create private key for ECDH: %w", err) + } + + unlockers := make([]*Unlocker, len(unlockerKeys)) + for i := range unlockerKeys { + unlockers[i], err = packUnlocker(secretEncryptionKey, ecdhKey, unlockerKeys[i]) + if err != nil { + return nil, fmt.Errorf("create unlocker: %w", err) + } + } + + return &MFABox{ + Unlockers: unlockers, + ECDHPublicKey: ecdhKey.PublicKey().Bytes(), + EncryptedSecrets: encryptedSecrets, + Salt: hkdfsalt, + }, nil +} + +// UnpackMFABox decrypts OTP key using unlocker key. +func UnpackMFABox(box *MFABox, unlockerKey *keys.PrivateKey) (*otp.Key, error) { + unlockerPublicKey := unlockerKey.PublicKey().Bytes() + ecdhKey, err := keys.NewPublicKeyFromBytes(box.ECDHPublicKey, elliptic.P256()) + if err != nil { + return nil, fmt.Errorf("parse ECDH key: %w", err) + } + + // First step: find unlocker message for unlocker key + var suitableUnlocker *Unlocker + for _, unlocker := range box.Unlockers { + if bytes.Equal(unlockerPublicKey, unlocker.GetPublicKey()) { + suitableUnlocker = unlocker + break + } + } + if suitableUnlocker == nil { + return nil, fmt.Errorf("no unlocker for %x", unlockerPublicKey) + } + + // Second step: decrypt encryption key of MFA secret + secretEncryptionKey, err := unpackUnlocker(suitableUnlocker, ecdhKey, unlockerKey) + if err != nil { + return nil, fmt.Errorf("unpack unlocker: %w", err) + } + + // Third step: decrypt MFA secret + data, err := decryptData(box.EncryptedSecrets, secretEncryptionKey, box.Salt) + if err != nil { + return nil, fmt.Errorf("decrypt secrets: %w", err) + } + + secrets := new(Secrets) + if err = proto.Unmarshal(data, secrets); err != nil { + return nil, fmt.Errorf("unmarshal secrets: %w", err) + } + + key, err := otp.NewKeyFromURL(secrets.GetMFAURL()) + if err != nil { + return nil, fmt.Errorf("parse OTP key: %w", err) + } + + return key, nil +} + +func packUnlocker(data []byte, ecdhKey *keys.PrivateKey, unlockerKey *keys.PublicKey) (*Unlocker, error) { + // derive unique encryption key for unlocker with ECDH algorithm + uniqueUnlockerKey, err := deriveECDH(ecdhKey, unlockerKey) + if err != nil { + return nil, fmt.Errorf("generate ECDH: %w", err) + } + + // encrypt data based on unique encryption key + encryptedData, salt, err := encryptData(data, uniqueUnlockerKey) + + return &Unlocker{ + PublicKey: unlockerKey.Bytes(), + EncryptedSecretsKey: encryptedData, + Salt: salt, + }, err +} + +func unpackUnlocker(unlocker *Unlocker, ecdhKey *keys.PublicKey, unlockerKey *keys.PrivateKey) ([]byte, error) { + // derive unique encryption key for unlocker with ECDH algorithm + uniqueUnlockerKey, err := deriveECDH(unlockerKey, ecdhKey) + if err != nil { + return nil, fmt.Errorf("generate ECDH: %w", err) + } + + return decryptData(unlocker.EncryptedSecretsKey, uniqueUnlockerKey, unlocker.Salt) +} + +func encryptData(data, encryptionKey []byte) (encryptedData []byte, salt []byte, err error) { + // generate salt for HKDF key derive function + salt, err = generateRandomBytes(saltLength) + if err != nil { + return nil, nil, fmt.Errorf("generate HKDF salt: %w", err) + } + + // get ChaCha20-Poly1305 AEAD cipher based on + // a key derived from encryptionKey and random salt + enc, err := getCipher(encryptionKey, salt) + if err != nil { + return nil, nil, fmt.Errorf("prepare AEAD: %w", err) + } + + // generate random nonce to encrypt data + nonce := make([]byte, enc.NonceSize()) + _, err = rand.Read(nonce) + if err != nil { + return nil, nil, fmt.Errorf("generate random nonce: %w", err) + } + + return enc.Seal(nonce, nonce, data, nil), salt, nil +} + +func decryptData(encryptedData, encryptionKey, salt []byte) (data []byte, err error) { + // get ChaCha20-Poly1305 AEAD cipher based on + // a key derived from encryptionKey and random salt + dec, err := getCipher(encryptionKey, salt) + if err != nil { + return nil, fmt.Errorf("prepare AEAD: %w", err) + } + + ld, ns := len(encryptedData), dec.NonceSize() + if ld < ns { + return nil, fmt.Errorf("data size %d, should be greater than nonce size %d", ld, ns) + } + + nonce, cypher := encryptedData[:dec.NonceSize()], encryptedData[dec.NonceSize():] + + return dec.Open(nil, nonce, cypher, nil) +} + +func getCipher(secret, salt []byte) (cipher.AEAD, error) { + key, err := deriveHKDF(secret, salt) + if err != nil { + return nil, fmt.Errorf("derive key: %w", err) + } + + return chacha20poly1305.NewX(key) +} + +func deriveECDH(prv *keys.PrivateKey, pub *keys.PublicKey) ([]byte, 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 deriveHKDF(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/mfa/pack_test.go b/mfa/pack_test.go new file mode 100644 index 0000000..d40976f --- /dev/null +++ b/mfa/pack_test.go @@ -0,0 +1,105 @@ +package mfa + +import ( + "encoding/base64" + "encoding/hex" + "testing" + + "github.com/nspcc-dev/neo-go/pkg/crypto/keys" + "github.com/pquerna/otp/totp" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" +) + +func TestPackUnpackBox(t *testing.T) { + unlockerKeys := generateKeys(t, 3) + unlockerPublicKeys := make([]*keys.PublicKey, len(unlockerKeys)) + for i, key := range unlockerKeys { + unlockerPublicKeys[i] = key.PublicKey() + } + + otpKey, err := totp.Generate(totp.GenerateOpts{ + Issuer: "iam-" + hex.EncodeToString(unlockerPublicKeys[0].Bytes()), + AccountName: "NbUgTSFvPmsRxmGeWpuuGeJUoRoi6PErcM (devenv)", + }) + require.NoError(t, err) + + box, err := PackMFABox(otpKey, unlockerPublicKeys) + require.NoError(t, err) + + // make sure secrets are encrypted + secrets := new(Secrets) + err = proto.Unmarshal(box.GetEncryptedSecrets(), secrets) + require.Error(t, err) + + // make sure MFA secret encryption key is encrypted + _, err = decryptData(box.EncryptedSecrets, box.Unlockers[0].EncryptedSecretsKey, box.Salt) + require.Error(t, err) + + for _, unlockerKey := range unlockerKeys { + otpKeyFromBox, err := UnpackMFABox(box, unlockerKey) + require.NoError(t, err) + require.Equal(t, otpKey.URL(), otpKeyFromBox.URL()) + } + + randomKey := generateKeys(t, 1) + _, err = UnpackMFABox(box, randomKey[0]) + require.Error(t, err) +} + +func TestProprietaryCompatibility(t *testing.T) { + for _, tc := range []struct { + binary string // base64 encoded MFA Box + otpURL string // secret + unlockerPrivateKeys []string // hex-encoded private keys + }{ + { + // this is MFA box created with proprietary code with HKDF salt + binary: "Cn8KIQNSH3wIalCMX35hjaEV9sYCJYJ9QC1EFy1eTH/ZaTdaIxJIl6m1UcOuwmtkkcwIqLbX+DNiIvzkGw29YiWbyycvWh048nf4phBKXkzIMy7GXzKJz1n3BWV7q9QPezpLkjeU1Nn2u1czyXrnGhCJ7VGazNvBg50zsokWEXXWCn8KIQK/IVlNTlmdqA0+XxuUR5KjRvEOYVmyO9JjUMSGaFb0ERJIlmYTw2HcLvscJCLiLLGBh+lXRaFUOqCYgKwwT5G352cYJnMejeQ6QlIlzygHTn9N+RHvh4Hmy5Pt1SUik6hVvfil/9LsX8T0GhDHh7f4v3mjmSgU4T4mjX7lCn8KIQLnAI+bca1SzSSPtsp9n6k2+uC4pVPw4rY0eYKUu6fJ4hJI5Pb7qVzFMDQcTLxLrA0vIQ4DEnwGt78QMdZaHM4FqosIMO0C6TLoTUOyXSgK/scF785JEH0freFiOh+fR5OqQNCrLHMjzoL4GhA2xmq4WmFuRaToK9pm4B0YEiEDWC0ggRO0nfUoYBDO58amY6Y13Zs4h05gjLXIp23y8r4avwEoZqY9aG/sHlso1pRtMtSUJdFGpsONIQvhG3nnJHEy1vbDiVzmThK9Onn2huX5vGJ4iKZX8sj2pd9/yo+twqKA9osNQDlVnJikd5wapnPWKKxjAN22pIQC3TJV0AXJdtklg39YO9H0cz5r2ZDFTWOlCZdOnBxoDW3CyxUX+lQMA5GJ/v+zVPQpCu8no8z1NdLNbiIHI6Gn6e0hfSENMeizH3QvKLLaAqR7VUK3dHNAhyHquIe05xt28RImSnZewCIQf3et/IfqVLm8QU9kcfGIFA==", + otpURL: "otpauth://totp/iam-test:NbUgTSFvPmsRxmGeWpuuGeJUoRoi6PErcM?algorithm=SHA1&digits=6&issuer=iam-test&period=30&secret=2EIZ6JTGWBZHYEMTFQGTCWLUIGHMNU2S", + unlockerPrivateKeys: []string{ + "b4e6b0cfd844f454a1cd0c726455356aa2120743d112805cfb05a67202b6a7a4", + "679f5eeab1a885ae7c8c2992149145265f46458cacc8191f6b6902aa7e7efdc8", + "e3ac3501472ab144dfc99d61b69e3fccc6ffa63519a728307624e9aa1e99350d", + }, + }, + { + // this is MFA box created with proprietary code without HKDF salt + binary: "Cm0KIQPjDLGDyyM2KTMx7JxNGYCcY+8V74wgFehEc10gxPde4RJIGWiM6mxo9q0EF8rp/pdFULNy9O+BQc89NIess3z5nSmWFFZxx7j/yKF22hRxLQYenxciHBC0PhbNGXoHG7H4q1a9rhlftcdbCm0KIQOxT6G7TamFLQnktNXXkPrVEXgO4w6yEJ7SQScsXc30EBJI7PSxnqIZmyufYiYkhmwhtP9OSziIA2mFbSz+Wwp45XLKkLYVsT+1Bvj2fKLPZdb5LIPielfRXZpoCrHo8jSDigFae5zxBfsSCm0KIQPEdTuOmmavHwgjPCCgh2ldCTNpubMNzcUc7zakNTDB9hJIl25dRwdxqdV1secWXnhlX6QGWOy8jIswE3MFP0mHhE5yy9jctpIhQKctOCtdsDdMZ081T72omxEc8wRdOK83/cE54P8G7cjPEiECRnc7bPPWVRZtcd5xc4Vvu4LXak16RwA09x4oKsYMB/MavwELRmMpyMwoFgixJrCdFg7PFML4SEb9LE2sj361psAS4kslwIDWMWMPJr110dtzMIL2wOl9AlfpHqHd6CRDhNIv5zOUTyvLSiFZbhC9l8mjEd672uBMlvkSZALOGTnsIigp31a1nDlbyuo9YuGddYGHVyg0tpN3RtHnklL/Bp3rdVGbjPqdo6stp8993syJ1+8mxAE0SvnN/AkuwMODlAHbtieoENVxZWHTtPX0FuFbv9v69I5Sm30RkuKqruPFlw==", + otpURL: "otpauth://totp/iam-test:NbUgTSFvPmsRxmGeWpuuGeJUoRoi6PErcM?algorithm=SHA1&digits=6&issuer=iam-test&period=30&secret=2EIZ6JTGWBZHYEMTFQGTCWLUIGHMNU2S", + unlockerPrivateKeys: []string{ + "17cbdf19c6ebb8c8e7753c489ee7851bf6fe9d157f5dc382bd55a84547cfe050", + "a7b0d0e3e88f2dafa73606d39cadfcf18d1a91d606db70e95815f15b9bd34ea6", + "b7f93214f7897a863cd9e0bdc3dfb1d7bc1fa49672d5e930d7682b22e1341076", + }, + }, + } { + boxData, err := base64.StdEncoding.DecodeString(tc.binary) + require.NoError(t, err) + + var box = new(MFABox) + require.NoError(t, box.Unmarshal(boxData)) + + for _, keyStr := range tc.unlockerPrivateKeys { + key, err := keys.NewPrivateKeyFromHex(keyStr) + require.NoError(t, err) + + otpKey, err := UnpackMFABox(box, key) + require.NoError(t, err) + + require.Equal(t, tc.otpURL, otpKey.URL()) + } + } +} + +func generateKeys(t *testing.T, n int) []*keys.PrivateKey { + var err error + + res := make([]*keys.PrivateKey, n) + for i := 0; i < n; i++ { + res[i], err = keys.NewPrivateKey() + require.NoError(t, err) + } + + return res +} diff --git a/mfa/storage.go b/mfa/storage.go new file mode 100644 index 0000000..7f995a6 --- /dev/null +++ b/mfa/storage.go @@ -0,0 +1,56 @@ +package mfa + +import ( + "context" + "errors" + + 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/user" +) + +type ( + // Storage is an interface for Manager to manage FrostFS objects + // and metadata in tree service. + Storage interface { + // CreateObject creates new FrostFS object. + CreateObject(context.Context, PrmObjectCreate) (oid.ID, error) + // GetObject returns payload of FrostFS object. + GetObject(context.Context, oid.Address) ([]byte, error) + // DeleteObject deletes FrostFS object. + DeleteObject(context.Context, oid.Address) error + // SetTreeNode creates or updates specified tree node the tree service and returns updated data. + SetTreeNode(ctx context.Context, cnrID cid.ID, name string, meta map[string]string) (*TreeMultiNode, error) + // GetTreeNode returns data about latest and remaining versions of specified tree node. + // Must return 'ErrTreeNodeNotFound' if tree does not exist. + GetTreeNode(ctx context.Context, cnrID cid.ID, name string) (*TreeMultiNode, error) + // DeleteTreeNode removes all specified tree nodes from the tree and returns copy of it. + DeleteTreeNode(ctx context.Context, cnrID cid.ID, name string) ([]*TreeNode, error) + // GetTreeNodes returns all available tree nodes with specified prefix. + GetTreeNodes(ctx context.Context, cnrID cid.ID, prefix string) ([]*TreeNode, error) + } + + // TreeNode contains metadata of node in the tree service. + TreeNode struct { + Meta map[string]string + } + + // TreeMultiNode contains metadata of latest and all available versions + // of node in the tree service. + TreeMultiNode struct { + Current TreeNode + Old []*TreeNode + } + + // PrmObjectCreate contains parameters to create new FrostFS object. + PrmObjectCreate struct { + Container cid.ID + Owner user.ID + FilePath string + Payload []byte + } +) + +var ( + ErrTreeNodeNotFound = errors.New("tree node not found") +) -- 2.45.3 From 4514a32e8dd5edc396b345fa3323383a31cd1653 Mon Sep 17 00:00:00 2001 From: Alex Vanin Date: Thu, 13 Mar 2025 11:58:39 +0300 Subject: [PATCH 2/5] [#1] Add test target in Makefile Signed-off-by: Alex Vanin --- Makefile | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Makefile b/Makefile index 4d6c63a..a461dda 100644 --- a/Makefile +++ b/Makefile @@ -32,3 +32,9 @@ protoc: protoc-bin $(PROTOC) --experimental_editions --plugin=protoc-gen-go=$(GOBIN)/protoc-gen-go --go_out=. --go_opt=paths=source_relative $$f \ --plugin=protoc-gen-go-grpc=$(GOBIN)/protoc-gen-go-grpc --go-grpc_out=. --go-grpc_opt=require_unimplemented_servers=false,paths=source_relative $$f; \ done + +# Run Unit Test with go test +test: GOFLAGS ?= "-count=1" +test: + @echo "> Running go test" + @GOFLAGS="$(GOFLAGS)" go test ./... \ No newline at end of file -- 2.45.3 From 2c10d9920fc5531c0e42a2ffffda66dbce4be024 Mon Sep 17 00:00:00 2001 From: Alex Vanin Date: Thu, 13 Mar 2025 12:52:39 +0300 Subject: [PATCH 3/5] [#1] Set linters up Signed-off-by: Alex Vanin --- .golangci.yml | 1 - Makefile | 10 +++++++++- mk/linters.mk | 8 ++++---- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/.golangci.yml b/.golangci.yml index 405bddc..fc8dd38 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -73,7 +73,6 @@ linters: - gocognit - contextcheck - importas - - truecloudlab-linters - perfsprint - testifylint - protogetter diff --git a/Makefile b/Makefile index a461dda..e66e478 100644 --- a/Makefile +++ b/Makefile @@ -1,3 +1,5 @@ +include mk/* + GOBIN ?= $(shell go env GOPATH)/bin PROTOC_VERSION ?= 25.6 @@ -34,7 +36,13 @@ protoc: protoc-bin done # Run Unit Test with go test +.PHONY: test test: GOFLAGS ?= "-count=1" test: @echo "> Running go test" - @GOFLAGS="$(GOFLAGS)" go test ./... \ No newline at end of file + @GOFLAGS="$(GOFLAGS)" go test ./... + +# Clean all installed files +.PHONY: clean +clean: + rm -rf ./bin/* diff --git a/mk/linters.mk b/mk/linters.mk index 1e9b082..bd1aa88 100644 --- a/mk/linters.mk +++ b/mk/linters.mk @@ -1,6 +1,6 @@ GO_VERSION ?= 1.22 -LINT_VERSION ?= 1.56.1 -TRUECLOUDLAB_LINT_VERSION ?= 0.0.5 +LINT_VERSION ?= 1.62.0 +TRUECLOUDLAB_LINT_VERSION ?= 0.0.8 BIN ?= bin OUTPUT_LINT_DIR ?= $(abspath $(BIN))/linters LINT_DIR ?= $(OUTPUT_LINT_DIR)/golangci-lint-$(LINT_VERSION)-v$(TRUECLOUDLAB_LINT_VERSION) @@ -11,14 +11,14 @@ TMP_DIR := .cache # Install linters $(LINT_DIR): @rm -rf $(OUTPUT_LINT_DIR) - @mkdir $(OUTPUT_LINT_DIR) + @mkdir -p $(OUTPUT_LINT_DIR) @mkdir -p $(TMP_DIR) @rm -rf $(TMP_DIR)/linters @git -c advice.detachedHead=false clone --branch v$(TRUECLOUDLAB_LINT_VERSION) https://git.frostfs.info/TrueCloudLab/linters.git $(TMP_DIR)/linters @@make -C $(TMP_DIR)/linters lib CGO_ENABLED=1 OUT_DIR=$(OUTPUT_LINT_DIR) @rm -rf $(TMP_DIR)/linters @rmdir $(TMP_DIR) 2>/dev/null || true - @CGO_ENABLED=1 GOBIN=$(LINT_DIR) go install github.com/golangci/golangci-lint/cmd/golangci-lint@v$(LINT_VERSION) + @CGO_ENABLED=1 GOBIN=$(LINT_DIR) go install -trimpath github.com/golangci/golangci-lint/cmd/golangci-lint@v$(LINT_VERSION) # Run linters lint: $(LINT_DIR) -- 2.45.3 From 7145bd89e1bb4b7c8212063b13703d36fc04edf6 Mon Sep 17 00:00:00 2001 From: Alex Vanin Date: Thu, 13 Mar 2025 12:55:23 +0300 Subject: [PATCH 4/5] [#1] Fix linter issues Signed-off-by: Alex Vanin --- mfa/pack.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/mfa/pack.go b/mfa/pack.go index 9373abe..5f9672b 100644 --- a/mfa/pack.go +++ b/mfa/pack.go @@ -31,10 +31,10 @@ func PackMFABox(secret *otp.Key, unlockerKeys []*keys.PublicKey) (*MFABox, error } // First step: generate encryption key and encrypt secret data with it. - secretUrl := secret.URL() + secretURL := secret.URL() // prepare MFA secret for encryption - data, err := proto.Marshal(&Secrets{MFAURL: &secretUrl}) + data, err := proto.Marshal(&Secrets{MFAURL: &secretURL}) if err != nil { return nil, fmt.Errorf("marshal secrets: %w", err) } @@ -80,14 +80,14 @@ func PackMFABox(secret *otp.Key, unlockerKeys []*keys.PublicKey) (*MFABox, error // UnpackMFABox decrypts OTP key using unlocker key. func UnpackMFABox(box *MFABox, unlockerKey *keys.PrivateKey) (*otp.Key, error) { unlockerPublicKey := unlockerKey.PublicKey().Bytes() - ecdhKey, err := keys.NewPublicKeyFromBytes(box.ECDHPublicKey, elliptic.P256()) + ecdhKey, err := keys.NewPublicKeyFromBytes(box.GetECDHPublicKey(), elliptic.P256()) if err != nil { return nil, fmt.Errorf("parse ECDH key: %w", err) } // First step: find unlocker message for unlocker key var suitableUnlocker *Unlocker - for _, unlocker := range box.Unlockers { + for _, unlocker := range box.GetUnlockers() { if bytes.Equal(unlockerPublicKey, unlocker.GetPublicKey()) { suitableUnlocker = unlocker break @@ -104,7 +104,7 @@ func UnpackMFABox(box *MFABox, unlockerKey *keys.PrivateKey) (*otp.Key, error) { } // Third step: decrypt MFA secret - data, err := decryptData(box.EncryptedSecrets, secretEncryptionKey, box.Salt) + data, err := decryptData(box.GetEncryptedSecrets(), secretEncryptionKey, box.GetSalt()) if err != nil { return nil, fmt.Errorf("decrypt secrets: %w", err) } @@ -146,7 +146,7 @@ func unpackUnlocker(unlocker *Unlocker, ecdhKey *keys.PublicKey, unlockerKey *ke return nil, fmt.Errorf("generate ECDH: %w", err) } - return decryptData(unlocker.EncryptedSecretsKey, uniqueUnlockerKey, unlocker.Salt) + return decryptData(unlocker.GetEncryptedSecretsKey(), uniqueUnlockerKey, unlocker.GetSalt()) } func encryptData(data, encryptionKey []byte) (encryptedData []byte, salt []byte, err error) { -- 2.45.3 From 1f45e3d9846a20699dea6f47e76fc1d734316698 Mon Sep 17 00:00:00 2001 From: Alex Vanin Date: Thu, 13 Mar 2025 13:12:49 +0300 Subject: [PATCH 5/5] [#1] Add basic forgejo workflows Signed-off-by: Alex Vanin --- .forgejo/workflows/dco.yml | 21 +++++++++++ .forgejo/workflows/govulncheck.yml | 28 +++++++++++++++ .forgejo/workflows/tests.yml | 57 ++++++++++++++++++++++++++++++ 3 files changed, 106 insertions(+) create mode 100644 .forgejo/workflows/dco.yml create mode 100644 .forgejo/workflows/govulncheck.yml create mode 100644 .forgejo/workflows/tests.yml diff --git a/.forgejo/workflows/dco.yml b/.forgejo/workflows/dco.yml new file mode 100644 index 0000000..5434c84 --- /dev/null +++ b/.forgejo/workflows/dco.yml @@ -0,0 +1,21 @@ +name: DCO action +on: [ pull_request ] + +jobs: + dco: + name: DCO + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - name: Setup Go + uses: actions/setup-go@v3 + with: + go-version: '1.22' + + - name: Run commit format checker + uses: https://git.frostfs.info/TrueCloudLab/dco-go@v3 + with: + from: 'origin/${{ github.event.pull_request.base.ref }}' \ No newline at end of file diff --git a/.forgejo/workflows/govulncheck.yml b/.forgejo/workflows/govulncheck.yml new file mode 100644 index 0000000..65e7886 --- /dev/null +++ b/.forgejo/workflows/govulncheck.yml @@ -0,0 +1,28 @@ +name: Vulncheck + +on: + pull_request: + push: + branches: + - master + +jobs: + vulncheck: + name: Vulncheck + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - name: Setup Go + uses: actions/setup-go@v3 + with: + go-version: '1.23' + check-latest: true + + - name: Install govulncheck + run: go install golang.org/x/vuln/cmd/govulncheck@latest + + - name: Run govulncheck + run: govulncheck ./... \ No newline at end of file diff --git a/.forgejo/workflows/tests.yml b/.forgejo/workflows/tests.yml new file mode 100644 index 0000000..7ad5896 --- /dev/null +++ b/.forgejo/workflows/tests.yml @@ -0,0 +1,57 @@ +name: Tests and linters + +on: + pull_request: + push: + branches: + - master + +jobs: + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Set up Go + uses: actions/setup-go@v3 + with: + go-version: '1.23' + cache: true + + - name: Run linters + run: make lint + + tests: + name: Tests + runs-on: ubuntu-latest + strategy: + matrix: + go_versions: [ '1.22', '1.23' ] + fail-fast: false + steps: + - uses: actions/checkout@v3 + + - name: Set up Go + uses: actions/setup-go@v3 + with: + go-version: '${{ matrix.go_versions }}' + cache: true + + - name: Run tests + run: make test + + tests-race: + name: Tests with -race + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Set up Go + uses: actions/setup-go@v3 + with: + go-version: '1.22' + cache: true + + - name: Run tests + run: go test ./... -count=1 -race \ No newline at end of file -- 2.45.3