From 5d1530a48fd5778dad7c1716a3ba26105342439f Mon Sep 17 00:00:00 2001 From: Andrey Butusov Date: Wed, 21 Oct 2026 18:38:08 +0300 Subject: [PATCH 1/2] state: encrypt session keys with HPKE Replace direct use of the node private key as an AES key with HPKE. Atomically migrate legacy records. Closes #3426. Signed-off-by: Andrey Butusov --- CHANGELOG.md | 7 +- pkg/util/state/encryption.go | 32 ------- pkg/util/state/encryption_test.go | 87 +++++++++++++++++- pkg/util/state/executor_test.go | 142 +++++++++++++++++++++++++++++- pkg/util/state/storage.go | 8 +- pkg/util/state/token.go | 78 ++++++++++------ pkg/util/state/util.go | 15 +++- 7 files changed, 297 insertions(+), 72 deletions(-) delete mode 100644 pkg/util/state/encryption.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 846e0d5344..a458c0ee1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ Changelog for NeoFS Node - CLI `--xhdr` (`-x`) flag is deprecated now (#4222) - Policer now uses an in-memory queue for redundant object removal, reducing metabase writes (#4216) - Session token storage now indexes tokens by expiration epoch for efficient cleanup (#4243) +- Session key encryption now uses HPKE (#4247) ### Removed - fstree-decompress utility (#4223) @@ -51,8 +52,10 @@ Remove `--xhdr` (`-x`) flags from all CLI commands. To GET/HEAD object's EC part Use plain URIs instead of multiaddress in node config and CLI invocations. Session records in `node.persistent_state.path` are migrated automatically on -startup and become unreadable by previous versions. Before upgrading, stop the -node and back up this file in case a rollback is needed. +startup to an epoch-indexed format with HPKE encryption and become unreadable by +previous versions. Before upgrading, stop the node and back up this file. +Do not open the migrated database with an older node binary; restore the +pre-upgrade backup before rolling back. ## [0.57.0] - 2026-09-30 - Bido diff --git a/pkg/util/state/encryption.go b/pkg/util/state/encryption.go deleted file mode 100644 index ffa683cb49..0000000000 --- a/pkg/util/state/encryption.go +++ /dev/null @@ -1,32 +0,0 @@ -package state - -import ( - "crypto/rand" - "fmt" - "io" -) - -func (p PersistentStorage) encrypt(value []byte) ([]byte, error) { - nonce := make([]byte, p.gcm.NonceSize()) - - if _, err := io.ReadFull(rand.Reader, nonce); err != nil { - return nil, fmt.Errorf("could not init random nonce: %w", err) - } - - return p.gcm.Seal(nonce, nonce, value, nil), nil -} - -func (p PersistentStorage) decrypt(value []byte) ([]byte, error) { - nonceSize := p.gcm.NonceSize() - if len(value) < nonceSize { - return nil, fmt.Errorf( - "unexpected encrypted length: nonce length is %d, encrypted data length is %d", - nonceSize, - len(value), - ) - } - - nonce, encryptedData := value[:nonceSize], value[nonceSize:] - - return p.gcm.Open(nil, nonce, encryptedData, nil) -} diff --git a/pkg/util/state/encryption_test.go b/pkg/util/state/encryption_test.go index 9a3812b088..9826a92cb2 100644 --- a/pkg/util/state/encryption_test.go +++ b/pkg/util/state/encryption_test.go @@ -5,7 +5,10 @@ import ( "path/filepath" "testing" + "github.com/nspcc-dev/bbolt" "github.com/nspcc-dev/neo-go/pkg/crypto/keys" + sessionv2 "github.com/nspcc-dev/neofs-sdk-go/session/v2" + usertest "github.com/nspcc-dev/neofs-sdk-go/user/test" "github.com/stretchr/testify/require" ) @@ -19,12 +22,88 @@ func TestTokenStore_Encryption(t *testing.T) { encryptedData, err := ts.encrypt(data) require.NoError(t, err) - require.False(t, bytes.Equal(data, encryptedData)) - decryptedData, err := ts.decrypt(encryptedData) - require.NoError(t, err) + t.Run("round trip", func(t *testing.T) { + require.NotEqual(t, data, encryptedData) + decrypted, err := ts.decrypt(encryptedData) + require.NoError(t, err) + require.Equal(t, data, decrypted) + }) + + t.Run("randomized encryption", func(t *testing.T) { + encrypted, err := ts.encrypt(data) + require.NoError(t, err) + require.NotEqual(t, encryptedData, encrypted) + }) + + t.Run("wrong key", func(t *testing.T) { + key, err := keys.NewPrivateKey() + require.NoError(t, err) + other := newStorageWithSession(t, filepath.Join(t.TempDir(), ".storage"), WithEncryptionKey(&key.PrivateKey)) + _, err = other.decrypt(encryptedData) + require.Error(t, err) + }) + + t.Run("truncated ciphertext", func(t *testing.T) { + for n := range encryptedData { + _, err := ts.decrypt(encryptedData[:n]) + require.Error(t, err, "truncated to %d bytes", n) + } + }) + + t.Run("corrupted ciphertext", func(t *testing.T) { + for i := range encryptedData { + corrupted := bytes.Clone(encryptedData) + corrupted[i] ^= 1 + _, err := ts.decrypt(corrupted) + require.Error(t, err, "corrupted byte %d", i) + } + }) + + t.Run("persistence", func(t *testing.T) { + path := filepath.Join(t.TempDir(), ".storage") + ts := newStorageWithSession(t, path, WithEncryptionKey(&pk.PrivateKey)) + // An empty encrypted store must also survive reopening. + require.NoError(t, ts.Close()) + ts = newStorageWithSession(t, path, WithEncryptionKey(&pk.PrivateKey)) + + account := usertest.User() + require.NoError(t, ts.Store(account.ECDSAPrivateKey, 12345)) + before := storedSessions(t, ts) + require.NoError(t, ts.Close()) + + wrongKey, err := keys.NewPrivateKey() + require.NoError(t, err) + wrongStore := newStorageWithSession(t, path, WithEncryptionKey(&wrongKey.PrivateKey)) + require.Nil(t, wrongStore.GetToken(account.ID)) + require.Equal(t, before, storedSessions(t, wrongStore)) + require.NoError(t, wrongStore.Close()) + + ts = newStorageWithSession(t, path, WithEncryptionKey(&pk.PrivateKey)) + require.Equal(t, before, storedSessions(t, ts)) + token := ts.GetToken(account.ID) + require.NotNil(t, token) + require.Equal(t, account.ECDSAPrivateKey, *token.SessionKey()) + require.EqualValues(t, 12345, token.ExpiredAt()) + + subject := sessionv2.NewTargetUser(account.ID) + require.Equal(t, token, ts.FindTokenBySubjects([]sessionv2.Target{subject})) + ts.RemoveOldTokens(12344) + require.NotNil(t, ts.GetToken(account.ID)) + ts.RemoveOldTokens(12345) + require.Nil(t, ts.GetToken(account.ID)) + }) +} - require.Equal(t, data, decryptedData) +func storedSessions(t *testing.T, p *PersistentStorage) map[string][]byte { + records := make(map[string][]byte) + require.NoError(t, p.db.View(func(tx *bbolt.Tx) error { + return tx.Bucket(sessionsBucket).ForEach(func(k, v []byte) error { + records[string(k)] = bytes.Clone(v) + return nil + }) + })) + return records } func newStorageWithSession(tb testing.TB, path string, opts ...Option) *PersistentStorage { diff --git a/pkg/util/state/executor_test.go b/pkg/util/state/executor_test.go index ff1cb20659..8bf85d2eed 100644 --- a/pkg/util/state/executor_test.go +++ b/pkg/util/state/executor_test.go @@ -2,14 +2,21 @@ package state import ( "bytes" + "crypto/aes" + "crypto/cipher" "crypto/ecdsa" + "crypto/rand" + "crypto/x509" + "encoding/binary" "fmt" "math" "path/filepath" + "slices" "testing" "github.com/nspcc-dev/bbolt" bolterrors "github.com/nspcc-dev/bbolt/errors" + "github.com/nspcc-dev/neo-go/pkg/crypto/keys" sessionv2 "github.com/nspcc-dev/neofs-sdk-go/session/v2" "github.com/nspcc-dev/neofs-sdk-go/user" usertest "github.com/nspcc-dev/neofs-sdk-go/user/test" @@ -110,6 +117,9 @@ func TestTokenStore_Migration(t *testing.T) { t.Run("empty", func(t *testing.T) { path := filepath.Join(t.TempDir(), ".storage") ts := newStorageWithSession(t, path) + require.NoError(t, ts.db.Update(func(tx *bbolt.Tx) error { + return ts.migrateTokens(tx.Bucket(sessionsBucket)) + })) require.NoError(t, ts.Close()) ts = newStorageWithSession(t, path) @@ -149,14 +159,14 @@ func TestTokenStore_Migration(t *testing.T) { require.NoError(t, err) } require.NoError(t, ts.SetBytes([]byte("unrelated"), []byte("state"))) - // Populate the exact pre-migration layout, including encrypted values. + // Test key migration independently of the encryption scheme. prepareLegacy(t, ts, func(b *bbolt.Bucket) error { for i, account := range accounts { if err := b.Put(account.ID[:], values[i]); err != nil { return err } } - return nil + return new(PersistentStorage).migrateTokens(b) }) // A second reopen must neither repeat the migration nor change values. @@ -235,6 +245,134 @@ func TestTokenStore_Migration(t *testing.T) { }) } +func TestTokenStore_MigrateEncryption(t *testing.T) { + for _, tc := range []struct { + name string + wrongKey bool + first bool + corrupt func([]byte) []byte + invalidKey bool + }{ + {name: "valid"}, + {name: "wrong node key", wrongKey: true}, + {name: "corrupt first record", first: true, corrupt: func(raw []byte) []byte { + raw[len(raw)-1] ^= 1 + return raw + }}, + {name: "corrupt last record", corrupt: func(raw []byte) []byte { + raw[len(raw)-1] ^= 1 + return raw + }}, + {name: "truncated epoch", corrupt: func(raw []byte) []byte { return raw[:keyOffset-1] }}, + {name: "truncated ciphertext", corrupt: func(raw []byte) []byte { return raw[:keyOffset+1] }}, + {name: "invalid private key", invalidKey: true}, + } { + t.Run(tc.name, func(t *testing.T) { + pk, err := keys.NewPrivateKey() + require.NoError(t, err) + path := filepath.Join(t.TempDir(), ".storage") + ts := newStorageWithSession(t, path) + require.NoError(t, ts.SetBytes([]byte("unrelated"), []byte("state"))) + + // Build the old on-disk format independently of the new encryption code. + key, err := pk.ECDH() + require.NoError(t, err) + block, err := aes.NewCipher(key.Bytes()) + require.NoError(t, err) + gcm, err := cipher.NewGCM(block) + require.NoError(t, err) + accounts := []usertest.UserSigner{usertest.User(), usertest.User(), usertest.User()} + require.NoError(t, ts.db.Update(func(tx *bbolt.Tx) error { + b := tx.Bucket(sessionsBucket) + for i, account := range accounts { + plain, err := x509.MarshalECPrivateKey(&account.ECDSAPrivateKey) + require.NoError(t, err) + if tc.invalidKey && i == 2 { + plain = []byte("invalid private key") + } + nonce := make([]byte, gcm.NonceSize()) + _, err = rand.Read(nonce) + require.NoError(t, err) + raw := make([]byte, keyOffset) + binary.LittleEndian.PutUint64(raw, uint64(i+1)) + raw = append(raw, gcm.Seal(nonce, nonce, plain, nil)...) + require.NoError(t, b.Put(account.ID[:], raw)) + } + return nil + })) + if tc.corrupt != nil { + records := storedSessions(t, ts) + var ids []string + for id := range records { + ids = append(ids, id) + } + slices.Sort(ids) + id := ids[len(ids)-1] + if tc.first { + id = ids[0] + } + require.NoError(t, ts.db.Update(func(tx *bbolt.Tx) error { + return tx.Bucket(sessionsBucket).Put([]byte(id), tc.corrupt(records[id])) + })) + } + before := storedSessions(t, ts) + require.NoError(t, ts.Close()) + + openingKey := pk + if tc.wrongKey { + openingKey, err = keys.NewPrivateKey() + require.NoError(t, err) + } + migrated, err := NewPersistentStorage(path, true, WithEncryptionKey(&openingKey.PrivateKey)) + if tc.wrongKey || tc.corrupt != nil || tc.invalidKey { + require.Error(t, err) + require.Nil(t, migrated) + // A failed migration must release the DB and roll back every write. + ts, err = NewPersistentStorage(path, false) + require.NoError(t, err) + t.Cleanup(func() { _ = ts.Close() }) + require.Equal(t, before, storedSessions(t, ts)) + state, err := ts.Bytes([]byte("unrelated")) + require.NoError(t, err) + require.Equal(t, []byte("state"), state) + return + } + require.NoError(t, err) + t.Cleanup(func() { _ = migrated.Close() }) + after := storedSessions(t, migrated) + require.Len(t, after, 2*len(accounts)) + for i, account := range accounts { + require.NotContains(t, after, string(account.ID[:])) + require.NotEqual(t, before[string(account.ID[:])], after[string(tokenKey(account.ID))]) + require.Contains(t, after, string(tokenEpochKey(uint64(i+1), account.ID))) + token := migrated.GetToken(account.ID) + require.NotNil(t, token) + require.Equal(t, account.ECDSAPrivateKey, *token.SessionKey()) + require.EqualValues(t, i+1, token.ExpiredAt()) + } + state, err := migrated.Bytes([]byte("unrelated")) + require.NoError(t, err) + require.Equal(t, []byte("state"), state) + newAccount := usertest.User() + require.NoError(t, migrated.Store(newAccount.ECDSAPrivateKey, 4)) + after = storedSessions(t, migrated) + require.NoError(t, migrated.Close()) + reopened := newStorageWithSession(t, path, WithEncryptionKey(&pk.PrivateKey)) + require.Equal(t, after, storedSessions(t, reopened)) + require.NotNil(t, reopened.GetToken(newAccount.ID)) + reopened.RemoveOldTokens(2) + for i, account := range accounts { + if i < 2 { + require.Nil(t, reopened.GetToken(account.ID)) + } else { + require.NotNil(t, reopened.GetToken(account.ID)) + } + } + require.NotNil(t, reopened.GetToken(newAccount.ID)) + }) + } +} + func TestTokenStore_Persistent(t *testing.T) { path := filepath.Join(t.TempDir(), ".storage") ts := newStorageWithSession(t, path) diff --git a/pkg/util/state/storage.go b/pkg/util/state/storage.go index fdf9634317..d8f4be818a 100644 --- a/pkg/util/state/storage.go +++ b/pkg/util/state/storage.go @@ -2,7 +2,7 @@ package state import ( "bytes" - "crypto/cipher" + "crypto/hpke" "encoding/binary" "fmt" @@ -20,10 +20,8 @@ type PersistentStorage struct { l *zap.Logger - // optional AES-256 algorithm - // encryption in Galois/Counter - // Mode - gcm cipher.AEAD + // Optional HPKE key for encrypting private session keys. + encryptionKey hpke.PrivateKey } var stateBucket = []byte("state") diff --git a/pkg/util/state/token.go b/pkg/util/state/token.go index 8c7566dbe6..9482ca6748 100644 --- a/pkg/util/state/token.go +++ b/pkg/util/state/token.go @@ -4,6 +4,8 @@ import ( "bytes" "crypto/aes" "crypto/cipher" + "crypto/hpke" + "crypto/x509" "encoding/binary" "fmt" @@ -18,38 +20,28 @@ var sessionsBucket = []byte("sessions") // initTokenStore initializes the TokenStore over the bolt DB. func (p *PersistentStorage) initTokenStore(cfg cfg) error { - err := p.db.Update(func(tx *bbolt.Tx) error { - b, err := tx.CreateBucketIfNotExists(sessionsBucket) + if cfg.privateKey != nil { + key, err := cfg.privateKey.ECDH() if err != nil { - return err + return fmt.Errorf("could not convert encryption key to ECDH: %w", err) } - return migrateTokens(b) - }) - if err != nil { - _ = p.db.Close() - - return fmt.Errorf("could not init session bucket: %w", err) - } - // enable encryption if it - // was configured so - if cfg.privateKey != nil { - rawKey := make([]byte, (cfg.privateKey.Params().N.BitLen()+7)/8) - cfg.privateKey.D.FillBytes(rawKey) // nolint:staticcheck // To be fixed in #3426. - - c, err := aes.NewCipher(rawKey) + p.encryptionKey, err = hpke.NewDHKEMPrivateKey(key) if err != nil { - return fmt.Errorf("could not create cipher block: %w", err) + return fmt.Errorf("could not create HPKE key: %w", err) } + } - gcm, err := cipher.NewGCM(c) + err := p.db.Update(func(tx *bbolt.Tx) error { + b, err := tx.CreateBucketIfNotExists(sessionsBucket) if err != nil { - return fmt.Errorf("could not wrap cipher block in Galois Counter Mode: %w", err) + return err } - - p.gcm = gcm + return p.migrateTokens(b) + }) + if err != nil { + return fmt.Errorf("could not init session bucket: %w", err) } - return nil } @@ -163,10 +155,10 @@ func (p PersistentStorage) FindTokenBySubjects(subjects []sessionv2.Target) *ses return token } -// migrateTokens upgrades the legacy account keys without changing token values. +// migrateTokens upgrades legacy account keys and optionally re-encrypts token values. // All token/index pairs are committed in the same transaction, so legacy and new // keys cannot coexist. A new token key sorts first and means migration is complete. -func migrateTokens(b *bbolt.Bucket) error { +func (p PersistentStorage) migrateTokens(b *bbolt.Bucket) error { if k, _ := b.Cursor().First(); k == nil || len(k) == sessionPrefixSize+len(user.ID{}) && k[0] == sessionTokenPrefix { return nil } @@ -186,12 +178,48 @@ func migrateTokens(b *bbolt.Bucket) error { }); err != nil { return err } + var gcm cipher.AEAD + if p.encryptionKey != nil { + legacyKey, err := p.encryptionKey.Bytes() + if err != nil { + return fmt.Errorf("could not export legacy encryption key: %w", err) + } + defer clear(legacyKey) + + block, err := aes.NewCipher(legacyKey) + if err != nil { + return fmt.Errorf("could not create legacy cipher: %w", err) + } + gcm, err = cipher.NewGCM(block) + if err != nil { + return fmt.Errorf("could not create legacy GCM: %w", err) + } + } + for _, account := range accounts { raw := bytes.Clone(b.Get(account[:])) epoch, err := epochFromToken(raw) if err != nil { return err } + if gcm != nil { + encrypted := raw[keyOffset:] + if len(encrypted) < gcm.NonceSize() { + return fmt.Errorf("legacy session ciphertext is too short: %d bytes", len(encrypted)) + } + plain, err := gcm.Open(nil, encrypted[:gcm.NonceSize()], encrypted[gcm.NonceSize():], nil) + if err != nil { + return fmt.Errorf("could not decrypt legacy session key: %w", err) + } + key, err := x509.ParseECPrivateKey(plain) + if err != nil { + return fmt.Errorf("could not unmarshal legacy session key: %w", err) + } + raw, err = p.packToken(epoch, key) + if err != nil { + return err + } + } if err := b.Put(tokenKey(account), raw); err != nil { return err } diff --git a/pkg/util/state/util.go b/pkg/util/state/util.go index 051d0f827e..4efc442793 100644 --- a/pkg/util/state/util.go +++ b/pkg/util/state/util.go @@ -2,6 +2,7 @@ package state import ( "crypto/ecdsa" + "crypto/hpke" "crypto/x509" "encoding/binary" "errors" @@ -14,6 +15,8 @@ import ( const ( keyOffset = 8 + sessionEncryptionInfo = "neofs-node/session-storage" + sessionTokenPrefix = 0 sessionEpochPrefix = 1 @@ -45,7 +48,7 @@ func (p PersistentStorage) packToken(exp uint64, key *ecdsa.PrivateKey) ([]byte, return nil, fmt.Errorf("could not marshal private key: %w", err) } - if p.gcm != nil { + if p.encryptionKey != nil { rawKey, err = p.encrypt(rawKey) if err != nil { return nil, fmt.Errorf("could not encrypt session key: %w", err) @@ -69,7 +72,7 @@ func (p PersistentStorage) unpackToken(raw []byte) (*session.PrivateToken, error } rawKey := raw[keyOffset:] - if p.gcm != nil { + if p.encryptionKey != nil { rawKey, err = p.decrypt(rawKey) if err != nil { return nil, fmt.Errorf("could not decrypt session key: %w", err) @@ -84,6 +87,14 @@ func (p PersistentStorage) unpackToken(raw []byte) (*session.PrivateToken, error return session.NewPrivateToken(key, epoch), nil } +func (p PersistentStorage) encrypt(value []byte) ([]byte, error) { + return hpke.Seal(p.encryptionKey.PublicKey(), hpke.HKDFSHA256(), hpke.AES256GCM(), []byte(sessionEncryptionInfo), value) +} + +func (p PersistentStorage) decrypt(value []byte) ([]byte, error) { + return hpke.Open(p.encryptionKey, hpke.HKDFSHA256(), hpke.AES256GCM(), []byte(sessionEncryptionInfo), value) +} + func epochFromToken(rawToken []byte) (uint64, error) { if len(rawToken) < keyOffset { return 0, fmt.Errorf("%w: too short: %d bytes", errInvalidPackedToken, len(rawToken)) From ab4e5b0affd6a3fa6d2204dedb43e16722d32ea7 Mon Sep 17 00:00:00 2001 From: Andrey Butusov Date: Thu, 22 Oct 2026 18:47:48 +0300 Subject: [PATCH 2/2] storage: close engines in initialization failure tests Register engine cleanup before adding shards so GC workers stop before the test finishes, including on assertion failures. Fix: ``` panic: Log in goroutine after TestInitializationFailure has completed: 2026-10-22T15:35:31.098Z DEBUG started expired objects handling {"shard_id": "S5XQxiKfqKatUEhoHMwZ5M", "epoch": 0} [recovered, repanicked] goroutine 93 [running]: sync.(*WaitGroup).Go.func1.1() /opt/hostedtoolcache/go/1.27.3/x64/src/sync/waitgroup.go:251 +0x3a panic({0x1c15dd8?, 0xc00032cfa0?}) /opt/hostedtoolcache/go/1.27.3/x64/src/runtime/panic.go:859 +0x125 testing.(*common).log(0xc0003ac6c8, {0xc0004bc380, 0x72}, 0x0) /opt/hostedtoolcache/go/1.27.3/x64/src/testing/testing.go:1123 +0x1e7 testing.(*common).Logf(0xc0003ac6c8, {0x11f600a, 0x2}, {0xc00032cf70, 0x1, 0x1}) /opt/hostedtoolcache/go/1.27.3/x64/src/testing/testing.go:1345 +0x91 go.uber.org/zap/zaptest.TestingWriter.Write({{0x1cfa128?, 0xc0003ac6c8?}, 0x60?}, {0xc0002a2800, 0x73, 0x400}) /home/runner/go/pkg/mod/go.uber.org/zap@v1.28.0/zaptest/logger.go:146 +0x11a go.uber.org/zap/zapcore.(*ioCore).Write(0xc0007daea0, {0xff, {0xc2aea930c5e3f97f, 0x709cbb486, 0x1d8e720}, {0x0, 0x0}, {0x1213392, 0x20}, {0x0, ...}, ...}, ...) /home/runner/go/pkg/mod/go.uber.org/zap@v1.28.0/zapcore/core.go:99 +0x202 go.uber.org/zap/zapcore.(*CheckedEntry).Write(0xc0006e80e0, {0x0, 0x0, 0x0}) /home/runner/go/pkg/mod/go.uber.org/zap@v1.28.0/zapcore/entry.go:276 +0x568 go.uber.org/zap.(*Logger).Debug(0xc000902900, {0x1213392, 0x20}, {0x0, 0x0, 0x0}) /home/runner/go/pkg/mod/go.uber.org/zap@v1.28.0/logger.go:239 +0x68 github.com/nspcc-dev/neofs-node/pkg/local_object_storage/shard.(*Shard).collectExpiredObjects(0xc0000a2620) /home/runner/work/neofs-node/neofs-node/pkg/local_object_storage/shard/gc.go:192 +0x22b github.com/nspcc-dev/neofs-node/pkg/local_object_storage/shard.(*Shard).removeGarbage(0xc0000a2620) /home/runner/work/neofs-node/neofs-node/pkg/local_object_storage/shard/gc.go:134 +0x3f github.com/nspcc-dev/neofs-node/pkg/local_object_storage/shard.(*gc).tickRemover(0xc0000b6410) /home/runner/work/neofs-node/neofs-node/pkg/local_object_storage/shard/gc.go:112 +0x96 sync.(*WaitGroup).Go.func1() /opt/hostedtoolcache/go/1.27.3/x64/src/sync/waitgroup.go:258 +0x4a created by sync.(*WaitGroup).Go in goroutine 71 /opt/hostedtoolcache/go/1.27.3/x64/src/sync/waitgroup.go:238 +0x73 FAIL github.com/nspcc-dev/neofs-node/pkg/local_object_storage/engine 30.356s ``` Signed-off-by: Andrey Butusov --- pkg/local_object_storage/engine/control_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/local_object_storage/engine/control_test.go b/pkg/local_object_storage/engine/control_test.go index 90f984ea82..cb8da88561 100644 --- a/pkg/local_object_storage/engine/control_test.go +++ b/pkg/local_object_storage/engine/control_test.go @@ -86,6 +86,7 @@ func testEngineFailInitAndReload(t *testing.T, badDir string, errOnAdd bool, s [ var configID string e := New() + t.Cleanup(func() { require.NoError(t, e.Close()) }) _, err := e.AddShard(s...) if errOnAdd { require.Error(t, err)