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>
172 lines
5.8 KiB
Go
172 lines
5.8 KiB
Go
// 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
|
|
}
|