Compare commits

...
Sign in to create a new pull request.

9 commits

Author SHA1 Message Date
e09ead8de2 [#9] Add CopiesNumber to CreateObject
Signed-off-by: Pavel Pogodaev <p.pogodaev@yadro.com>
2025-03-19 12:34:58 +00:00
7d9494960b [#8] Do not use leading slash in filepath
While FrostFS API requires filepath to start with
leading slash, it should not in tree service, because
'newDevice' expects exactly two values after split. It
cannot be changed without breaking changes.

Signed-off-by: Alex Vanin <a.vanin@yadro.com>
2025-03-18 18:03:31 +03:00
7bca08b339 [#5] Fix Go version declaration in go.mod
Signed-off-by: Alex Vanin <a.vanin@yadro.com>
2025-03-14 09:45:55 +03:00
80fb768113 [#4] Fix module name
Signed-off-by: Alex Vanin <a.vanin@yadro.com>
2025-03-13 14:30:09 +03:00
1f45e3d984 [#1] Add basic forgejo workflows
Signed-off-by: Alex Vanin <a.vanin@yadro.com>
2025-03-13 13:41:22 +03:00
7145bd89e1 [#1] Fix linter issues
Signed-off-by: Alex Vanin <a.vanin@yadro.com>
2025-03-13 13:41:17 +03:00
2c10d9920f [#1] Set linters up
Signed-off-by: Alex Vanin <a.vanin@yadro.com>
2025-03-13 13:41:11 +03:00
4514a32e8d [#1] Add test target in Makefile
Signed-off-by: Alex Vanin <a.vanin@yadro.com>
2025-03-13 13:41:06 +03:00
9462aea03d [#1] Add initial implementation of MFA library
Signed-off-by: Alex Vanin <a.vanin@yadro.com>
2025-03-13 13:40:57 +03:00
17 changed files with 1606 additions and 5 deletions

View file

@ -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 }}'

View file

@ -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 ./...

View file

@ -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

View file

@ -73,7 +73,6 @@ linters:
- gocognit
- contextcheck
- importas
- truecloudlab-linters
- perfsprint
- testifylint
- protogetter

48
Makefile Normal file
View file

@ -0,0 +1,48 @@
include mk/*
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
# Run Unit Test with go test
.PHONY: test
test: GOFLAGS ?= "-count=1"
test:
@echo "> Running go test"
@GOFLAGS="$(GOFLAGS)" go test ./...
# Clean all installed files
.PHONY: clean
clean:
rm -rf ./bin/*

46
go.mod Normal file
View file

@ -0,0 +1,46 @@
module git.frostfs.info/TrueCloudLab/frostfs-mfa
go 1.22
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
)

103
go.sum Normal file
View file

@ -0,0 +1,103 @@
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/rfc6979 v0.4.0 h1:M2KR3iBj7WpY3hP10IevfIB9MURr4O9mwVfJ+SjT3HA=
git.frostfs.info/TrueCloudLab/rfc6979 v0.4.0/go.mod h1:okpbKfVYf/BpejtfFTfhZqFP+sZ8rsHrP8Rr/jYPNRc=
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/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/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/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/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/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
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/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
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/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/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/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/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/rfc6979 v0.2.1 h1:8wWxkamHWFmO790GsewSoKUSJjVnL1fmdRpokU/RgRM=
github.com/nspcc-dev/rfc6979 v0.2.1/go.mod h1:Tk7h5kyUWkhjyO3zUgFFhy1v2vQv3BvQEntakdtqrWc=
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/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/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
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=
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/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4=
golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU=
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/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
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/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=

15
mfa/box.go Normal file
View file

@ -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)
}

130
mfa/device.go Normal file
View file

@ -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
}

8
mfa/errors.go Normal file
View file

@ -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")
)

417
mfa/mfa.go Normal file
View file

@ -0,0 +1,417 @@
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
settings Settings
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
Settings Settings
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")
}
if cfg.Settings == nil {
return nil, errors.New("mfa settings is nil")
}
return &Manager{
storage: cfg.Storage,
container: cfg.Container,
unlocker: cfg.Unlocker,
logger: cfg.Logger,
settings: cfg.Settings,
}, 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,
CopiesNumber: m.settings.CopiesNumber(),
})
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
}

289
mfa/mfabox.pb.go generated Normal file
View file

@ -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
}

44
mfa/mfabox.proto Normal file
View file

@ -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"];
}

229
mfa/pack.go Normal file
View file

@ -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.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.GetUnlockers() {
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.GetEncryptedSecrets(), secretEncryptionKey, box.GetSalt())
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.GetEncryptedSecretsKey(), uniqueUnlockerKey, unlocker.GetSalt())
}
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
}

105
mfa/pack_test.go Normal file
View file

@ -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
}

62
mfa/storage.go Normal file
View file

@ -0,0 +1,62 @@
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)
}
Settings interface {
// CopiesNumber returns list of copies number for MFA object.
CopiesNumber() []uint32
}
// 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
CopiesNumber []uint32
}
)
var (
ErrTreeNodeNotFound = errors.New("tree node not found")
)

View file

@ -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)