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) } } } // A key stored by the browser and restored on the next visit must produce // signatures the server still accepts. func TestPrivateKeyRoundTripThroughStorage(t *testing.T) { pub, priv := newKey(t) // What the browser would persist. edSeed := priv.Ed.Seed() pqBytes, err := priv.PQ.MarshalBinary() if err != nil { t.Fatal(err) } restored, err := pqid.PrivateFromBytes(edSeed, pqBytes) if err != nil { t.Fatal(err) } msg := []byte("a challenge issued after the page reloaded") sig, err := pqid.Sign(restored, msg) if err != nil { t.Fatal(err) } if err := pqid.Verify(pub, msg, sig); err != nil { t.Fatalf("a restored key produced a signature the original public key rejects: %v", err) } } // The public key must be derivable, so a client cannot present one that does // not match what it signs with. func TestPublicKeyDerivesFromPrivate(t *testing.T) { pub, priv := newKey(t) derived, err := pqid.PublicFromPrivate(priv) if err != nil { t.Fatal(err) } if derived.Hex() != pub.Hex() { t.Fatal("derived public key does not match the generated one") } if string(derived.ID()) != string(pub.ID()) { t.Fatal("derived public key has a different account id") } } func TestMalformedStoredKeysRejected(t *testing.T) { _, priv := newKey(t) pqBytes, _ := priv.PQ.MarshalBinary() if _, err := pqid.PrivateFromBytes([]byte("short"), pqBytes); !errors.Is(err, pqid.ErrMalformedKey) { t.Errorf("short ed seed gave %v, want ErrMalformedKey", err) } if _, err := pqid.PrivateFromBytes(priv.Ed.Seed(), []byte("nonsense")); !errors.Is(err, pqid.ErrMalformedKey) { t.Errorf("bad pq key gave %v, want ErrMalformedKey", err) } }