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:
116
pkg/identity/identity.go
Normal file
116
pkg/identity/identity.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user