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
}