feat: playable arcade — rooms, identity, client, deployment

Round length is now bounded: the multiplier follows a hyperbolic curve
diverging at 60s, replacing an exponential one where a 275x crash point
produced a two-and-a-half minute round.

Fixes seed reveal, which silently failed every round because pgx cannot
encode a fixed-size byte array as bytea.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
drjones
2026-08-05 15:34:52 +00:00
parent 41c1bb2fdf
commit 2a2a1db8de
17 changed files with 2885 additions and 25 deletions

116
pkg/identity/identity.go Normal file
View File

@@ -0,0 +1,116 @@
// Package identity implements keypair-based sign-in.
//
// There are no accounts in the usual sense: a player's ed25519 public key is
// their identity. To prove ownership they sign a server-issued challenge, which
// is single-use and short-lived. There is no password to leak, no email to
// verify, and nothing to reset — losing the key loses the balance, which is
// stated plainly in the interface.
package identity
import (
"crypto/ed25519"
"crypto/rand"
"encoding/hex"
"errors"
"sync"
"time"
)
var (
ErrUnknownChallenge = errors.New("identity: challenge not found or expired")
ErrBadSignature = errors.New("identity: signature does not verify")
ErrBadPublicKey = errors.New("identity: malformed public key")
)
// ChallengeTTL is how long a challenge stays valid. Short, because a client
// signs it immediately.
const ChallengeTTL = 2 * time.Minute
type challenge struct {
nonce [32]byte
expires time.Time
}
// Authenticator issues and verifies sign-in challenges.
type Authenticator struct {
mu sync.Mutex
challenges map[string]challenge
now func() time.Time
}
func NewAuthenticator() *Authenticator {
return &Authenticator{
challenges: make(map[string]challenge),
now: time.Now,
}
}
// Challenge issues a fresh nonce for a public key to sign.
func (a *Authenticator) Challenge(pubkeyHex string) (string, error) {
pk, err := ParsePublicKey(pubkeyHex)
if err != nil {
return "", err
}
var n [32]byte
if _, err := rand.Read(n[:]); err != nil {
panic("identity: system randomness unavailable: " + err.Error())
}
a.mu.Lock()
defer a.mu.Unlock()
a.sweepLocked()
a.challenges[hex.EncodeToString(pk)] = challenge{
nonce: n,
expires: a.now().Add(ChallengeTTL),
}
return hex.EncodeToString(n[:]), nil
}
// Verify checks a signature over the outstanding challenge for that key and
// consumes it, so a captured signature cannot be replayed.
func (a *Authenticator) Verify(pubkeyHex, signatureHex string) error {
pk, err := ParsePublicKey(pubkeyHex)
if err != nil {
return err
}
sig, err := hex.DecodeString(signatureHex)
if err != nil || len(sig) != ed25519.SignatureSize {
return ErrBadSignature
}
a.mu.Lock()
key := hex.EncodeToString(pk)
c, ok := a.challenges[key]
if ok {
delete(a.challenges, key) // single use, whether or not it verifies
}
now := a.now()
a.mu.Unlock()
if !ok || now.After(c.expires) {
return ErrUnknownChallenge
}
if !ed25519.Verify(pk, c.nonce[:], sig) {
return ErrBadSignature
}
return nil
}
// sweepLocked drops expired challenges. Called under the mutex.
func (a *Authenticator) sweepLocked() {
now := a.now()
for k, c := range a.challenges {
if now.After(c.expires) {
delete(a.challenges, k)
}
}
}
// ParsePublicKey decodes and validates a hex-encoded ed25519 public key.
func ParsePublicKey(s string) (ed25519.PublicKey, error) {
b, err := hex.DecodeString(s)
if err != nil || len(b) != ed25519.PublicKeySize {
return nil, ErrBadPublicKey
}
return ed25519.PublicKey(b), nil
}

View File

@@ -0,0 +1,86 @@
package identity_test
import (
"crypto/ed25519"
"crypto/rand"
"encoding/hex"
"errors"
"testing"
"github.com/drjones/quantum-arcade/pkg/identity"
)
func newKey(t *testing.T) (string, ed25519.PrivateKey) {
t.Helper()
pub, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
return hex.EncodeToString(pub), priv
}
func TestValidSignatureAuthenticates(t *testing.T) {
a := identity.NewAuthenticator()
pubHex, priv := newKey(t)
nonceHex, err := a.Challenge(pubHex)
if err != nil {
t.Fatal(err)
}
nonce, _ := hex.DecodeString(nonceHex)
sig := ed25519.Sign(priv, nonce)
if err := a.Verify(pubHex, hex.EncodeToString(sig)); err != nil {
t.Fatalf("valid signature rejected: %v", err)
}
}
func TestWrongKeyCannotAuthenticate(t *testing.T) {
a := identity.NewAuthenticator()
pubHex, _ := newKey(t)
_, otherPriv := newKey(t)
nonceHex, _ := a.Challenge(pubHex)
nonce, _ := hex.DecodeString(nonceHex)
sig := ed25519.Sign(otherPriv, nonce)
if err := a.Verify(pubHex, hex.EncodeToString(sig)); !errors.Is(err, identity.ErrBadSignature) {
t.Fatalf("got %v, want ErrBadSignature", err)
}
}
// A captured signature must not work twice.
func TestChallengeIsSingleUse(t *testing.T) {
a := identity.NewAuthenticator()
pubHex, priv := newKey(t)
nonceHex, _ := a.Challenge(pubHex)
nonce, _ := hex.DecodeString(nonceHex)
sigHex := hex.EncodeToString(ed25519.Sign(priv, nonce))
if err := a.Verify(pubHex, sigHex); err != nil {
t.Fatal(err)
}
if err := a.Verify(pubHex, sigHex); !errors.Is(err, identity.ErrUnknownChallenge) {
t.Fatalf("replay succeeded or gave %v, want ErrUnknownChallenge", err)
}
}
func TestMalformedKeyRejected(t *testing.T) {
a := identity.NewAuthenticator()
if _, err := a.Challenge("not-hex"); !errors.Is(err, identity.ErrBadPublicKey) {
t.Fatalf("got %v, want ErrBadPublicKey", err)
}
if _, err := a.Challenge("aabb"); !errors.Is(err, identity.ErrBadPublicKey) {
t.Fatalf("short key: got %v, want ErrBadPublicKey", err)
}
}
func TestVerifyWithoutChallengeFails(t *testing.T) {
a := identity.NewAuthenticator()
pubHex, priv := newKey(t)
sig := ed25519.Sign(priv, []byte("anything"))
if err := a.Verify(pubHex, hex.EncodeToString(sig)); !errors.Is(err, identity.ErrUnknownChallenge) {
t.Fatalf("got %v, want ErrUnknownChallenge", err)
}
}