diff --git a/host.go b/host.go index bc80011..e5e85dd 100644 --- a/host.go +++ b/host.go @@ -84,6 +84,8 @@ type attempt struct { connectionID uint64 // outcome is set once a game outcome has been reported. outcome atomic.Bool + // key is the admission key of the login until its identity is verified. + key atomic.Pointer[admission.Key] } func newHost(addr string, maxSessions int, log *slog.Logger) (*host, error) { @@ -292,6 +294,7 @@ func (h *host) establish(a *attempt, adm nethernet.Admission, password string) { h.changed() <-conn.Context().Done() + a.key.Store(nil) h.mu.Lock() delete(h.sessions, a.connectionID) delete(h.tuples, a.tuple) @@ -358,7 +361,11 @@ func (h *host) gameOutcome(addr net.Addr, stage, reason string) { h.mu.Lock() a, ok := h.conns[na.ConnectionID] h.mu.Unlock() - if ok && !a.outcome.Swap(true) { + if !ok { + return + } + a.key.Store(nil) + if !a.outcome.Swap(true) { h.emit(a, stage, reason, nil) } } @@ -366,6 +373,7 @@ func (h *host) gameOutcome(addr net.Addr, stage, reason string) { func (h *host) fail(a *attempt, reason string, err error) { h.log.Debug("admission failed", "reason", reason, "addr", a.tuple, "error", err) h.emit(a, "ticket.failed", reason, nil) + a.key.Store(nil) h.mu.Lock() h.pending-- delete(h.tuples, a.tuple) @@ -382,16 +390,22 @@ func (h *host) emit(a *attempt, stage, reason string, remote *netip.AddrPort) { // verifier returns a one-shot check of the login identity key against the admission. func (h *host) verifier(a *attempt, key admission.Key, claims admission.Claims) func(*ecdsa.PublicKey) error { - deadline := time.Now().Add(time.Until(claims.ExpiresAt)) + ttl := time.Until(claims.ExpiresAt) + deadline := time.Now().Add(ttl) + a.key.Store(&key) + time.AfterFunc(ttl, func() { a.key.Store(nil) }) var used atomic.Bool return func(pub *ecdsa.PublicKey) error { if used.Swap(true) { return errors.New("nxs: admission identity already verified") } + k := a.key.Swap(nil) var err error - if time.Now().After(deadline) { + if k == nil { + err = errors.New("nxs: admission no longer pending") + } else if time.Now().After(deadline) { err = errors.New("nxs: admission expired before login") - } else if !key.VerifyBinding(h.audience, pub, claims.IdentityBinding) { + } else if !k.VerifyBinding(h.audience, pub, claims.IdentityBinding) { err = errors.New("nxs: login identity does not match admission") } if err != nil && h.gameOutcomes && !a.outcome.Swap(true) { diff --git a/host_test.go b/host_test.go new file mode 100644 index 0000000..08903a3 --- /dev/null +++ b/host_test.go @@ -0,0 +1,61 @@ +package nxs + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "testing" + "time" + + "github.com/df-mc/go-nethernet" + "github.com/df-mc/go-nxs/admission" +) + +func TestVerifierReleasesKey(t *testing.T) { + h := &host{log: testLog(), report: func(event) {}, conns: make(map[uint64]*attempt), gameOutcomes: true, + audience: admission.Audience("00000000000000000000000000000000")} + key := admission.Key{ID: "K001", Secret: "fake-provider-secret-at-least-32-bytes"} + identity, _ := ecdsa.GenerateKey(elliptic.P384(), rand.Reader) + binding, _ := key.Binding(h.audience, &identity.PublicKey) + claims := admission.Claims{ExpiresAt: time.Now().Add(time.Minute), IdentityBinding: binding} + + a := &attempt{} + verify := h.verifier(a, key, claims) + if err := verify(&identity.PublicKey); err != nil { + t.Fatalf("verify: %v", err) + } + if a.key.Load() != nil { + t.Fatal("a verified login should release its key") + } + if verify(&identity.PublicKey) == nil { + t.Fatal("verify should be one-shot") + } + + a = &attempt{} + other, _ := ecdsa.GenerateKey(elliptic.P384(), rand.Reader) + if h.verifier(a, key, claims)(&other.PublicKey) == nil || a.key.Load() != nil { + t.Fatal("a mismatched login should be rejected and release its key") + } + + a = &attempt{} + verify = h.verifier(a, key, admission.Claims{ExpiresAt: time.Now().Add(100 * time.Millisecond), IdentityBinding: binding}) + waitFor(t, func() bool { return a.key.Load() == nil }) + if verify(&identity.PublicKey) == nil { + t.Fatal("an expired login should be rejected") + } + + a = &attempt{} + verify = h.verifier(a, key, claims) + h.fail(a, "closed", nil) + if a.key.Load() != nil || verify(&identity.PublicKey) == nil { + t.Fatal("a failed admission should release its key") + } + + a = &attempt{connectionID: 1} + verify = h.verifier(a, key, claims) + h.conns[1] = a + h.gameOutcome(&nethernet.Addr{ConnectionID: 1}, "ticket.game_rejected", "banned") + if a.key.Load() != nil || verify(&identity.PublicKey) == nil { + t.Fatal("a rejected login should release its key") + } +}