// 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 }