Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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

Expand Down
1 change: 1 addition & 0 deletions pkg/local_object_storage/engine/control_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
32 changes: 0 additions & 32 deletions pkg/util/state/encryption.go

This file was deleted.

87 changes: 83 additions & 4 deletions pkg/util/state/encryption_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand All @@ -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 {
Expand Down
142 changes: 140 additions & 2 deletions pkg/util/state/executor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down
8 changes: 3 additions & 5 deletions pkg/util/state/storage.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ package state

import (
"bytes"
"crypto/cipher"
"crypto/hpke"
"encoding/binary"
"fmt"

Expand All @@ -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")
Expand Down
Loading
Loading