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:
12
pkg/pqid/export_test.go
Normal file
12
pkg/pqid/export_test.go
Normal 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
171
pkg/pqid/pqid.go
Normal 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
210
pkg/pqid/pqid_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user