feat(pqid): hybrid post-quantum identity, AGPL-3.0 license

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>
This commit is contained in:
drjones
2026-08-05 22:17:54 +00:00
parent 48a9120fe4
commit 5210266fd6
6 changed files with 1064 additions and 2 deletions

12
pkg/pqid/export_test.go Normal file
View File

@@ -0,0 +1,12 @@
package pqid_test
import (
"github.com/cloudflare/circl/sign/mldsa/mldsa65"
"github.com/drjones/quantum-arcade/pkg/pqid"
)
// signWithContext produces an ML-DSA signature under an arbitrary context, so
// the domain-separation test can prove the production context is enforced.
func signWithContext(priv *pqid.PrivateKey, msg, ctx, out []byte) error {
return mldsa65.SignTo(priv.PQ, msg, ctx, false, out)
}

171
pkg/pqid/pqid.go Normal file
View File

@@ -0,0 +1,171 @@
// Package pqid implements hybrid post-quantum player identity.
//
// A player's key is two keys: classical Ed25519 and post-quantum ML-DSA-65
// (NIST FIPS 204). Both signatures are required for every authentication, so
// the identity holds if *either* algorithm survives:
//
// - Ed25519 alone falls to Shor's algorithm on a sufficiently large quantum
// computer.
// - ML-DSA is young. Lattice cryptanalysis is an active field, and betting
// everything on a 2024 standard would be its own kind of naive.
//
// Requiring both is the composition NIST and the IETF recommend: an attacker
// must break lattice assumptions *and* elliptic curves, not either one.
//
// What this does and does not protect:
//
// protected player identity, session authentication, bet authorisation
// protected round fairness — commit-reveal is SHA-256/HMAC, and Grover
// only halves the security margin, leaving ~128 bits
// NOT protected Bitcoin and Lightning settlement, which sign with secp256k1.
// No application-layer choice can change that.
package pqid
import (
"crypto/ed25519"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"github.com/cloudflare/circl/sign/mldsa/mldsa65"
)
// Sizes of the wire encodings.
const (
EdPublicKeySize = ed25519.PublicKeySize // 32
EdSignatureSize = ed25519.SignatureSize // 64
PQPublicKeySize = mldsa65.PublicKeySize // 1952
PQSignatureSize = mldsa65.SignatureSize // 3309
PublicKeySize = EdPublicKeySize + PQPublicKeySize
SignatureSize = EdSignatureSize + PQSignatureSize
// IDSize is the length of the account identifier derived from a key.
IDSize = sha256.Size
)
// context binds signatures to this application, so a signature produced for
// some other ML-DSA protocol cannot be replayed here.
var context = []byte("quantum-arcade/v1")
var (
ErrMalformedKey = errors.New("pqid: malformed public key")
ErrMalformedSignature = errors.New("pqid: malformed signature")
ErrClassicalFailed = errors.New("pqid: ed25519 signature did not verify")
ErrPostQuantumFailed = errors.New("pqid: ML-DSA signature did not verify")
)
// PublicKey is a player's hybrid identity.
type PublicKey struct {
Ed ed25519.PublicKey
PQ *mldsa65.PublicKey
}
// PrivateKey is the matching secret half. Clients hold this; the server never
// sees it.
type PrivateKey struct {
Ed ed25519.PrivateKey
PQ *mldsa65.PrivateKey
}
// GenerateKey creates a hybrid keypair.
func GenerateKey(rand io.Reader) (*PublicKey, *PrivateKey, error) {
edPub, edPriv, err := ed25519.GenerateKey(rand)
if err != nil {
return nil, nil, fmt.Errorf("pqid: generating ed25519 key: %w", err)
}
pqPub, pqPriv, err := mldsa65.GenerateKey(rand)
if err != nil {
return nil, nil, fmt.Errorf("pqid: generating ML-DSA key: %w", err)
}
return &PublicKey{Ed: edPub, PQ: pqPub}, &PrivateKey{Ed: edPriv, PQ: pqPriv}, nil
}
// Bytes encodes the public key as ed25519 || ML-DSA.
func (p *PublicKey) Bytes() []byte {
out := make([]byte, 0, PublicKeySize)
out = append(out, p.Ed...)
pq, err := p.PQ.MarshalBinary()
if err != nil {
// MarshalBinary on a valid key cannot fail; a failure here means the
// key is corrupt, and silently returning a short key would be worse.
panic("pqid: marshalling ML-DSA public key: " + err.Error())
}
return append(out, pq...)
}
// Hex renders the public key for transport.
func (p *PublicKey) Hex() string { return hex.EncodeToString(p.Bytes()) }
// ID is the account identifier: SHA-256 over the whole hybrid key.
//
// The ledger keys on this rather than the raw key because the hybrid key is
// nearly 2KB, and a fixed 32-byte identifier keeps indexes small. Hashing also
// means the identifier is stable in length no matter how the key evolves.
func (p *PublicKey) ID() []byte {
sum := sha256.Sum256(p.Bytes())
return sum[:]
}
// ParsePublicKey decodes a hybrid public key from its wire encoding.
func ParsePublicKey(b []byte) (*PublicKey, error) {
if len(b) != PublicKeySize {
return nil, fmt.Errorf("%w: got %d bytes, want %d",
ErrMalformedKey, len(b), PublicKeySize)
}
ed := ed25519.PublicKey(append([]byte(nil), b[:EdPublicKeySize]...))
var pq mldsa65.PublicKey
if err := pq.UnmarshalBinary(b[EdPublicKeySize:]); err != nil {
return nil, fmt.Errorf("%w: %v", ErrMalformedKey, err)
}
return &PublicKey{Ed: ed, PQ: &pq}, nil
}
// ParsePublicKeyHex decodes a hex-encoded hybrid public key.
func ParsePublicKeyHex(s string) (*PublicKey, error) {
b, err := hex.DecodeString(s)
if err != nil {
return nil, fmt.Errorf("%w: not hex", ErrMalformedKey)
}
return ParsePublicKey(b)
}
// Sign produces both signatures over the message, concatenated.
func Sign(priv *PrivateKey, msg []byte) ([]byte, error) {
edSig := ed25519.Sign(priv.Ed, msg)
pqSig := make([]byte, PQSignatureSize)
// randomized=false gives deterministic (hedged) signatures, so a bad RNG
// on a phone cannot leak the key through signature randomness.
if err := mldsa65.SignTo(priv.PQ, msg, context, false, pqSig); err != nil {
return nil, fmt.Errorf("pqid: ML-DSA signing: %w", err)
}
out := make([]byte, 0, SignatureSize)
out = append(out, edSig...)
return append(out, pqSig...), nil
}
// Verify checks both signatures. Both must pass.
//
// It deliberately reports which half failed: a mismatch between the two is
// diagnostic — it means a client is half-upgraded or something is tampering
// with one algorithm — and that is worth surfacing rather than flattening into
// a generic failure. Nothing secret is revealed by saying which one broke.
func Verify(pub *PublicKey, msg, sig []byte) error {
if len(sig) != SignatureSize {
return fmt.Errorf("%w: got %d bytes, want %d",
ErrMalformedSignature, len(sig), SignatureSize)
}
if !ed25519.Verify(pub.Ed, msg, sig[:EdSignatureSize]) {
return ErrClassicalFailed
}
if !mldsa65.Verify(pub.PQ, msg, context, sig[EdSignatureSize:]) {
return ErrPostQuantumFailed
}
return nil
}

210
pkg/pqid/pqid_test.go Normal file
View File

@@ -0,0 +1,210 @@
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)
}
}
}