|
5 | 5 | "crypto/ed25519" |
6 | 6 | "crypto/elliptic" |
7 | 7 | "crypto/rand" |
| 8 | + "math/big" |
8 | 9 | "sync" |
9 | 10 | "testing" |
10 | 11 |
|
@@ -165,3 +166,137 @@ func TestMinpkGenkey1kParallel(t *testing.T) { |
165 | 166 | } |
166 | 167 | wg.Wait() |
167 | 168 | } |
| 169 | + |
| 170 | +// TestLowSCanonization verifies that ECDSA signatures are normalized to low-S form. |
| 171 | +// This is required for secp256k1 (tz2) compatibility with Tezos/libsecp256k1. |
| 172 | +// See issue #364: https://github.com/ecadlabs/signatory/issues/364 |
| 173 | +func TestLowSCanonization(t *testing.T) { |
| 174 | + curves := []struct { |
| 175 | + name string |
| 176 | + curve elliptic.Curve |
| 177 | + }{ |
| 178 | + {"secp256k1", secp256k1.S256()}, |
| 179 | + {"P256", elliptic.P256()}, |
| 180 | + } |
| 181 | + |
| 182 | + for _, c := range curves { |
| 183 | + t.Run(c.name, func(t *testing.T) { |
| 184 | + order := c.curve.Params().N |
| 185 | + halfOrder := new(big.Int).Quo(order, big.NewInt(2)) |
| 186 | + |
| 187 | + // Create a high-S value (S > N/2) |
| 188 | + highS := new(big.Int).Add(halfOrder, big.NewInt(1000)) |
| 189 | + r := big.NewInt(12345) |
| 190 | + |
| 191 | + t.Run("NewECDSASignature_canonizes_highS", func(t *testing.T) { |
| 192 | + sig := NewECDSASignature(r, highS, c.curve) |
| 193 | + // S should now be <= N/2 |
| 194 | + require.LessOrEqual(t, sig.S.Cmp(halfOrder), 0, |
| 195 | + "NewECDSASignature should produce low-S signature") |
| 196 | + // Verify S was actually changed |
| 197 | + require.NotEqual(t, 0, highS.Cmp(sig.S), |
| 198 | + "high-S should be different from canonized S") |
| 199 | + }) |
| 200 | + |
| 201 | + t.Run("NewECDSASignature_preserves_lowS", func(t *testing.T) { |
| 202 | + lowS := big.NewInt(1000) // definitely < N/2 |
| 203 | + sig := NewECDSASignature(r, lowS, c.curve) |
| 204 | + require.Equal(t, 0, lowS.Cmp(sig.S), |
| 205 | + "low-S should remain unchanged") |
| 206 | + }) |
| 207 | + |
| 208 | + t.Run("canonizeSignature_math_correctness", func(t *testing.T) { |
| 209 | + // When S > N/2, canonical S should be N - S |
| 210 | + sig := &ECDSASignature{R: r, S: new(big.Int).Set(highS), Curve: c.curve} |
| 211 | + canonical := canonizeSignature(sig) |
| 212 | + |
| 213 | + expectedS := new(big.Int).Sub(order, highS) |
| 214 | + require.Equal(t, 0, expectedS.Cmp(canonical.S), |
| 215 | + "canonical S should equal N - S for high-S values") |
| 216 | + }) |
| 217 | + }) |
| 218 | + } |
| 219 | +} |
| 220 | + |
| 221 | +// TestNewSignatureFromBytesCanonization verifies that signatures parsed from |
| 222 | +// ASN.1 (as received from cloud KMS providers) are canonized to low-S form. |
| 223 | +func TestNewSignatureFromBytesCanonization(t *testing.T) { |
| 224 | + curves := []struct { |
| 225 | + name string |
| 226 | + curve elliptic.Curve |
| 227 | + }{ |
| 228 | + {"secp256k1", secp256k1.S256()}, |
| 229 | + {"P256", elliptic.P256()}, |
| 230 | + } |
| 231 | + |
| 232 | + for _, c := range curves { |
| 233 | + t.Run(c.name, func(t *testing.T) { |
| 234 | + // Generate a real key and sign to get valid signature bytes |
| 235 | + priv, err := ecdsa.GenerateKey(c.curve, rand.Reader) |
| 236 | + require.NoError(t, err) |
| 237 | + |
| 238 | + message := []byte("test message for signature canonization") |
| 239 | + digest := DigestFunc(message) |
| 240 | + |
| 241 | + // Sign multiple times - statistically ~50% should have high-S originally |
| 242 | + // All should be low-S after canonization |
| 243 | + halfOrder := new(big.Int).Quo(c.curve.Params().N, big.NewInt(2)) |
| 244 | + |
| 245 | + for i := 0; i < 20; i++ { |
| 246 | + r, s, err := ecdsa.Sign(rand.Reader, priv, digest[:]) |
| 247 | + require.NoError(t, err) |
| 248 | + |
| 249 | + // Create ASN.1 DER encoded signature (as cloud KMS would return) |
| 250 | + asn1Sig := encodeASN1Signature(r, s) |
| 251 | + |
| 252 | + pub := (*ECDSAPublicKey)(&priv.PublicKey) |
| 253 | + sig, err := NewSignatureFromBytes(asn1Sig, pub) |
| 254 | + require.NoError(t, err) |
| 255 | + |
| 256 | + ecdsaSig, ok := sig.(*ECDSASignature) |
| 257 | + require.True(t, ok) |
| 258 | + |
| 259 | + // Verify S is in low-S form |
| 260 | + require.LessOrEqual(t, ecdsaSig.S.Cmp(halfOrder), 0, |
| 261 | + "NewSignatureFromBytes must produce low-S signature (iteration %d)", i) |
| 262 | + |
| 263 | + // Verify signature still validates |
| 264 | + require.True(t, sig.Verify(pub, message), |
| 265 | + "canonized signature must still verify") |
| 266 | + } |
| 267 | + }) |
| 268 | + } |
| 269 | +} |
| 270 | + |
| 271 | +// encodeASN1Signature creates ASN.1 DER encoded signature from R, S values |
| 272 | +// (simulating what cloud KMS providers return) |
| 273 | +func encodeASN1Signature(r, s *big.Int) []byte { |
| 274 | + rBytes := r.Bytes() |
| 275 | + sBytes := s.Bytes() |
| 276 | + |
| 277 | + // Add leading zero if high bit is set (ASN.1 integer is signed) |
| 278 | + if len(rBytes) > 0 && rBytes[0]&0x80 != 0 { |
| 279 | + rBytes = append([]byte{0}, rBytes...) |
| 280 | + } |
| 281 | + if len(sBytes) > 0 && sBytes[0]&0x80 != 0 { |
| 282 | + sBytes = append([]byte{0}, sBytes...) |
| 283 | + } |
| 284 | + |
| 285 | + // Build ASN.1 SEQUENCE |
| 286 | + innerLen := 2 + len(rBytes) + 2 + len(sBytes) |
| 287 | + result := make([]byte, 0, 2+innerLen) |
| 288 | + |
| 289 | + // SEQUENCE tag and length |
| 290 | + result = append(result, 0x30) |
| 291 | + result = append(result, byte(innerLen)) |
| 292 | + |
| 293 | + // INTEGER for R |
| 294 | + result = append(result, 0x02, byte(len(rBytes))) |
| 295 | + result = append(result, rBytes...) |
| 296 | + |
| 297 | + // INTEGER for S |
| 298 | + result = append(result, 0x02, byte(len(sBytes))) |
| 299 | + result = append(result, sBytes...) |
| 300 | + |
| 301 | + return result |
| 302 | +} |
0 commit comments