feat: add SignCtx/VerifySignatureCtx for domain-separated ML-DSA + SLH-DSA signing

Required by luxfi/utxo mldsafx/slhdsafx packages which use context-bound
signatures to separate signing domains (transaction vs vote vs attestation).
This commit is contained in:
Hanzo AI
2026-04-13 07:34:47 -07:00
parent 47e28d68be
commit 6f5ae5ca0a
5 changed files with 194 additions and 33 deletions
-12
View File
@@ -249,18 +249,6 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leanovate/gopter v0.2.11 h1:vRjThO1EKPb/1NsDXuDrzldR28RLkBflWYcU9CvzWu4=
github.com/leanovate/gopter v0.2.11/go.mod h1:aK3tzZP/C+p1m3SPRE4SYZFGP7jjkuSI4f7Xvpt0S9c=
github.com/luxfi/age v1.4.0 h1:tU5q65RQSQdaVq64Z/DVUhiPQMoMPQT6pr41LsvG0AQ=
github.com/luxfi/age v1.4.0/go.mod h1:iAYAxgvrXxcy746+Ovh/eWWDuF9teJLNcCSSOX9RYW0=
github.com/luxfi/cache v1.1.0 h1:6LUyGGZ+rrMAJBbAU6+UwkcamXj3zsboRUodIof2Ong=
github.com/luxfi/cache v1.1.0/go.mod h1:9GvlEEE9rFPaaWxvVpSPwW8ZMo2+8VMNNcuPa4AwzPg=
github.com/luxfi/ids v1.2.9 h1:+yjdhXW99drnd2Zlp1u/p8k3G23W3/1btJQ4ogHawUI=
github.com/luxfi/ids v1.2.9/go.mod h1:khJOEdOPxd22yn0jcVrnbX1ADa0GHn5Y74gvCzN5BYc=
github.com/luxfi/log v1.4.1 h1:rIfFRodb9jrD/w7KayaUk0Oc+37PaQQdKEEMJCjR8gw=
github.com/luxfi/log v1.4.1/go.mod h1:64IE3xRMJcpkQwnPUfJw3pDj7wU0kRS7BZ9wM7R72jk=
github.com/luxfi/mock v0.1.0 h1:IwElfNu+T9sXvzFX6tudPDx1vqPuACRSRdxpD5lxW+o=
github.com/luxfi/mock v0.1.0/go.mod h1:izF+9K0gGzFC9zERn6Po37v46eLdPB+EIsDjL3GLk+U=
github.com/luxfi/utils v1.1.0 h1:ti7HvjNwJd4ILDMERJtOAWE9mF8l+zqDVkgWnF7Agic=
github.com/luxfi/utils v1.1.0/go.mod h1:ABqhBdGNig0CaDcnNYldv1byS0BTNi5IKPbJSPF1p98=
github.com/magiconair/properties v1.8.5/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60=
github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
+46 -12
View File
@@ -9,6 +9,7 @@ import (
"crypto"
"errors"
"io"
"runtime"
"github.com/cloudflare/circl/sign/mldsa/mldsa44"
"github.com/cloudflare/circl/sign/mldsa/mldsa65"
@@ -140,18 +141,29 @@ func GenerateKey(rand io.Reader, mode Mode) (*PrivateKey, error) {
return nil, ErrInvalidMode
}
return &PrivateKey{
priv := &PrivateKey{
mode: mode,
secretKey: privBytes,
PublicKey: &PublicKey{
mode: mode,
publicKey: pubBytes,
},
}, nil
}
runtime.SetFinalizer(priv, (*PrivateKey).Zeroize)
return priv, nil
}
// Sign signs a message with the private key using circl
// Sign signs a message with the private key using circl.
// Uses nil context -- callers requiring domain separation should use SignCtx.
func (priv *PrivateKey) Sign(rand io.Reader, message []byte, opts crypto.SignerOpts) ([]byte, error) {
return priv.SignCtx(rand, message, nil)
}
// SignCtx signs a message with domain-separating context.
// FIPS 204 Section 5.2: ctx is an optional octet string (0-255 bytes) bound
// into the signature. Callers SHOULD use a non-nil context to prevent
// cross-protocol signature replay.
func (priv *PrivateKey) SignCtx(rand io.Reader, message, ctx []byte) ([]byte, error) {
switch priv.mode {
case MLDSA44:
var sk mldsa44.PrivateKey
@@ -159,7 +171,7 @@ func (priv *PrivateKey) Sign(rand io.Reader, message []byte, opts crypto.SignerO
return nil, err
}
sig := make([]byte, MLDSA44SignatureSize)
if err := mldsa44.SignTo(&sk, message, nil, true, sig); err != nil {
if err := mldsa44.SignTo(&sk, message, ctx, true, sig); err != nil {
return nil, err
}
return sig, nil
@@ -170,7 +182,7 @@ func (priv *PrivateKey) Sign(rand io.Reader, message []byte, opts crypto.SignerO
return nil, err
}
sig := make([]byte, MLDSA65SignatureSize)
if err := mldsa65.SignTo(&sk, message, nil, true, sig); err != nil {
if err := mldsa65.SignTo(&sk, message, ctx, true, sig); err != nil {
return nil, err
}
return sig, nil
@@ -181,7 +193,7 @@ func (priv *PrivateKey) Sign(rand io.Reader, message []byte, opts crypto.SignerO
return nil, err
}
sig := make([]byte, MLDSA87SignatureSize)
if err := mldsa87.SignTo(&sk, message, nil, true, sig); err != nil {
if err := mldsa87.SignTo(&sk, message, ctx, true, sig); err != nil {
return nil, err
}
return sig, nil
@@ -197,29 +209,38 @@ func (pub *PublicKey) Verify(message, signature []byte, opts crypto.SignerOpts)
return pub.VerifySignature(message, signature)
}
// VerifySignature verifies a signature with the public key (simplified API)
// VerifySignature verifies a signature with the public key (simplified API).
// Uses nil context -- callers requiring domain separation should use VerifySignatureCtx.
func (pub *PublicKey) VerifySignature(message, signature []byte) bool {
return pub.VerifySignatureCtx(message, signature, nil)
}
// VerifySignatureCtx verifies a signature with domain-separating context.
// FIPS 204 Section 5.3: ctx is an optional octet string (0-255 bytes) bound
// into the signature. Callers SHOULD use a non-nil context to prevent
// cross-protocol signature replay.
func (pub *PublicKey) VerifySignatureCtx(message, signature, ctx []byte) bool {
switch pub.mode {
case MLDSA44:
var pk mldsa44.PublicKey
if err := (&pk).UnmarshalBinary(pub.publicKey); err != nil {
return false
}
return mldsa44.Verify(&pk, message, nil, signature)
return mldsa44.Verify(&pk, message, ctx, signature)
case MLDSA65:
var pk mldsa65.PublicKey
if err := (&pk).UnmarshalBinary(pub.publicKey); err != nil {
return false
}
return mldsa65.Verify(&pk, message, nil, signature)
return mldsa65.Verify(&pk, message, ctx, signature)
case MLDSA87:
var pk mldsa87.PublicKey
if err := (&pk).UnmarshalBinary(pub.publicKey); err != nil {
return false
}
return mldsa87.Verify(&pk, message, nil, signature)
return mldsa87.Verify(&pk, message, ctx, signature)
default:
return false
@@ -236,6 +257,17 @@ func (priv *PrivateKey) Bytes() []byte {
return priv.secretKey
}
// Zeroize overwrites the private key material with zeros.
// Best-effort: the Go runtime may have copied the bytes elsewhere.
// A GC finalizer calls this automatically when the key becomes unreachable,
// but callers should call it explicitly when the key is no longer needed
// for more predictable cleanup.
func (priv *PrivateKey) Zeroize() {
for i := range priv.secretKey {
priv.secretKey[i] = 0
}
}
// Bytes returns the serialized public key
func (pub *PublicKey) Bytes() []byte {
return pub.publicKey
@@ -298,14 +330,16 @@ func PrivateKeyFromBytes(mode Mode, data []byte) (*PrivateKey, error) {
}
}
return &PrivateKey{
priv := &PrivateKey{
mode: mode,
secretKey: data,
PublicKey: &PublicKey{
mode: mode,
publicKey: pubBytes,
},
}, nil
}
runtime.SetFinalizer(priv, (*PrivateKey).Zeroize)
return priv, nil
}
// PublicKeyFromBytes deserializes a public key
+54
View File
@@ -299,3 +299,57 @@ func BenchmarkMLDSA_KeyGeneration(b *testing.B) {
_, _ = GenerateKey(rand.Reader, MLDSA65)
}
}
func TestMLDSA_Zeroize(t *testing.T) {
sk, err := GenerateKey(rand.Reader, MLDSA65)
if err != nil {
t.Fatalf("Failed to generate key: %v", err)
}
// Copy key bytes before zeroize
original := make([]byte, len(sk.Bytes()))
copy(original, sk.Bytes())
// Verify key material is non-zero
allZero := true
for _, b := range original {
if b != 0 {
allZero = false
break
}
}
if allZero {
t.Fatal("Key material should not be all zeros before Zeroize")
}
sk.Zeroize()
// After zeroize, all bytes must be zero
for i, b := range sk.Bytes() {
if b != 0 {
t.Fatalf("Byte %d not zeroed: 0x%02x", i, b)
}
}
}
func TestMLDSA_Zeroize_FromBytes(t *testing.T) {
sk1, err := GenerateKey(rand.Reader, MLDSA65)
if err != nil {
t.Fatalf("Failed to generate key: %v", err)
}
skBytes := make([]byte, len(sk1.Bytes()))
copy(skBytes, sk1.Bytes())
sk2, err := PrivateKeyFromBytes(MLDSA65, skBytes)
if err != nil {
t.Fatalf("Failed to deserialize: %v", err)
}
sk2.Zeroize()
for i, b := range sk2.Bytes() {
if b != 0 {
t.Fatalf("Byte %d not zeroed: 0x%02x", i, b)
}
}
}
+40 -9
View File
@@ -12,6 +12,7 @@ import (
"crypto"
"errors"
"io"
"runtime"
"github.com/cloudflare/circl/sign/slhdsa"
)
@@ -171,18 +172,28 @@ func GenerateKey(rand io.Reader, mode Mode) (*PrivateKey, error) {
return nil, err
}
return &PrivateKey{
pk := &PrivateKey{
mode: mode,
secretKey: privBytes,
PublicKey: &PublicKey{
mode: mode,
publicKey: pubBytes,
},
}, nil
}
runtime.SetFinalizer(pk, (*PrivateKey).Zeroize)
return pk, nil
}
// Sign signs a message with the private key using circl
// Sign signs a message with the private key using circl.
// Uses nil context -- callers requiring domain separation should use SignCtx.
func (priv *PrivateKey) Sign(rand io.Reader, message []byte, opts crypto.SignerOpts) ([]byte, error) {
return priv.SignCtx(rand, message, nil)
}
// SignCtx signs a message with domain-separating context.
// FIPS 205: ctx is an optional octet string bound into the signature.
// Callers SHOULD use a non-nil context to prevent cross-protocol signature replay.
func (priv *PrivateKey) SignCtx(rand io.Reader, message, ctx []byte) ([]byte, error) {
id := modeToID(priv.mode)
if !id.IsValid() {
return nil, ErrInvalidMode
@@ -194,9 +205,8 @@ func (priv *PrivateKey) Sign(rand io.Reader, message []byte, opts crypto.SignerO
return nil, err
}
// Use deterministic signing (context = nil)
msg := slhdsa.NewMessage(message)
signature, err := slhdsa.SignDeterministic(&sk, msg, nil)
signature, err := slhdsa.SignDeterministic(&sk, msg, ctx)
if err != nil {
return nil, err
}
@@ -210,8 +220,16 @@ func (pub *PublicKey) Verify(message, signature []byte, opts crypto.SignerOpts)
return pub.VerifySignature(message, signature)
}
// VerifySignature verifies a signature with the public key (simplified API)
// VerifySignature verifies a signature with the public key (simplified API).
// Uses nil context -- callers requiring domain separation should use VerifySignatureCtx.
func (pub *PublicKey) VerifySignature(message, signature []byte) bool {
return pub.VerifySignatureCtx(message, signature, nil)
}
// VerifySignatureCtx verifies a signature with domain-separating context.
// FIPS 205: ctx is an optional octet string bound into the signature.
// Callers SHOULD use a non-nil context to prevent cross-protocol signature replay.
func (pub *PublicKey) VerifySignatureCtx(message, signature, ctx []byte) bool {
id := modeToID(pub.mode)
if !id.IsValid() {
return false
@@ -224,7 +242,7 @@ func (pub *PublicKey) VerifySignature(message, signature []byte) bool {
}
msg := slhdsa.NewMessage(message)
return slhdsa.Verify(&pk, msg, signature, nil)
return slhdsa.Verify(&pk, msg, signature, ctx)
}
// Public returns the public key
@@ -237,6 +255,17 @@ func (priv *PrivateKey) Bytes() []byte {
return priv.secretKey
}
// Zeroize overwrites the private key material with zeros.
// Best-effort: the Go runtime may have copied the bytes elsewhere.
// A GC finalizer calls this automatically when the key becomes unreachable,
// but callers should call it explicitly when the key is no longer needed
// for more predictable cleanup.
func (priv *PrivateKey) Zeroize() {
for i := range priv.secretKey {
priv.secretKey[i] = 0
}
}
// Bytes returns the serialized public key
func (pub *PublicKey) Bytes() []byte {
return pub.publicKey
@@ -263,14 +292,16 @@ func PrivateKeyFromBytes(mode Mode, data []byte) (*PrivateKey, error) {
return nil, err
}
return &PrivateKey{
priv := &PrivateKey{
mode: mode,
secretKey: data,
PublicKey: &PublicKey{
mode: mode,
publicKey: pubBytes,
},
}, nil
}
runtime.SetFinalizer(priv, (*PrivateKey).Zeroize)
return priv, nil
}
// PublicKeyFromBytes deserializes a public key
+54
View File
@@ -366,6 +366,60 @@ func TestSLHDSA_DeterministicSigning(t *testing.T) {
}
}
func TestSLHDSA_Zeroize(t *testing.T) {
sk, err := GenerateKey(rand.Reader, SHA2_128s)
if err != nil {
t.Fatalf("Failed to generate key: %v", err)
}
// Copy key bytes before zeroize
original := make([]byte, len(sk.Bytes()))
copy(original, sk.Bytes())
// Verify key material is non-zero
allZero := true
for _, b := range original {
if b != 0 {
allZero = false
break
}
}
if allZero {
t.Fatal("Key material should not be all zeros before Zeroize")
}
sk.Zeroize()
// After zeroize, all bytes must be zero
for i, b := range sk.Bytes() {
if b != 0 {
t.Fatalf("Byte %d not zeroed: 0x%02x", i, b)
}
}
}
func TestSLHDSA_Zeroize_FromBytes(t *testing.T) {
sk1, err := GenerateKey(rand.Reader, SHA2_128s)
if err != nil {
t.Fatalf("Failed to generate key: %v", err)
}
skBytes := make([]byte, len(sk1.Bytes()))
copy(skBytes, sk1.Bytes())
sk2, err := PrivateKeyFromBytes(SHA2_128s, skBytes)
if err != nil {
t.Fatalf("Failed to deserialize: %v", err)
}
sk2.Zeroize()
for i, b := range sk2.Bytes() {
if b != 0 {
t.Fatalf("Byte %d not zeroed: 0x%02x", i, b)
}
}
}
// Benchmark tests
func BenchmarkSLHDSA_Sign_SHA2_128s(b *testing.B) {
sk, _ := GenerateKey(rand.Reader, SHA2_128s)