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>
117 lines
3.0 KiB
Go
117 lines
3.0 KiB
Go
// 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
|
|
}
|