What changed, and why it matters
This commit adds new strict hash-parsing functions to btcd's chainhash package. The existing lenient functions accept shortened or odd-length hex strings and silently pad them with zeros, which can be risky when parsing transaction or block IDs. The new strict helpers require an exact 64-character hex string. The commit does not switch any callers to the strict versions, so it is a defensive API addition rather than an immediate fix for a known vulnerability.
Review all call sites of NewHashFromStr and Decode throughout btcd and dependent projects to determine which should be migrated to the strict variants, especially those handling user-supplied txids or block hashes. Treat this commit as a preparatory hardening change and follow up with caller migration patches.
Security signals we found
Lenient hash parsing can silently accept truncated or odd-length transaction/block IDs, potentially enabling identifier confusion or collision-style attacks.
New strict API is added but no existing callers are migrated, so the security benefit is latent until downstream code adopts it.
Commit message frames the change as adding strict parsing for callers that must parse full txids or block hashes exactly.
No CVE, advisory, or vendor security disclosure is present in the supplied materials.
Evidence from the diff
The patch introduces NewHashFromStrStrict and DecodeStrict alongside the existing NewHashFromStr and Decode in chaincfg/chainhash/hash.go. The strict variants return ErrHashStrSizeMismatch unless the input is exactly MaxHashStringSize (64) hex characters. The original lenient functions are preserved for backward compatibility and now carry NOTE comments recommending strict parsing for full txids/block hashes. A shared decodeHash helper is extracted to avoid code duplication. Tests and a benchmark are added for the strict behavior.
Changed components
chaincfg/chainhash/hash.gochaincfg/chainhash/hash_test.goInspect captured patch +196 / −2
diff --git a/chaincfg/chainhash/hash.go b/chaincfg/chainhash/hash.go
index 4aa7aeb..5cac411 100644
--- a/chaincfg/chainhash/hash.go
+++ b/chaincfg/chainhash/hash.go
@@ -62,6 +62,12 @@ var (
// string that has too many characters.
var ErrHashStrSize = fmt.Errorf("max hash string length is %v bytes", MaxHashStringSize)
+// ErrHashStrSizeMismatch describes an error that indicates the caller
+// specified a hash string that does not meet the exact length required for
+// strict parsing.
+var ErrHashStrSizeMismatch = fmt.Errorf("hash string must be exactly %d "+
+ "characters", MaxHashStringSize)
+
// Hash is used in several of the bitcoin messages and common structures. It
// typically represents the double sha256 of data.
type Hash [HashSize]byte
@@ -180,6 +186,10 @@ func TaggedHash(tag []byte, msgs ...[]byte) *Hash {
// NewHashFromStr creates a Hash from a hash string. The string should be
// the hexadecimal string of a byte-reversed hash, but any missing characters
// result in zero padding at the end of the Hash.
+//
+// NOTE: This function accepts short and odd-length hex strings and pads them.
+// Typical parsing of full txids or block hashes should use NewHashFromStrStrict
+// instead.
func NewHashFromStr(hash string) (*Hash, error) {
ret := new(Hash)
err := Decode(ret, hash)
@@ -189,8 +199,23 @@ func NewHashFromStr(hash string) (*Hash, error) {
return ret, nil
}
+// NewHashFromStrStrict creates a Hash from a hash string. The string must be
+// the full hexadecimal string of a byte-reversed hash.
+func NewHashFromStrStrict(hash string) (*Hash, error) {
+ ret := new(Hash)
+ err := DecodeStrict(ret, hash)
+ if err != nil {
+ return nil, err
+ }
+ return ret, nil
+}
+
// Decode decodes the byte-reversed hexadecimal string encoding of a Hash to a
// destination.
+//
+// NOTE: This function accepts short and odd-length hex strings and pads them.
+// Typical parsing of full txids or block hashes should use DecodeStrict
+// instead.
func Decode(dst *Hash, src string) error {
// Return error if hash string is too long.
if len(src) > MaxHashStringSize {
@@ -208,9 +233,27 @@ func Decode(dst *Hash, src string) error {
copy(srcBytes[1:], src)
}
+ return decodeHash(dst, srcBytes)
+}
+
+// DecodeStrict decodes the byte-reversed hexadecimal string encoding of a Hash
+// to a destination. The source string must be exactly MaxHashStringSize
+// bytes, or ErrHashStrSizeMismatch is returned.
+func DecodeStrict(dst *Hash, src string) error {
+ if len(src) != MaxHashStringSize {
+ return ErrHashStrSizeMismatch
+ }
+
+ return decodeHash(dst, []byte(src))
+}
+
+// decodeHash decodes the provided byte-reversed hexadecimal bytes into dst.
+// The caller is responsible for applying any caller-specific length validation
+// before invoking this helper.
+func decodeHash(dst *Hash, src []byte) error {
// Hex decode the source bytes to a temporary destination.
var reversedHash Hash
- _, err := hex.Decode(reversedHash[HashSize-hex.DecodedLen(len(srcBytes)):], srcBytes)
+ _, err := hex.Decode(reversedHash[HashSize-hex.DecodedLen(len(src)):], src)
if err != nil {
return err
}
diff --git a/chaincfg/chainhash/hash_test.go b/chaincfg/chainhash/hash_test.go
index 85738a6..f0dc207 100644
--- a/chaincfg/chainhash/hash_test.go
+++ b/chaincfg/chainhash/hash_test.go
@@ -8,6 +8,7 @@ import (
"bytes"
"encoding/hex"
"encoding/json"
+ "strings"
"testing"
)
@@ -110,7 +111,8 @@ func TestHashString(t *testing.T) {
}
}
-// TestNewHashFromStr executes tests against the NewHashFromStr function.
+// TestNewHashFromStr executes compatibility tests against the lenient
+// NewHashFromStr function.
func TestNewHashFromStr(t *testing.T) {
tests := []struct {
in string
@@ -196,6 +198,155 @@ func TestNewHashFromStr(t *testing.T) {
}
}
+// TestNewHashFromStrStrict executes tests against the NewHashFromStrStrict
+// function.
+func TestNewHashFromStrStrict(t *testing.T) {
+ tests := []struct {
+ name string
+ in string
+ want Hash
+ err error
+ }{
+ {
+ name: "genesis hash",
+ in: "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f",
+ want: mainNetGenesisHash,
+ err: nil,
+ },
+ {
+ name: "stripped leading zeros",
+ in: "19d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f",
+ want: Hash{},
+ err: ErrHashStrSizeMismatch,
+ },
+ {
+ name: "odd length hash",
+ in: "1",
+ want: Hash{},
+ err: ErrHashStrSizeMismatch,
+ },
+ {
+ name: "empty string",
+ in: "",
+ want: Hash{},
+ err: ErrHashStrSizeMismatch,
+ },
+ {
+ name: "hash string that is too long",
+ in: "01234567890123456789012345678901234567890123456789012345678912345",
+ want: Hash{},
+ err: ErrHashStrSizeMismatch,
+ },
+ {
+ name: "hash string that contains non-hex chars",
+ in: "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26g",
+ want: Hash{},
+ err: hex.InvalidByteError('g'),
+ },
+ }
+
+ unexpectedErrStr := "NewHashFromStrStrict #%d failed to detect expected error - got: %v want: %v"
+ unexpectedResultStr := "NewHashFromStrStrict #%d got: %v want: %v"
+ t.Logf("Running %d tests", len(tests))
+ for i, test := range tests {
+ test := test
+
+ t.Run(test.name, func(t *testing.T) {
+ result, err := NewHashFromStrStrict(test.in)
+ if err != test.err {
+ t.Errorf(unexpectedErrStr, i, err, test.err)
+ return
+ } else if err != nil {
+ return
+ }
+ if !test.want.IsEqual(result) {
+ t.Errorf(unexpectedResultStr, i, result, &test.want)
+ }
+ })
+ }
+}
+
+// TestDecodeStrict executes tests against the DecodeStrict function.
+func TestDecodeStrict(t *testing.T) {
+ tests := []struct {
+ name string
+ in string
+ want Hash
+ err error
+ }{
+ {
+ name: "genesis hash",
+ in: "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f",
+ want: mainNetGenesisHash,
+ err: nil,
+ },
+ {
+ name: "odd length hash",
+ in: "1",
+ want: Hash{},
+ err: ErrHashStrSizeMismatch,
+ },
+ {
+ name: "even length hash that is too short, 32 chars",
+ in: "deadbeef" + strings.Repeat("0", 24),
+ want: Hash{},
+ err: ErrHashStrSizeMismatch,
+ },
+ {
+ name: "hash string that contains non-hex chars",
+ in: "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26g",
+ want: Hash{},
+ err: hex.InvalidByteError('g'),
+ },
+ {
+ name: "hash string that is too long",
+ in: "01234567890123456789012345678901234567890123456789012345678912345",
+ want: Hash{},
+ err: ErrHashStrSizeMismatch,
+ },
+ }
+
+ unexpectedErrStr := "DecodeStrict #%d failed to detect expected error - got: %v want: %v"
+ unexpectedResultStr := "DecodeStrict #%d got: %v want: %v"
+ t.Logf("Running %d tests", len(tests))
+ for i, test := range tests {
+ test := test
+
+ t.Run(test.name, func(t *testing.T) {
+ var result Hash
+ err := DecodeStrict(&result, test.in)
+ if err != test.err {
+ t.Errorf(unexpectedErrStr, i, err, test.err)
+ return
+ } else if err != nil {
+ return
+ }
+ if !test.want.IsEqual(&result) {
+ t.Errorf(unexpectedResultStr, i, &result, &test.want)
+ }
+ })
+ }
+}
+
+// BenchmarkDecodeStrict benchmarks DecodeStrict on a valid canonical hash.
+func BenchmarkDecodeStrict(b *testing.B) {
+ // Use a canonical 64-character hash to exercise the successful strict path.
+ hashStr := "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f"
+ var result Hash
+
+ b.ReportAllocs()
+ for i := 0; i < b.N; i++ {
+ if err := DecodeStrict(&result, hashStr); err != nil {
+ b.Fatalf("unexpected decode error: %v", err)
+ }
+ }
+
+ if !mainNetGenesisHash.IsEqual(&result) {
+ b.Fatalf("unexpected decode result: got %v want %v",
+ &result, &mainNetGenesisHash)
+ }
+}
+
// TestHashJsonMarshal tests json marshal and unmarshal.
func TestHashJsonMarshal(t *testing.T) {
hashStr := "000000000003ba27aa200b1cecaad478d2b00432346c3f1f3986da1afd33e506"
Why this scored 37/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.