Files
casino/pkg/pqid/pqid_test.go
drjones b0f07f63ff feat(pqsign): WASM signer for browser-side post-quantum identity
WebCrypto has Ed25519 but no ML-DSA, so the post-quantum half is
compiled from the same pkg/pqid the server verifies with. One
implementation of the scheme in the project means a client and server
cannot disagree about signing.

The Ed25519 half is stored as its 32-byte seed rather than the expanded
key, since the seed cannot encode an inconsistent pair, and the public
key is derived rather than stored so a client cannot present one that
does not match what it signs with.

Verified end to end in a JS runtime: 1984-byte public key, 3373-byte
signature, derived key matches, malformed input returns an error rather
than crashing the module. 3.4MB, 0.9MB gzipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 04:10:51 +00:00

267 lines
7.4 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)
}
}
}
// 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)
}
}