From 9a8c9ff5366f3da75d313e909692e91f7e5715d1 Mon Sep 17 00:00:00 2001 From: yperbasis Date: Fri, 14 Aug 2026 17:08:37 +0200 Subject: [PATCH 1/4] keccak: add independent hasher cloning --- keccak.go | 31 +++++++++++++++++++++- keccak_asm.go | 20 ++++++++++++-- keccak_clone_asm_test.go | 13 ++++++++++ keccak_clone_test.go | 56 ++++++++++++++++++++++++++++++++++++++++ keccak_default.go | 15 ++++++++++- 5 files changed, 131 insertions(+), 4 deletions(-) create mode 100644 keccak_clone_asm_test.go create mode 100644 keccak_clone_test.go diff --git a/keccak.go b/keccak.go index bc3ae7a..a3b8506 100644 --- a/keccak.go +++ b/keccak.go @@ -9,7 +9,13 @@ package keccak //go:generate go run gen_keccakf_bmi2.go //go:generate go run github.com/klauspost/asmfmt/cmd/asmfmt@v1.3.2 -w keccakf_amd64_bmi2.s -import "hash" +import ( + "encoding" + "errors" + "hash" + + "golang.org/x/crypto/sha3" +) // KeccakState wraps the keccak hasher. In addition to the usual hash methods, it also supports // Read to get a variable amount of data from the hash state. Read is faster than Sum @@ -28,3 +34,26 @@ var _ KeccakState = (*Hasher)(nil) func NewFastKeccak() *Hasher { return &Hasher{} } + +var errCloneUnsupported = errors.New("keccak: fallback hasher does not support cloning") + +func cloneXCrypto(state KeccakState) (KeccakState, error) { + marshaler, ok := state.(encoding.BinaryMarshaler) + if !ok { + return nil, errCloneUnsupported + } + encoded, err := marshaler.MarshalBinary() + if err != nil { + return nil, err + } + + clone := sha3.NewLegacyKeccak256().(KeccakState) + unmarshaler, ok := clone.(encoding.BinaryUnmarshaler) + if !ok { + return nil, errCloneUnsupported + } + if err := unmarshaler.UnmarshalBinary(encoded); err != nil { + return nil, err + } + return clone, nil +} diff --git a/keccak_asm.go b/keccak_asm.go index 37ce284..97afcc8 100644 --- a/keccak_asm.go +++ b/keccak_asm.go @@ -149,8 +149,9 @@ func sum256XCrypto(data []byte) [32]byte { return out } -// Hasher is a streaming Keccak-256 hasher. -// Uses platform assembly when available, x/crypto/sha3 otherwise. +// Hasher is a streaming Keccak-256 hasher. It uses platform assembly when +// available and x/crypto/sha3 otherwise. A Hasher must not be copied after +// first use; use Clone to create an independent copy. type Hasher struct { sponge xc KeccakState // x/crypto fallback @@ -221,6 +222,21 @@ func (h *Hasher) Read(out []byte) (int, error) { return h.sponge.Read(out) } +// Clone returns an independent copy of h in its current state. +func (h *Hasher) Clone() (*Hasher, error) { + if useASM { + return &Hasher{sponge: h.sponge}, nil + } + if h.xc == nil { + return &Hasher{}, nil + } + state, err := cloneXCrypto(h.xc) + if err != nil { + return nil, err + } + return &Hasher{xc: state}, nil +} + // xorIn XORs data into the first len(data) bytes of state using uint64 loads. func xorIn(state *[200]byte, data []byte) { for i := 0; i+8 <= len(data); i += 8 { diff --git a/keccak_clone_asm_test.go b/keccak_clone_asm_test.go new file mode 100644 index 0000000..1eec57e --- /dev/null +++ b/keccak_clone_asm_test.go @@ -0,0 +1,13 @@ +//go:build (amd64 || arm64) && !purego + +package keccak + +import "testing" + +func TestHasherCloneRuntimeFallback(t *testing.T) { + old := useASM + useASM = false + t.Cleanup(func() { useASM = old }) + + testHasherClone(t) +} diff --git a/keccak_clone_test.go b/keccak_clone_test.go new file mode 100644 index 0000000..20ac3de --- /dev/null +++ b/keccak_clone_test.go @@ -0,0 +1,56 @@ +package keccak + +import ( + "bytes" + "testing" +) + +func testHasherClone(t *testing.T) { + t.Helper() + + var zero Hasher + zeroClone, err := zero.Clone() + if err != nil { + t.Fatalf("Clone zero value: %v", err) + } + if got, want := zeroClone.Sum256(), Sum256(nil); got != want { + t.Fatalf("zero-value clone digest = %x, want %x", got, want) + } + + prefix := []byte("shared prefix") + var original Hasher + original.Write(prefix) + + clone, err := original.Clone() + if err != nil { + t.Fatalf("Clone: %v", err) + } + original.Write([]byte(" original")) + clone.Write([]byte(" clone")) + + if got, want := original.Sum256(), Sum256(append(bytes.Clone(prefix), " original"...)); got != want { + t.Fatalf("original digest = %x, want %x", got, want) + } + if got, want := clone.Sum256(), Sum256(append(bytes.Clone(prefix), " clone"...)); got != want { + t.Fatalf("clone digest = %x, want %x", got, want) + } + + var squeezing Hasher + squeezing.Write(prefix) + squeezing.Read(make([]byte, 17)) + squeezingClone, err := squeezing.Clone() + if err != nil { + t.Fatalf("Clone while squeezing: %v", err) + } + want := make([]byte, 200) + got := make([]byte, len(want)) + squeezing.Read(want) + squeezingClone.Read(got) + if !bytes.Equal(got, want) { + t.Fatal("clone did not preserve an independent squeeze position") + } +} + +func TestHasherClone(t *testing.T) { + testHasherClone(t) +} diff --git a/keccak_default.go b/keccak_default.go index 2e8eee3..2e5bf4d 100644 --- a/keccak_default.go +++ b/keccak_default.go @@ -16,7 +16,8 @@ func Sum256(data []byte) [32]byte { return out } -// Hasher is a streaming Keccak-256 hasher wrapping x/crypto/sha3. +// Hasher is a streaming Keccak-256 hasher wrapping x/crypto/sha3. A Hasher +// must not be copied after first use; use Clone to create an independent copy. type Hasher struct { h KeccakState } @@ -69,3 +70,15 @@ func (h *Hasher) Read(out []byte) (int, error) { h.init() return h.h.Read(out) } + +// Clone returns an independent copy of h in its current state. +func (h *Hasher) Clone() (*Hasher, error) { + if h.h == nil { + return &Hasher{}, nil + } + state, err := cloneXCrypto(h.h) + if err != nil { + return nil, err + } + return &Hasher{h: state}, nil +} From e173a44e61760bc802acbd33386dcf480718eea1 Mon Sep 17 00:00:00 2001 From: Alexey Sharov Date: Wed, 2 Sep 2026 11:19:30 +0700 Subject: [PATCH 2/4] keccak: dispatch Clone on the receiver, report the failed assertion Cloning with useASM true while the state lives in h.xc returned an empty sponge and a nil error. Branch on which sub-state the receiver holds, use comma-ok for the KeccakState assertion so Clone returns errCloneUnsupported instead of panicking, and cover the fallback receiver by building it directly rather than by writing the useASM global from a test. --- keccak.go | 5 ++++- keccak_asm.go | 17 +++++++---------- keccak_asm_test.go | 33 +++++++++++++++++++++++++++++++++ keccak_clone_asm_test.go | 13 ------------- 4 files changed, 44 insertions(+), 24 deletions(-) delete mode 100644 keccak_clone_asm_test.go diff --git a/keccak.go b/keccak.go index a3b8506..94e5589 100644 --- a/keccak.go +++ b/keccak.go @@ -47,7 +47,10 @@ func cloneXCrypto(state KeccakState) (KeccakState, error) { return nil, err } - clone := sha3.NewLegacyKeccak256().(KeccakState) + clone, ok := sha3.NewLegacyKeccak256().(KeccakState) + if !ok { + return nil, errCloneUnsupported + } unmarshaler, ok := clone.(encoding.BinaryUnmarshaler) if !ok { return nil, errCloneUnsupported diff --git a/keccak_asm.go b/keccak_asm.go index 97afcc8..66cfc27 100644 --- a/keccak_asm.go +++ b/keccak_asm.go @@ -224,17 +224,14 @@ func (h *Hasher) Read(out []byte) (int, error) { // Clone returns an independent copy of h in its current state. func (h *Hasher) Clone() (*Hasher, error) { - if useASM { - return &Hasher{sponge: h.sponge}, nil - } - if h.xc == nil { - return &Hasher{}, nil - } - state, err := cloneXCrypto(h.xc) - if err != nil { - return nil, err + if h.xc != nil { + state, err := cloneXCrypto(h.xc) + if err != nil { + return nil, err + } + return &Hasher{xc: state}, nil } - return &Hasher{xc: state}, nil + return &Hasher{sponge: h.sponge}, nil } // xorIn XORs data into the first len(data) bytes of state using uint64 loads. diff --git a/keccak_asm_test.go b/keccak_asm_test.go index 44a8f64..db896d2 100644 --- a/keccak_asm_test.go +++ b/keccak_asm_test.go @@ -5,6 +5,8 @@ package keccak import ( "math/rand" "testing" + + "golang.org/x/crypto/sha3" ) // TestXorAndPermute verifies the fused XOR+permute assembly entry point @@ -34,3 +36,34 @@ func TestXorAndPermute(t *testing.T) { } } } + +func xcDigest(state KeccakState) [32]byte { + var out [32]byte + state.Sum(out[:0]) + return out +} + +// TestHasherCloneFallbackState covers Clone on a receiver whose state lives in +// the x/crypto fallback while the package dispatches to assembly. Building the +// receiver directly keeps the test off the useASM global, which is only safe +// to write while nothing else in the package runs concurrently. +func TestHasherCloneFallbackState(t *testing.T) { + const prefix = "shared prefix" + var original Hasher + original.xc = sha3.NewLegacyKeccak256().(KeccakState) + original.xc.Write([]byte(prefix)) + + clone, err := original.Clone() + if err != nil { + t.Fatalf("Clone: %v", err) + } + original.xc.Write([]byte(" original")) + clone.xc.Write([]byte(" clone")) + + if got, want := xcDigest(original.xc), Sum256([]byte(prefix+" original")); got != want { + t.Fatalf("original digest = %x, want %x", got, want) + } + if got, want := xcDigest(clone.xc), Sum256([]byte(prefix+" clone")); got != want { + t.Fatalf("clone digest = %x, want %x", got, want) + } +} diff --git a/keccak_clone_asm_test.go b/keccak_clone_asm_test.go deleted file mode 100644 index 1eec57e..0000000 --- a/keccak_clone_asm_test.go +++ /dev/null @@ -1,13 +0,0 @@ -//go:build (amd64 || arm64) && !purego - -package keccak - -import "testing" - -func TestHasherCloneRuntimeFallback(t *testing.T) { - old := useASM - useASM = false - t.Cleanup(func() { useASM = old }) - - testHasherClone(t) -} From b96937e2d4df81557f5eaa885d5486cd960cd49e Mon Sep 17 00:00:00 2001 From: Alexey Sharov Date: Wed, 2 Sep 2026 11:43:12 +0700 Subject: [PATCH 3/4] keccak: fold the clone test helper into its only caller The asm-side caller went away with keccak_clone_asm_test.go. --- keccak_clone_test.go | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/keccak_clone_test.go b/keccak_clone_test.go index 20ac3de..837a293 100644 --- a/keccak_clone_test.go +++ b/keccak_clone_test.go @@ -5,9 +5,7 @@ import ( "testing" ) -func testHasherClone(t *testing.T) { - t.Helper() - +func TestHasherClone(t *testing.T) { var zero Hasher zeroClone, err := zero.Clone() if err != nil { @@ -50,7 +48,3 @@ func testHasherClone(t *testing.T) { t.Fatal("clone did not preserve an independent squeeze position") } } - -func TestHasherClone(t *testing.T) { - testHasherClone(t) -} From 09a0f5c0969e0dc79335dde4cebd423447481327 Mon Sep 17 00:00:00 2001 From: Alexey Sharov Date: Wed, 2 Sep 2026 11:47:11 +0700 Subject: [PATCH 4/4] keccak: Clone carries both sub-states Copying only the live one made the copy dispatch differently from the original: with both populated, Clone returned a zero sponge and the copy hashed the empty string where the original hashed its sponge data. --- keccak_asm.go | 18 ++++++++++-------- keccak_asm_test.go | 8 ++++++++ 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/keccak_asm.go b/keccak_asm.go index 66cfc27..975a9ea 100644 --- a/keccak_asm.go +++ b/keccak_asm.go @@ -222,16 +222,18 @@ func (h *Hasher) Read(out []byte) (int, error) { return h.sponge.Read(out) } -// Clone returns an independent copy of h in its current state. +// Clone returns an independent copy of h in its current state. It carries +// both sub-states, so the copy dispatches the same way the original does for +// any value of useASM. func (h *Hasher) Clone() (*Hasher, error) { - if h.xc != nil { - state, err := cloneXCrypto(h.xc) - if err != nil { - return nil, err - } - return &Hasher{xc: state}, nil + if h.xc == nil { + return &Hasher{sponge: h.sponge}, nil + } + state, err := cloneXCrypto(h.xc) + if err != nil { + return nil, err } - return &Hasher{sponge: h.sponge}, nil + return &Hasher{sponge: h.sponge, xc: state}, nil } // xorIn XORs data into the first len(data) bytes of state using uint64 loads. diff --git a/keccak_asm_test.go b/keccak_asm_test.go index db896d2..3cb72ed 100644 --- a/keccak_asm_test.go +++ b/keccak_asm_test.go @@ -50,6 +50,7 @@ func xcDigest(state KeccakState) [32]byte { func TestHasherCloneFallbackState(t *testing.T) { const prefix = "shared prefix" var original Hasher + original.Write([]byte("sponge side")) original.xc = sha3.NewLegacyKeccak256().(KeccakState) original.xc.Write([]byte(prefix)) @@ -57,6 +58,13 @@ func TestHasherCloneFallbackState(t *testing.T) { if err != nil { t.Fatalf("Clone: %v", err) } + if got, want := clone.Sum256(), original.Sum256(); got != want { + t.Fatalf("clone digest = %x, original = %x: the copy does not dispatch like the original", got, want) + } + if clone.sponge != original.sponge { + t.Fatal("Clone dropped the sponge state") + } + original.xc.Write([]byte(" original")) clone.xc.Write([]byte(" clone"))