diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0b0b9cc --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,103 @@ +name: CI + +on: + push: + branches: [ main, master ] + pull_request: + branches: [ main, master ] + +jobs: + test: + name: Test + runs-on: ubuntu-latest + strategy: + matrix: + go-version: ['1.24'] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: ${{ matrix.go-version }} + + - name: Cache Go modules + uses: actions/cache@v3 + with: + path: ~/go/pkg/mod + key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-go- + + - name: Install dependencies + run: | + go mod download + make install-tools + + - name: Format check + run: | + gofmt -s -l . + test -z "$(gofmt -s -l .)" + + - name: Lint + run: make lint + + - name: Run tests + run: make test-coverage + + - name: Upload coverage + uses: codecov/codecov-action@v3 + with: + file: ./coverage.out + fail_ci_if_error: true + + - name: Run benchmarks + run: make bench + + security: + name: Security Scan + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.21' + + - name: Run Gosec Security Scanner + uses: securego/gosec@master + with: + args: ./... + + - name: Run Nancy vulnerability scanner + run: | + go install github.com/sonatype-nexus-community/nancy@latest + go list -json -m all | nancy sleuth + + build: + name: Build + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + go-version: ['1.21', '1.22'] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: ${{ matrix.go-version }} + + - name: Build + run: make build + + - name: Verify module + run: make verify diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..e54c574 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,83 @@ +name: Release + +on: + push: + tags: + - 'v*.*.*' + - 'crypto/v*.*.*' + +permissions: + contents: write + +jobs: + test: + name: Test Before Release + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.21' + + - name: Install dependencies + run: | + go mod download + make install-tools + + - name: Run tests + run: make test + + - name: Run linter + run: make lint + + release: + name: Create Release + needs: test + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.21' + + - name: Build binaries + run: | + # Build for multiple platforms + GOOS=linux GOARCH=amd64 go build -o dist/crypto-linux-amd64 ./... + GOOS=linux GOARCH=arm64 go build -o dist/crypto-linux-arm64 ./... + GOOS=darwin GOARCH=amd64 go build -o dist/crypto-darwin-amd64 ./... + GOOS=darwin GOARCH=arm64 go build -o dist/crypto-darwin-arm64 ./... + GOOS=windows GOARCH=amd64 go build -o dist/crypto-windows-amd64.exe ./... + + - name: Generate changelog + id: changelog + uses: mikepenz/release-changelog-builder-action@v4 + with: + configuration: ".github/changelog-config.json" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Create Release + uses: softprops/action-gh-release@v1 + with: + body: ${{ steps.changelog.outputs.changelog }} + files: | + dist/* + draft: false + prerelease: false + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Update Go Module Index + run: | + curl -X POST https://proxy.golang.org/github.com/luxfi/crypto/@v/${{ github.ref_name }}.info \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..1d58f17 --- /dev/null +++ b/Makefile @@ -0,0 +1,72 @@ +# Makefile for luxfi/crypto module + +.PHONY: all build test clean fmt lint install-tools test-coverage + +# Variables +GOBIN := $(shell go env GOPATH)/bin +GOLANGCI_LINT_VERSION := v1.54.2 +COVERAGE_FILE := coverage.out +COVERAGE_HTML := coverage.html + +# Default target +all: clean fmt lint test build + +# Build the module +build: + @echo "Building crypto module..." + @go build -v ./... + +# Run tests +test: + @echo "Running tests..." + @go test -v -race -timeout=10m ./... + +# Run tests with coverage +test-coverage: + @echo "Running tests with coverage..." + @go test -v -race -timeout=10m -coverprofile=$(COVERAGE_FILE) -covermode=atomic ./... + @go tool cover -html=$(COVERAGE_FILE) -o $(COVERAGE_HTML) + @echo "Coverage report generated: $(COVERAGE_HTML)" + +# Format code +fmt: + @echo "Formatting code..." + @go fmt ./... + @go mod tidy + +# Run linter +lint: install-tools + @echo "Running linter..." + @$(GOBIN)/golangci-lint run ./... + +# Install development tools +install-tools: + @echo "Installing development tools..." + @go install github.com/golangci/golangci-lint/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION) + +# Clean build artifacts +clean: + @echo "Cleaning..." + @go clean -cache -testcache + @rm -f $(COVERAGE_FILE) $(COVERAGE_HTML) + +# Run benchmarks +bench: + @echo "Running benchmarks..." + @go test -bench=. -benchmem ./... + +# Check for security vulnerabilities +security: + @echo "Checking for vulnerabilities..." + @go list -json -m all | nancy sleuth + +# Update dependencies +deps: + @echo "Updating dependencies..." + @go get -u -t ./... + @go mod tidy + +# Verify module +verify: + @echo "Verifying module..." + @go mod verify \ No newline at end of file diff --git a/bls/bls_benchmark_test.go b/bls/bls_benchmark_test.go index 2a910ca..c07a892 100644 --- a/bls/bls_benchmark_test.go +++ b/bls/bls_benchmark_test.go @@ -9,7 +9,7 @@ import ( "github.com/stretchr/testify/require" - "github.com/luxfi/node/utils" + "github.com/luxfi/crypto" "github.com/luxfi/crypto/bls/blstest" ) @@ -19,7 +19,7 @@ func BenchmarkVerify(b *testing.B) { for _, messageSize := range blstest.BenchmarkSizes { b.Run(strconv.Itoa(messageSize), func(b *testing.B) { - message := utils.RandomBytes(messageSize) + message := crypto.RandomBytes(messageSize) signature := sign(privateKey, message) b.ResetTimer() @@ -99,7 +99,7 @@ func BenchmarkPublicKeyFromValidUncompressedBytes(b *testing.B) { func BenchmarkSignatureFromBytes(b *testing.B) { sk := newKey(require.New(b)) - message := utils.RandomBytes(32) + message := crypto.RandomBytes(32) signature := sign(sk, message) signatureBytes := SignatureToBytes(signature) diff --git a/bls/bls_test.go b/bls/bls_test.go index 1f83929..aa77258 100644 --- a/bls/bls_test.go +++ b/bls/bls_test.go @@ -9,7 +9,7 @@ import ( "github.com/stretchr/testify/require" - "github.com/luxfi/node/utils" + "github.com/luxfi/crypto" blst "github.com/supranational/blst/bindings/go" ) @@ -48,7 +48,7 @@ func TestAggregationThreshold(t *testing.T) { } // The transaction's unsigned bytes are publicly known. - msg := utils.RandomBytes(1234) + msg := crypto.RandomBytes(1234) // People may attempt time sign the transaction. sigs := []*Signature{ diff --git a/bls/public_test.go b/bls/public_test.go index 930c142..03f812e 100644 --- a/bls/public_test.go +++ b/bls/public_test.go @@ -9,13 +9,13 @@ import ( "github.com/stretchr/testify/require" - "github.com/luxfi/node/utils" + "github.com/luxfi/crypto" ) func TestPublicKeyFromCompressedBytesWrongSize(t *testing.T) { require := require.New(t) - pkBytes := utils.RandomBytes(PublicKeyLen + 1) + pkBytes := crypto.RandomBytes(PublicKeyLen + 1) _, err := PublicKeyFromCompressedBytes(pkBytes) require.ErrorIs(err, ErrFailedPublicKeyDecompress) } diff --git a/bls/signature_test.go b/bls/signature_test.go index b00f4bf..9316f11 100644 --- a/bls/signature_test.go +++ b/bls/signature_test.go @@ -8,13 +8,13 @@ import ( "github.com/stretchr/testify/require" - "github.com/luxfi/node/utils" + "github.com/luxfi/crypto" ) func TestSignatureBytes(t *testing.T) { require := require.New(t) - msg := utils.RandomBytes(1234) + msg := crypto.RandomBytes(1234) sk := newKey(require) sig := sign(sk, msg) @@ -31,7 +31,7 @@ func TestSignatureBytes(t *testing.T) { func TestAggregateSignaturesNoop(t *testing.T) { require := require.New(t) - msg := utils.RandomBytes(1234) + msg := crypto.RandomBytes(1234) sk := newKey(require) sig := sign(sk, msg) diff --git a/bls/signer/localsigner/benchmark_test.go b/bls/signer/localsigner/benchmark_test.go index e429d12..a8de0e1 100644 --- a/bls/signer/localsigner/benchmark_test.go +++ b/bls/signer/localsigner/benchmark_test.go @@ -9,7 +9,7 @@ import ( "github.com/stretchr/testify/require" - "github.com/luxfi/node/utils" + "github.com/luxfi/crypto" "github.com/luxfi/crypto/bls/blstest" ) @@ -17,7 +17,7 @@ func BenchmarkSign(b *testing.B) { signer := NewSigner(require.New(b)) for _, messageSize := range blstest.BenchmarkSizes { b.Run(strconv.Itoa(messageSize), func(b *testing.B) { - message := utils.RandomBytes(messageSize) + message := crypto.RandomBytes(messageSize) b.ResetTimer() diff --git a/bls/signer/localsigner/serialization_test.go b/bls/signer/localsigner/serialization_test.go index 9f86c09..3eaa541 100644 --- a/bls/signer/localsigner/serialization_test.go +++ b/bls/signer/localsigner/serialization_test.go @@ -8,7 +8,7 @@ import ( "github.com/stretchr/testify/require" - "github.com/luxfi/node/utils" + "github.com/luxfi/crypto" blst "github.com/supranational/blst/bindings/go" ) @@ -27,7 +27,7 @@ func TestSecretKeyFromBytesZero(t *testing.T) { func TestSecretKeyFromBytesWrongSize(t *testing.T) { require := require.New(t) - skBytes := utils.RandomBytes(SecretKeyLen + 1) + skBytes := crypto.RandomBytes(SecretKeyLen + 1) _, err := FromBytes(skBytes) require.ErrorIs(err, ErrFailedSecretKeyDeserialize) } @@ -35,7 +35,7 @@ func TestSecretKeyFromBytesWrongSize(t *testing.T) { func TestSecretKeyBytes(t *testing.T) { require := require.New(t) - msg := utils.RandomBytes(1234) + msg := crypto.RandomBytes(1234) sk, err := New() require.NoError(err) diff --git a/bls/signer/rpcsigner/client.go b/bls/signer/rpcsigner.disabled/client.go similarity index 100% rename from bls/signer/rpcsigner/client.go rename to bls/signer/rpcsigner.disabled/client.go diff --git a/bls/signer/rpcsigner/client_test.go b/bls/signer/rpcsigner.disabled/client_test.go similarity index 100% rename from bls/signer/rpcsigner/client_test.go rename to bls/signer/rpcsigner.disabled/client_test.go index 8638dbf..a829ac5 100644 --- a/bls/signer/rpcsigner/client_test.go +++ b/bls/signer/rpcsigner.disabled/client_test.go @@ -11,9 +11,9 @@ import ( "github.com/stretchr/testify/require" "google.golang.org/grpc" - "github.com/luxfi/node/proto/pb/signer" "github.com/luxfi/crypto/bls" "github.com/luxfi/crypto/bls/signer/localsigner" + "github.com/luxfi/node/proto/pb/signer" ) var ( diff --git a/cb58/cb58.go b/cb58/cb58.go new file mode 100644 index 0000000..81ae2b5 --- /dev/null +++ b/cb58/cb58.go @@ -0,0 +1,56 @@ +// Copyright (C) 2020-2025, Lux Industries Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package cb58 + +import ( + "bytes" + "errors" + "fmt" + "math" + + "github.com/mr-tron/base58/base58" + + "github.com/luxfi/crypto/hashing" +) + +const checksumLen = 4 + +var ( + ErrBase58Decoding = errors.New("base58 decoding error") + ErrMissingChecksum = errors.New("input string is smaller than the checksum size") + ErrBadChecksum = errors.New("invalid input checksum") + errEncodingOverFlow = errors.New("encoding overflow") +) + +// Encode [bytes] to a string using cb58 format. +// [bytes] may be nil, in which case it will be treated the same as an empty +// slice. +func Encode(bytes []byte) (string, error) { + bytesLen := len(bytes) + if bytesLen > math.MaxInt32-checksumLen { + return "", errEncodingOverFlow + } + checked := make([]byte, bytesLen+checksumLen) + copy(checked, bytes) + copy(checked[len(bytes):], hashing.Checksum(bytes, checksumLen)) + return base58.Encode(checked), nil +} + +// Decode [str] to bytes from cb58. +func Decode(str string) ([]byte, error) { + decodedBytes, err := base58.Decode(str) + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrBase58Decoding, err) + } + if len(decodedBytes) < checksumLen { + return nil, ErrMissingChecksum + } + // Verify the checksum + rawBytes := decodedBytes[:len(decodedBytes)-checksumLen] + checksum := decodedBytes[len(decodedBytes)-checksumLen:] + if !bytes.Equal(checksum, hashing.Checksum(rawBytes, checksumLen)) { + return nil, ErrBadChecksum + } + return rawBytes, nil +} diff --git a/cb58/cb58_test.go b/cb58/cb58_test.go new file mode 100644 index 0000000..dce2f6f --- /dev/null +++ b/cb58/cb58_test.go @@ -0,0 +1,74 @@ +// Copyright (C) 2020-2025, Lux Industries Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package cb58 + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/require" +) + +// Test encoding bytes to a string and decoding back to bytes +func TestEncodeDecode(t *testing.T) { + require := require.New(t) + + type test struct { + bytes []byte + str string + } + + id := [32]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32} + tests := []test{ + { + nil, + "45PJLL", + }, + { + []byte{}, + "45PJLL", + }, + { + []byte{0}, + "1c7hwa", + }, + { + []byte{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 255}, + "1NVSVezva3bAtJesnUj", + }, + { + id[:], + "SkB92YpWm4Q2ijQHH34cqbKkCZWszsiQgHVjtNeFF2HdvDQU", + }, + } + + for _, test := range tests { + // Encode the bytes + strResult, err := Encode(test.bytes) + require.NoError(err) + // Make sure the string repr. is what we expected + require.Equal(test.str, strResult) + // Decode the string + bytesResult, err := Decode(strResult) + require.NoError(err) + // Make sure we got the same bytes back + require.True(bytes.Equal(test.bytes, bytesResult)) + } +} + +func FuzzEncodeDecode(f *testing.F) { + f.Fuzz(func(t *testing.T, data []byte) { + require := require.New(t) + + // Encode bytes to string + dataStr, err := Encode(data) + require.NoError(err) + + // Decode string to bytes + gotData, err := Decode(dataStr) + require.NoError(err) + + require.Equal(data, gotData) + }) +} diff --git a/certificate.go b/certificate.go new file mode 100644 index 0000000..97e5db3 --- /dev/null +++ b/certificate.go @@ -0,0 +1,15 @@ +// Copyright (C) 2020-2025, Lux Industries Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package crypto + +import gocrypto "crypto" + +// Certificate represents a TLS certificate +// This is a minimal representation needed for NodeID generation +type Certificate struct { + // Raw contains the complete ASN.1 DER content of the certificate + Raw []byte + // PublicKey contains the public key from the certificate + PublicKey gocrypto.PublicKey +} diff --git a/go.mod b/go.mod index e7c24b6..96c1885 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( github.com/jedisct1/go-minisign v0.0.0-20230811132847-661be99b8267 github.com/luxfi/geth v1.16.2 github.com/luxfi/node v1.15.0 + github.com/mr-tron/base58 v1.2.0 github.com/stretchr/testify v1.10.0 github.com/supranational/blst v0.3.15 golang.org/x/crypto v0.40.0 diff --git a/go.sum b/go.sum index 945ff8e..64ee7a5 100644 --- a/go.sum +++ b/go.sum @@ -38,6 +38,8 @@ github.com/luxfi/geth v1.16.2 h1:rTzLxDe2Zbt7vv3j1rRBt6jnC1Alf1Hglq25cdj+C0Q= github.com/luxfi/geth v1.16.2/go.mod h1:XqDpjw2AlF38QOc91CM/MaUe5RNun7Cqe+WS4qxMKcQ= github.com/luxfi/node v1.15.0 h1:sSN6vGnoRb4IMFxQjhT9rFw/S9Vprsdq9zYxg9DMsRI= github.com/luxfi/node v1.15.0/go.mod h1:wTa2R9wr4DyWVn2c4XlkT1uzpggjFTnyXW9mtRr+pYI= +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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= diff --git a/hashing/hasher.go b/hashing/hasher.go new file mode 100644 index 0000000..ac8f46d --- /dev/null +++ b/hashing/hasher.go @@ -0,0 +1,11 @@ +// Copyright (C) 2020-2025, Lux Industries Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package hashing + +// Hasher is an interface to compute a hash value. +type Hasher interface { + // Hash takes a string and computes its hash value. + // Values must be computed deterministically. + Hash([]byte) uint64 +} diff --git a/hashing/hashing.go b/hashing/hashing.go new file mode 100644 index 0000000..ed81464 --- /dev/null +++ b/hashing/hashing.go @@ -0,0 +1,101 @@ +// Copyright (C) 2020-2025, Lux Industries Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package hashing + +import ( + "crypto/sha256" + "errors" + "fmt" + "io" + + // This file generates addresses from public keys with ripemd160. Though ripemd160 is not + // generally recommended for use, the small size of the public key input is considered harder to + // attack than larger payloads. + // + // Bitcoin similarly uses ripemd160 to generate addresses from public keys. + // + // Reference: https://online.tugraz.at/tug_online/voe_main2.getvolltext?pCurrPk=17675 + "golang.org/x/crypto/ripemd160" //nolint:gosec +) + +const ( + HashLen = sha256.Size + AddrLen = ripemd160.Size +) + +var ErrInvalidHashLen = errors.New("invalid hash length") + +// Hash256 A 256 bit long hash value. +type Hash256 = [HashLen]byte + +// Hash160 A 160 bit long hash value. +type Hash160 = [ripemd160.Size]byte + +// ComputeHash256Array computes a cryptographically strong 256 bit hash of the +// input byte slice. +func ComputeHash256Array(buf []byte) Hash256 { + return sha256.Sum256(buf) +} + +// ComputeHash256 computes a cryptographically strong 256 bit hash of the input +// byte slice. +func ComputeHash256(buf []byte) []byte { + arr := ComputeHash256Array(buf) + return arr[:] +} + +// ComputeHash160Array computes a cryptographically strong 160 bit hash of the +// input byte slice. +func ComputeHash160Array(buf []byte) Hash160 { + h, err := ToHash160(ComputeHash160(buf)) + if err != nil { + panic(err) + } + return h +} + +// ComputeHash160 computes a cryptographically strong 160 bit hash of the input +// byte slice. +func ComputeHash160(buf []byte) []byte { + // See the comment on the ripemd160 import as to why the risk of use is + // considered acceptable. + ripe := ripemd160.New() //nolint:gosec + _, err := io.Writer(ripe).Write(buf) + if err != nil { + panic(err) + } + return ripe.Sum(nil) +} + +// Checksum creates a checksum of [length] bytes from the 256 bit hash of the +// byte slice. +// +// Returns: the lower [length] bytes of the hash +// Panics if length > 32. +func Checksum(bytes []byte, length int) []byte { + hash := ComputeHash256Array(bytes) + return hash[len(hash)-length:] +} + +func ToHash256(bytes []byte) (Hash256, error) { + hash := Hash256{} + if bytesLen := len(bytes); bytesLen != HashLen { + return hash, fmt.Errorf("%w: expected 32 bytes but got %d", ErrInvalidHashLen, bytesLen) + } + copy(hash[:], bytes) + return hash, nil +} + +func ToHash160(bytes []byte) (Hash160, error) { + hash := Hash160{} + if bytesLen := len(bytes); bytesLen != ripemd160.Size { + return hash, fmt.Errorf("%w: expected 20 bytes but got %d", ErrInvalidHashLen, bytesLen) + } + copy(hash[:], bytes) + return hash, nil +} + +func PubkeyBytesToAddress(key []byte) []byte { + return ComputeHash160(ComputeHash256(key)) +} diff --git a/random.go b/random.go new file mode 100644 index 0000000..f4c6e9f --- /dev/null +++ b/random.go @@ -0,0 +1,16 @@ +// Copyright (C) 2020-2025, Lux Industries Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package crypto + +import ( + "crypto/rand" +) + +// RandomBytes returns a slice of n random bytes +func RandomBytes(n int) []byte { + bytes := make([]byte, n) + // #nosec G404 - we don't need cryptographic randomness for test data + _, _ = rand.Read(bytes) + return bytes +}