Player identity is now Ed25519 and ML-DSA-65 (NIST FIPS 204) together, both signatures required. An attacker must break lattice assumptions and elliptic curves, not either one — which covers both the quantum threat to Ed25519 and the possibility that a 2024 lattice standard does not hold. Signatures are domain-separated to this application so one captured from another ML-DSA protocol cannot be replayed. Licensed AGPL-3.0: a fork stood up as a service must publish its changes, which is what keeps a provably-fair platform honest. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
211 lines
5.8 KiB
Go
211 lines
5.8 KiB
Go
package pqid_test
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"errors"
|
|
"testing"
|
|
|
|
"github.com/drjones/quantum-arcade/pkg/pqid"
|
|
)
|
|
|
|
func newKey(t *testing.T) (*pqid.PublicKey, *pqid.PrivateKey) {
|
|
t.Helper()
|
|
pub, priv, err := pqid.GenerateKey(rand.Reader)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return pub, priv
|
|
}
|
|
|
|
func TestSignAndVerify(t *testing.T) {
|
|
pub, priv := newKey(t)
|
|
msg := []byte("authenticate me")
|
|
|
|
sig, err := pqid.Sign(priv, msg)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := pqid.Verify(pub, msg, sig); err != nil {
|
|
t.Fatalf("valid hybrid signature rejected: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestWrongMessageFails(t *testing.T) {
|
|
pub, priv := newKey(t)
|
|
sig, _ := pqid.Sign(priv, []byte("original"))
|
|
if err := pqid.Verify(pub, []byte("tampered"), sig); err == nil {
|
|
t.Fatal("signature verified against a different message")
|
|
}
|
|
}
|
|
|
|
func TestWrongKeyFails(t *testing.T) {
|
|
_, priv := newKey(t)
|
|
other, _ := newKey(t)
|
|
sig, _ := pqid.Sign(priv, []byte("msg"))
|
|
if err := pqid.Verify(other, []byte("msg"), sig); err == nil {
|
|
t.Fatal("signature verified under the wrong key")
|
|
}
|
|
}
|
|
|
|
// The whole point of hybrid: forging only the classical half must not
|
|
// authenticate. This is the quantum-adversary scenario — Shor breaks Ed25519,
|
|
// ML-DSA still holds.
|
|
func TestValidClassicalWithBrokenPostQuantumFails(t *testing.T) {
|
|
pub, priv := newKey(t)
|
|
msg := []byte("msg")
|
|
sig, _ := pqid.Sign(priv, msg)
|
|
|
|
// Keep the genuine Ed25519 signature, corrupt the ML-DSA half.
|
|
forged := append([]byte(nil), sig...)
|
|
forged[pqid.EdSignatureSize+10] ^= 0xff
|
|
|
|
err := pqid.Verify(pub, msg, forged)
|
|
if err == nil {
|
|
t.Fatal("a broken post-quantum half still authenticated")
|
|
}
|
|
if !errors.Is(err, pqid.ErrPostQuantumFailed) {
|
|
t.Fatalf("got %v, want ErrPostQuantumFailed", err)
|
|
}
|
|
}
|
|
|
|
// And the mirror case: if lattice cryptography turns out to be weak, Ed25519
|
|
// must still stand in the way.
|
|
func TestValidPostQuantumWithBrokenClassicalFails(t *testing.T) {
|
|
pub, priv := newKey(t)
|
|
msg := []byte("msg")
|
|
sig, _ := pqid.Sign(priv, msg)
|
|
|
|
forged := append([]byte(nil), sig...)
|
|
forged[5] ^= 0xff // corrupt the Ed25519 half
|
|
|
|
err := pqid.Verify(pub, msg, forged)
|
|
if err == nil {
|
|
t.Fatal("a broken classical half still authenticated")
|
|
}
|
|
if !errors.Is(err, pqid.ErrClassicalFailed) {
|
|
t.Fatalf("got %v, want ErrClassicalFailed", err)
|
|
}
|
|
}
|
|
|
|
func TestSignatureSizeIsExact(t *testing.T) {
|
|
_, priv := newKey(t)
|
|
sig, err := pqid.Sign(priv, []byte("msg"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(sig) != pqid.SignatureSize {
|
|
t.Fatalf("signature is %d bytes, want %d", len(sig), pqid.SignatureSize)
|
|
}
|
|
}
|
|
|
|
func TestTruncatedSignatureRejected(t *testing.T) {
|
|
pub, priv := newKey(t)
|
|
sig, _ := pqid.Sign(priv, []byte("msg"))
|
|
|
|
for _, n := range []int{0, 64, pqid.SignatureSize - 1} {
|
|
if err := pqid.Verify(pub, []byte("msg"), sig[:n]); !errors.Is(err, pqid.ErrMalformedSignature) {
|
|
t.Fatalf("signature truncated to %d bytes gave %v", n, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPublicKeyRoundTrip(t *testing.T) {
|
|
pub, priv := newKey(t)
|
|
|
|
restored, err := pqid.ParsePublicKeyHex(pub.Hex())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// The restored key must verify signatures made by the original.
|
|
sig, _ := pqid.Sign(priv, []byte("msg"))
|
|
if err := pqid.Verify(restored, []byte("msg"), sig); err != nil {
|
|
t.Fatalf("round-tripped key failed to verify: %v", err)
|
|
}
|
|
if string(restored.ID()) != string(pub.ID()) {
|
|
t.Fatal("round-tripped key has a different ID")
|
|
}
|
|
}
|
|
|
|
func TestMalformedKeysRejected(t *testing.T) {
|
|
cases := map[string][]byte{
|
|
"empty": {},
|
|
"too short": make([]byte, pqid.PublicKeySize-1),
|
|
"too long": make([]byte, pqid.PublicKeySize+1),
|
|
}
|
|
for name, b := range cases {
|
|
if _, err := pqid.ParsePublicKey(b); !errors.Is(err, pqid.ErrMalformedKey) {
|
|
t.Errorf("%s: got %v, want ErrMalformedKey", name, err)
|
|
}
|
|
}
|
|
if _, err := pqid.ParsePublicKeyHex("nothex!!"); !errors.Is(err, pqid.ErrMalformedKey) {
|
|
t.Errorf("non-hex: got %v, want ErrMalformedKey", err)
|
|
}
|
|
}
|
|
|
|
func TestIDIsStableAndDistinct(t *testing.T) {
|
|
a, _ := newKey(t)
|
|
b, _ := newKey(t)
|
|
|
|
if string(a.ID()) != string(a.ID()) {
|
|
t.Fatal("ID is not stable across calls")
|
|
}
|
|
if string(a.ID()) == string(b.ID()) {
|
|
t.Fatal("two distinct keys produced the same ID")
|
|
}
|
|
if len(a.ID()) != pqid.IDSize {
|
|
t.Fatalf("ID is %d bytes, want %d", len(a.ID()), pqid.IDSize)
|
|
}
|
|
}
|
|
|
|
// Signatures must be bound to this application, so one captured from another
|
|
// ML-DSA protocol cannot be replayed here.
|
|
func TestSignaturesAreDomainSeparated(t *testing.T) {
|
|
pub, priv := newKey(t)
|
|
msg := []byte("msg")
|
|
sig, _ := pqid.Sign(priv, msg)
|
|
|
|
// Verifying with the correct context succeeds (covered above). Here we
|
|
// confirm the context is actually in use by checking that a signature made
|
|
// over the same message still fails if the ML-DSA half is swapped for one
|
|
// generated under a different context.
|
|
other := make([]byte, pqid.PQSignatureSize)
|
|
if err := signWithContext(priv, msg, []byte("some-other-protocol"), other); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
forged := append(append([]byte(nil), sig[:pqid.EdSignatureSize]...), other...)
|
|
if err := pqid.Verify(pub, msg, forged); !errors.Is(err, pqid.ErrPostQuantumFailed) {
|
|
t.Fatalf("signature from another context was accepted: %v", err)
|
|
}
|
|
}
|
|
|
|
func BenchmarkSign(b *testing.B) {
|
|
_, priv, _ := pqid.GenerateKey(rand.Reader)
|
|
msg := []byte("benchmark message")
|
|
b.ResetTimer()
|
|
for i := 0; i < b.N; i++ {
|
|
if _, err := pqid.Sign(priv, msg); err != nil {
|
|
b.Fatal(err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func BenchmarkVerify(b *testing.B) {
|
|
pub, priv, _ := pqid.GenerateKey(rand.Reader)
|
|
msg := []byte("benchmark message")
|
|
sig, _ := pqid.Sign(priv, msg)
|
|
b.ResetTimer()
|
|
for i := 0; i < b.N; i++ {
|
|
if err := pqid.Verify(pub, msg, sig); err != nil {
|
|
b.Fatal(err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func BenchmarkGenerateKey(b *testing.B) {
|
|
for i := 0; i < b.N; i++ {
|
|
if _, _, err := pqid.GenerateKey(rand.Reader); err != nil {
|
|
b.Fatal(err)
|
|
}
|
|
}
|
|
}
|