diff --git a/Makefile b/Makefile index 43f0b29d6..d800a48ab 100644 --- a/Makefile +++ b/Makefile @@ -90,3 +90,7 @@ postcli: get-postrs-lib bench: @$(ULIMIT) CGO_LDFLAGS="$(CGO_TEST_LDFLAGS)" go test -benchmem -run='^$$' -bench 'BenchmarkVerifying|BenchmarkProving' github.com/spacemeshos/post/proving github.com/spacemeshos/post/verifying .PHONY: bench + +fuzz: + @$(ULIMIT) CGO_LDFLAGS="$(CGO_TEST_LDFLAGS)" ./scripts/fuzz.sh $(FUZZTIME) +.PHONY: fuzz diff --git a/config/config.go b/config/config.go index 24d869670..e9e77b315 100644 --- a/config/config.go +++ b/config/config.go @@ -2,7 +2,6 @@ package config import ( "encoding/hex" - "errors" "fmt" "math" "os" @@ -118,7 +117,7 @@ type InitOpts struct { MaxFileSize uint64 ProviderID *uint32 Throttle bool - Scrypt ScryptParams + Scrypt shared.ScryptParams // ComputeBatchSize must be greater than 0 ComputeBatchSize uint64 @@ -140,25 +139,8 @@ func (o *InitOpts) TotalFiles(labelsPerUnit uint64) int { return int(math.Ceil(float64(o.TotalLabels(labelsPerUnit)) / float64(o.MaxFileNumLabels()))) } -type ScryptParams struct { - N, R, P uint -} - -func (p *ScryptParams) Validate() error { - if p.N == 0 { - return errors.New("scrypt parameter N cannot be 0") - } - if p.R == 0 { - return errors.New("scrypt parameter r cannot be 0") - } - if p.P == 0 { - return errors.New("scrypt parameter p cannot be 0") - } - return nil -} - -func DefaultLabelParams() ScryptParams { - return ScryptParams{ +func DefaultLabelParams() shared.ScryptParams { + return shared.ScryptParams{ N: 8192, R: 1, P: 1, diff --git a/initialization/initialization.go b/initialization/initialization.go index 1223dd580..b0ee281d2 100644 --- a/initialization/initialization.go +++ b/initialization/initialization.go @@ -172,8 +172,8 @@ type Initializer struct { // these values are atomics so they can be read from multiple other goroutines safely // write is protected by mtx - nonceValue atomic.Pointer[[]byte] nonce atomic.Pointer[uint64] + nonceValue atomic.Pointer[[]byte] lastPosition atomic.Pointer[uint64] numLabelsWritten atomic.Uint64 @@ -229,39 +229,10 @@ func NewInitializer(opts ...OptionFunc) (*Initializer, error) { return nil, err } init.nonce.Store(m.Nonce) + nonceValue := make([]byte, postrs.LabelLength) + copy(nonceValue, m.NonceValue) + init.nonceValue.Store(&nonceValue) init.lastPosition.Store(m.LastPosition) - - switch { - case m.NonceValue != nil: - // there is already a nonce value in the metadata - nonceValue := make([]byte, postrs.LabelLength) - copy(nonceValue, m.NonceValue) - init.nonceValue.Store(&nonceValue) - case m.Nonce != nil: - // there is a nonce in the metadata but no nonce value - cpuProviderID := CPUProviderID() - wo, err := oracle.New( - oracle.WithProviderID(&cpuProviderID), - oracle.WithCommitment(init.commitment), - oracle.WithVRFDifficulty(make([]byte, 32)), // we are not looking for it, so set difficulty to 0 - oracle.WithScryptParams(init.opts.Scrypt), - oracle.WithLogger(init.logger), - ) - if err != nil { - return nil, fmt.Errorf("failed to create work oracle: %w", err) - } - defer wo.Close() - - result, err := wo.Position(*m.Nonce) - if err != nil { - return nil, fmt.Errorf("failed to compute nonce value: %w", err) - } - nonceValue := make([]byte, postrs.LabelLength) - copy(nonceValue, result.Output) - init.nonceValue.Store(&nonceValue) - default: - // no nonce in the metadata - } } if err := init.saveMetadata(); err != nil { @@ -686,13 +657,18 @@ func (init *Initializer) verifyMetadata(m *shared.PostMetadata) error { func (init *Initializer) saveMetadata() error { v := shared.PostMetadata{ + Version: 1, + NodeId: init.nodeId, CommitmentAtxId: init.commitmentAtxId, - LabelsPerUnit: init.cfg.LabelsPerUnit, - NumUnits: init.opts.NumUnits, - MaxFileSize: init.opts.MaxFileSize, - Nonce: init.nonce.Load(), - LastPosition: init.lastPosition.Load(), + + LabelsPerUnit: init.cfg.LabelsPerUnit, + NumUnits: init.opts.NumUnits, + MaxFileSize: init.opts.MaxFileSize, + Scrypt: init.opts.Scrypt, + + Nonce: init.nonce.Load(), + LastPosition: init.lastPosition.Load(), } if init.nonceValue.Load() != nil { v.NonceValue = *init.nonceValue.Load() @@ -701,5 +677,8 @@ func (init *Initializer) saveMetadata() error { } func (init *Initializer) loadMetadata() (*shared.PostMetadata, error) { + if err := MigratePoST(init.opts.DataDir, init.logger); err != nil { + return nil, err + } return LoadMetadata(init.opts.DataDir) } diff --git a/initialization/initialization_test.go b/initialization/initialization_test.go index 9672f618e..29dd8717c 100644 --- a/initialization/initialization_test.go +++ b/initialization/initialization_test.go @@ -3,6 +3,7 @@ package initialization import ( "bytes" "context" + "encoding/json" "fmt" "io" "io/fs" @@ -76,8 +77,9 @@ func TestInitialize(t *testing.T) { require.NoError(t, verifying.VerifyVRFNonce(init.Nonce(), m, verifying.WithLabelScryptParams(opts.Scrypt))) } -func TestInitialize_BeforeNonceValue(t *testing.T) { +func TestInitialize_Migrate_Metadata(t *testing.T) { cfg, opts := getTestConfig(t) + opts.Scrypt.N = 8192 // use default scrypt params init, err := NewInitializer( WithNodeId(nodeId), @@ -99,10 +101,27 @@ func TestInitialize_BeforeNonceValue(t *testing.T) { require.NotNil(t, meta.Nonce) require.NotNil(t, meta.NonceValue) nonceValue := meta.NonceValue + nonce := meta.Nonce + + old := postMetadataV0{ + NodeId: nodeId, + CommitmentAtxId: commitmentAtxId, + LabelsPerUnit: meta.LabelsPerUnit, + NumUnits: meta.NumUnits, + MaxFileSize: meta.MaxFileSize, + Nonce: meta.Nonce, + NonceValue: meta.NonceValue, + LastPosition: meta.LastPosition, + } // delete nonce value - meta.NonceValue = nil - require.NoError(t, SaveMetadata(opts.DataDir, meta)) + old.NonceValue = nil + + // store in old metadata format + f, err := os.Create(filepath.Join(opts.DataDir, MetadataFileName)) + require.NoError(t, err) + require.NoError(t, json.NewEncoder(f).Encode(old)) + require.NoError(t, f.Close()) // just creating a new initializer should update the metadata init, err = NewInitializer( @@ -117,8 +136,10 @@ func TestInitialize_BeforeNonceValue(t *testing.T) { meta, err = LoadMetadata(opts.DataDir) require.NoError(t, err) + require.Equal(t, 1, meta.Version) require.NotNil(t, meta.Nonce) require.NotNil(t, meta.NonceValue) + require.Equal(t, *nonce, *meta.Nonce) require.Equal(t, nonceValue, meta.NonceValue) } @@ -1125,3 +1146,33 @@ func TestRemoveRedundantFiles(t *testing.T) { require.NoError(t, err) } } + +func Test_Initialize_Migrates_Metadata(t *testing.T) { + cfg := config.DefaultConfig() + + opts := config.DefaultInitOpts() + opts.DataDir = t.TempDir() + opts.NumUnits = 3 + opts.MaxFileSize = 2 * cfg.UnitSize() + + expectedFilesCount := opts.TotalFiles(cfg.LabelsPerUnit) + // Create 2 redundant files + for i := 0; i < expectedFilesCount+2; i++ { + f, err := os.Create(filepath.Join(opts.DataDir, shared.InitFileName(i))) + require.NoError(t, err) + _, err = f.Write([]byte("test")) + require.NoError(t, err) + require.NoError(t, f.Close()) + } + + removeRedundantFiles(cfg, opts, zap.NewNop()) + + files, err := os.ReadDir(opts.DataDir) + require.NoError(t, err) + require.Len(t, files, expectedFilesCount) + + for i := 0; i < expectedFilesCount; i++ { + _, err := os.Stat(filepath.Join(opts.DataDir, shared.InitFileName(i))) + require.NoError(t, err) + } +} diff --git a/initialization/migrate_metadata.go b/initialization/migrate_metadata.go new file mode 100644 index 000000000..ac80bd541 --- /dev/null +++ b/initialization/migrate_metadata.go @@ -0,0 +1,182 @@ +package initialization + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + + "github.com/natefinch/atomic" + "go.uber.org/zap" + + "github.com/spacemeshos/post/config" + "github.com/spacemeshos/post/oracle" + "github.com/spacemeshos/post/shared" +) + +var migrateData map[int]func(dir string, logger *zap.Logger) (err error) + +func init() { + migrateData = make(map[int]func(dir string, logger *zap.Logger) (err error)) + migrateData[0] = migrateV0 +} + +type MetadataVersion struct { + Version int `json:",omitempty"` +} + +// MigratePoST migrates the PoST metadata file to the latest version. +func MigratePoST(dir string, logger *zap.Logger) (err error) { + logger.Info("checking PoST for migrations") + + filename := filepath.Join(dir, MetadataFileName) + file, err := os.Open(filename) + switch { + case os.IsNotExist(err): + return ErrStateMetadataFileMissing + case err != nil: + return fmt.Errorf("could not open metadata file: %w", err) + } + defer file.Close() + + version := MetadataVersion{} + if err := json.NewDecoder(file).Decode(&version); err != nil { + return fmt.Errorf("failed to determine metadata version: %w", err) + } + if err := file.Close(); err != nil { + return fmt.Errorf("failed to close metadata file: %w", err) + } + + if version.Version == len(migrateData) { + logger.Info("PoST is up to date, no migration needed") + return nil + } + + if version.Version > len(migrateData) { + return fmt.Errorf( + "PoST metadata version %d is newer than the latest supported version %d", + version.Version, + len(migrateData), + ) + } + + logger.Info("determined PoST version", zap.Int("version", version.Version)) + + for v := version.Version; v < len(migrateData); v++ { + if err := migrateData[v](dir, logger); err != nil { + return fmt.Errorf("failed to migrate metadata from version %d to version %d: %w", v, v+1, err) + } + + logger.Info("migrated PoST successfully to version", zap.Int("version", v+1)) + } + + logger.Info("PoST migration process finished successfully") + return nil +} + +type postMetadataV0 struct { + NodeId []byte + CommitmentAtxId []byte + + LabelsPerUnit uint64 + NumUnits uint32 + MaxFileSize uint64 + Nonce *uint64 `json:",omitempty"` + NonceValue shared.NonceValue `json:",omitempty"` + LastPosition *uint64 `json:",omitempty"` +} + +// migrateV0 upgrades PoST from version 0 to version 1. +// +// - add version field to postdata_metadata.json (missing in version 0) +// - add NonceValue field to postdata_metadata.json if missing (was introduced before migrations, +// not every PoST version 0 metadata file has it) +// - re-encode NodeId and CommitmentAtxId as hex strings. +// - add Scrypt field to postdata_metadata.json (missing in version 0), assume default mainnet values. +func migrateV0(dir string, logger *zap.Logger) (err error) { + filename := filepath.Join(dir, MetadataFileName) + file, err := os.Open(filename) + switch { + case os.IsNotExist(err): + return ErrStateMetadataFileMissing + case err != nil: + return fmt.Errorf("could not read metadata file: %w", err) + } + defer file.Close() + + old := postMetadataV0{} + if err := json.NewDecoder(file).Decode(&old); err != nil { + return fmt.Errorf("failed to determine metadata version: %w", err) + } + + if err := file.Close(); err != nil { + return fmt.Errorf("failed to close metadata file: %w", err) + } + + if len(old.NodeId) != 32 { + return fmt.Errorf("invalid node ID length: %d", len(old.NodeId)) + } + + if len(old.CommitmentAtxId) != 32 { + return fmt.Errorf("invalid commitment ATX ID length: %d", len(old.CommitmentAtxId)) + } + + new := shared.PostMetadata{ + Version: 1, + + NodeId: old.NodeId, + CommitmentAtxId: old.CommitmentAtxId, + + LabelsPerUnit: old.LabelsPerUnit, + NumUnits: old.NumUnits, + MaxFileSize: old.MaxFileSize, + Scrypt: config.DefaultLabelParams(), // we don't know the scrypt params, so use mainnet defaults + + Nonce: old.Nonce, + NonceValue: old.NonceValue, + LastPosition: old.LastPosition, + } + + if new.Nonce != nil && new.NonceValue == nil { + // there is a nonce in the metadata but no nonce value + commitment := oracle.CommitmentBytes(new.NodeId, new.CommitmentAtxId) + cpuProviderID := CPUProviderID() + + wo, err := oracle.New( + oracle.WithProviderID(&cpuProviderID), + oracle.WithCommitment(commitment), + oracle.WithVRFDifficulty(make([]byte, 32)), // we are not looking for it, so set difficulty to 0 + oracle.WithScryptParams(new.Scrypt), + oracle.WithLogger(logger), + ) + if err != nil { + return fmt.Errorf("failed to create oracle: %w", err) + } + + result, err := wo.Position(*new.Nonce) + if err != nil { + return fmt.Errorf("failed to compute nonce value: %w", err) + } + new.NonceValue = result.Output + } + + tmp, err := os.Create(fmt.Sprintf("%s.tmp", filename)) + if err != nil { + return fmt.Errorf("create temporary file: %w", err) + } + defer tmp.Close() + + if err := json.NewEncoder(tmp).Encode(new); err != nil { + return fmt.Errorf("failed to encode metadata during migration: %w", err) + } + + if err := tmp.Close(); err != nil { + return fmt.Errorf("failed to close tmp file %s: %w", tmp.Name(), err) + } + + if err := atomic.ReplaceFile(tmp.Name(), filename); err != nil { + return fmt.Errorf("atomic replace: %w", err) + } + + return nil +} diff --git a/initialization/migrate_metadata_test.go b/initialization/migrate_metadata_test.go new file mode 100644 index 000000000..c5441ed79 --- /dev/null +++ b/initialization/migrate_metadata_test.go @@ -0,0 +1,143 @@ +package initialization + +import ( + "crypto/rand" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + "go.uber.org/zap/zaptest" +) + +func Fuzz_MigrateMetadata(f *testing.F) { + nodeId := make([]byte, 32) + rand.Read(nodeId) + + commitmentAtxId := make([]byte, 32) + rand.Read(commitmentAtxId) + + f.Add( + make([]byte, 32), + make([]byte, 32), + []byte{1}, + uint64(67), + uint64(1024), + uint64(1024*1024), + uint64(1024), + uint32(4), + ) + f.Add( + nodeId, + commitmentAtxId, + []byte{1, 23}, + uint64(128), + uint64(1024*1024), + uint64(1024*1024*1024), + uint64(2389712), + uint32(16), + ) + + f.Fuzz(func( + t *testing.T, + nodeId, + commitmentAtxId, + nonceValue []byte, + nonce, + labelsPerUnit, + maxFileSize, + lastPosition uint64, + numUnits uint32, + ) { + if len(nodeId) != 32 || len(commitmentAtxId) != 32 { + return + } + + path := t.TempDir() + + f, err := os.Create(filepath.Join(path, MetadataFileName)) + require.NoError(t, err) + defer f.Close() + + old := postMetadataV0{ + NodeId: nodeId, + CommitmentAtxId: commitmentAtxId, + LabelsPerUnit: labelsPerUnit, + NumUnits: numUnits, + MaxFileSize: maxFileSize, + Nonce: &nonce, + NonceValue: nonceValue, + LastPosition: &lastPosition, + } + + if len(nonceValue) == 0 { + old.NonceValue = nil + old.Nonce = nil + } + + require.NoError(t, json.NewEncoder(f).Encode(old)) + require.NoError(t, f.Close()) + + log := zaptest.NewLogger(t) + require.NoError(t, MigratePoST(path, log)) + + metadata, err := LoadMetadata(path) + require.NoError(t, err) + + require.Equal(t, 1, metadata.Version) + + require.Equal(t, nodeId, metadata.NodeId) + require.Equal(t, commitmentAtxId, metadata.CommitmentAtxId) + require.Equal(t, labelsPerUnit, metadata.LabelsPerUnit) + require.Equal(t, numUnits, metadata.NumUnits) + require.Equal(t, maxFileSize, metadata.MaxFileSize) + if old.NonceValue == nil { + require.Nil(t, metadata.NonceValue) + require.Nil(t, metadata.Nonce) + } else { + require.Equal(t, old.NonceValue, metadata.NonceValue) + require.Equal(t, *old.Nonce, *metadata.Nonce) + } + require.Equal(t, lastPosition, *metadata.LastPosition) + + require.NotNil(t, metadata.Scrypt) + }) +} + +func Test_Migrate_MissingMetadataFile(t *testing.T) { + path := t.TempDir() + log := zaptest.NewLogger(t) + require.ErrorIs(t, MigratePoST(path, log), ErrStateMetadataFileMissing) +} + +func Test_Migrate_Adds_NonceValue(t *testing.T) { + nonce := uint64(10) + old := postMetadataV0{ + NodeId: make([]byte, 32), + CommitmentAtxId: make([]byte, 32), + LabelsPerUnit: 1024, + NumUnits: 4, + MaxFileSize: 1024 * 1014, + Nonce: &nonce, + NonceValue: nil, + LastPosition: nil, + } + + path := t.TempDir() + f, err := os.Create(filepath.Join(path, MetadataFileName)) + require.NoError(t, err) + require.NoError(t, json.NewEncoder(f).Encode(old)) + require.NoError(t, f.Close()) + + log := zaptest.NewLogger(t) + require.NoError(t, MigratePoST(path, log)) + + metadata, err := LoadMetadata(path) + require.NoError(t, err) + + require.Equal(t, 1, metadata.Version) + + require.NotNil(t, metadata.NonceValue) + require.NotNil(t, metadata.Nonce) +} diff --git a/initialization/vrf_search_test.go b/initialization/vrf_search_test.go index a2f61ad5a..ac2f99936 100644 --- a/initialization/vrf_search_test.go +++ b/initialization/vrf_search_test.go @@ -9,9 +9,9 @@ import ( "go.uber.org/zap" "go.uber.org/zap/zaptest" - "github.com/spacemeshos/post/config" "github.com/spacemeshos/post/internal/postrs" "github.com/spacemeshos/post/oracle" + "github.com/spacemeshos/post/shared" ) func TestCheckLabel(t *testing.T) { @@ -20,7 +20,7 @@ func TestCheckLabel(t *testing.T) { oracle.WithProviderID(&cpuProviderID), oracle.WithCommitment(make([]byte, 32)), oracle.WithVRFDifficulty(make([]byte, 32)), - oracle.WithScryptParams(config.ScryptParams{ + oracle.WithScryptParams(shared.ScryptParams{ N: 2, R: 1, P: 1, diff --git a/oracle/oracle.go b/oracle/oracle.go index a1e066e8c..86bc0bb2f 100644 --- a/oracle/oracle.go +++ b/oracle/oracle.go @@ -8,8 +8,8 @@ import ( "go.uber.org/zap" - "github.com/spacemeshos/post/config" "github.com/spacemeshos/post/internal/postrs" + "github.com/spacemeshos/post/shared" ) // ErrWorkOracleClosed is returned when calling a method on an already closed WorkOracle instance. @@ -85,7 +85,7 @@ func WithVRFDifficulty(difficulty []byte) OptionFunc { // WithScryptParams sets the parameters for the scrypt algorithm. // At the moment only configuring N is supported. r and p are fixed at 1 (due to limitations in the OpenCL // implementation). -func WithScryptParams(params config.ScryptParams) OptionFunc { +func WithScryptParams(params shared.ScryptParams) OptionFunc { return func(opts *option) error { if params.P != 1 || params.R != 1 { return errors.New("invalid scrypt params: only r = 1, p = 1 are supported for initialization") diff --git a/scripts/fuzz.sh b/scripts/fuzz.sh new file mode 100755 index 000000000..54e9aceeb --- /dev/null +++ b/scripts/fuzz.sh @@ -0,0 +1,21 @@ +#!/bin/bash + +# https://github.com/golang/go/issues/46312 + +set -e + +fuzzTime=${1:-"10s"} + +files=$(grep -r --include='**_test.go' --files-with-matches 'func Fuzz' .) + +for file in ${files} +do + funcs=$(grep -oP 'func \K(Fuzz\w*)' $file) + for func in ${funcs} + do + parentDir=$(dirname $file) + command="go test $parentDir -run=$func -fuzz=^$func\$ -fuzztime=${fuzzTime}" + echo $command + eval $command + done +done diff --git a/shared/post_metadata.go b/shared/post_metadata.go index 1b388380e..4da18ac86 100644 --- a/shared/post_metadata.go +++ b/shared/post_metadata.go @@ -3,19 +3,44 @@ package shared import ( "encoding/hex" "encoding/json" + "errors" ) +// ErrStateMetadataFileMissing is returned when the metadata file is missing. +var ErrStateMetadataFileMissing = errors.New("metadata file is missing") + // PostMetadata is the data associated with the PoST init procedure, persisted in the datadir next to the init files. type PostMetadata struct { + Version int `json:",omitempty"` + NodeId []byte CommitmentAtxId []byte LabelsPerUnit uint64 NumUnits uint32 MaxFileSize uint64 - Nonce *uint64 `json:",omitempty"` - NonceValue NonceValue `json:",omitempty"` - LastPosition *uint64 `json:",omitempty"` + Scrypt ScryptParams + + Nonce *uint64 `json:",omitempty"` + NonceValue NonceValue `json:",omitempty"` + LastPosition *uint64 `json:",omitempty"` +} + +type ScryptParams struct { + N, R, P uint +} + +func (p *ScryptParams) Validate() error { + if p.N == 0 { + return errors.New("scrypt parameter N cannot be 0") + } + if p.R == 0 { + return errors.New("scrypt parameter r cannot be 0") + } + if p.P == 0 { + return errors.New("scrypt parameter p cannot be 0") + } + return nil } type NonceValue []byte @@ -32,3 +57,35 @@ func (n *NonceValue) UnmarshalJSON(data []byte) (err error) { *n, err = hex.DecodeString(hexString) return } + +// TODO(mafa): for version 2 of metadata use NodeID as type for NodeID. +type NodeID []byte + +func (n NodeID) MarshalJSON() ([]byte, error) { + return json.Marshal(hex.EncodeToString(n)) +} + +func (n *NodeID) UnmarshalJSON(data []byte) (err error) { + var hexString string + if err = json.Unmarshal(data, &hexString); err != nil { + return + } + *n, err = hex.DecodeString(hexString) + return +} + +// TODO(mafa): for version 2 of metadata use ATXID as type for CommitmentAtxID. +type ATXID []byte + +func (a ATXID) MarshalJSON() ([]byte, error) { + return json.Marshal(hex.EncodeToString(a[:])) +} + +func (a *ATXID) UnmarshalJSON(data []byte) (err error) { + var hexString string + if err = json.Unmarshal(data, &hexString); err != nil { + return + } + *a, err = hex.DecodeString(hexString) + return +} diff --git a/verifying/verifying_options.go b/verifying/verifying_options.go index e341ab9a2..e2de9bab3 100644 --- a/verifying/verifying_options.go +++ b/verifying/verifying_options.go @@ -3,12 +3,13 @@ package verifying import ( "github.com/spacemeshos/post/config" "github.com/spacemeshos/post/internal/postrs" + "github.com/spacemeshos/post/shared" ) type option struct { powFlags config.PowFlags // scrypt parameters for labels initialization - labelScrypt config.ScryptParams + labelScrypt shared.ScryptParams internalOpts []postrs.VerifyOptionFunc } @@ -26,7 +27,7 @@ func applyOpts(options ...OptionFunc) *option { type OptionFunc func(*option) -func WithLabelScryptParams(params config.ScryptParams) OptionFunc { +func WithLabelScryptParams(params shared.ScryptParams) OptionFunc { return func(o *option) { o.labelScrypt = params }