// 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 } // PrivateFromBytes reconstructs a private key from stored material. // // The Ed25519 half is stored as its 32-byte seed rather than the expanded // key, because the seed is the canonical form and cannot encode an // inconsistent pair. The ML-DSA half is stored in its own binary encoding. func PrivateFromBytes(edSeed, pqKey []byte) (*PrivateKey, error) { if len(edSeed) != ed25519.SeedSize { return nil, fmt.Errorf("%w: ed25519 seed is %d bytes, want %d", ErrMalformedKey, len(edSeed), ed25519.SeedSize) } var pq mldsa65.PrivateKey if err := pq.UnmarshalBinary(pqKey); err != nil { return nil, fmt.Errorf("%w: ML-DSA private key: %v", ErrMalformedKey, err) } return &PrivateKey{ Ed: ed25519.NewKeyFromSeed(edSeed), PQ: &pq, }, nil } // PublicFromPrivate derives the public half. // // Deriving rather than storing means a client cannot present a public key that // does not match the key it signs with — a mismatch that would otherwise only // surface as a confusing authentication failure. func PublicFromPrivate(priv *PrivateKey) (*PublicKey, error) { edPub, ok := priv.Ed.Public().(ed25519.PublicKey) if !ok { return nil, fmt.Errorf("%w: ed25519 private key has no public half", ErrMalformedKey) } pqPub, ok := priv.PQ.Public().(*mldsa65.PublicKey) if !ok { return nil, fmt.Errorf("%w: ML-DSA private key has no public half", ErrMalformedKey) } return &PublicKey{Ed: edPub, PQ: pqPub}, nil }