package game // Login regressions for the off-owner password path: the production entry // point verifies passwords and hashes new-account passwords on the // connection goroutine (never the owner), then completes the state // transition on the owner. The observable login behavior is unchanged. import ( "crypto/sha256" "encoding/base64" "strings" "testing" "time" "thehouseoficarus/internal/net" "thehouseoficarus/internal/player" ) // waitForOutput polls the queued session writer until substr appears or the // deadline passes. func waitForOutput(t *testing.T, conn *chanConn, substr string) { t.Helper() deadline := time.Now().Add(3 * time.Second) for !conn.Contains(substr) && time.Now().Before(deadline) { time.Sleep(time.Millisecond) } if !conn.Contains(substr) { t.Fatalf("output %q never arrived; got:\n%s", substr, conn.Bytes()) } } // TestLoginThroughServerEntryUsesOffOwnerPathAndUpgradesLegacyHash drives // the real server entry point (HandleSessionOnOwner) through: legacy-hash // account creation, a WRONG password attempt, and a correct login that // rehashes the legacy SHA-256 hash to Argon2id durably. func TestLoginThroughServerEntryUsesOffOwnerPathAndUpgradesLegacyHash(t *testing.T) { h := newHarness(t) h.seedAccount("alice") h.seedCharacter("alice", "alice") acc, err := h.g.AccountStore.LoadAccount("alice") if err != nil { t.Fatal(err) } // Downgrade to a legacy salted SHA-256 hash so the login exercises the // transparent rehash. salt := []byte("0123456789abcdef") sum := sha256.Sum256(append(append([]byte{}, salt...), []byte("pw")...)) acc.PasswordHash = "sha256:" + base64.RawStdEncoding.EncodeToString(salt) + ":" + strings.ToLower(sha256HashHex(sum)) if err := h.g.AccountStore.SaveAccount(acc); err != nil { t.Fatal(err) } conn := newChanConn() sess := &net.Session{Conn: conn} sess.SetState(net.StateAccountName) sess.StartWriter() lines := make(chan string, 8) go h.input(sess, lines) lines <- "alice" // existing account → password prompt (echo off) lines <- "wrongpw" // rejected, echo restored, back to account name waitForOutput(t, conn, "Wrong password.") lines <- "alice" // account name again lines <- "pw" // correct password through the off-owner path waitForOutput(t, conn, "Welcome back to Vesta, alice.") if sess.Account == nil || sess.Account.Name != "alice" || len(sess.Account.Characters) != 1 { t.Fatalf("login did not complete: %+v", sess.Account) } upgraded, err := h.g.AccountStore.LoadAccount("alice") if err != nil { t.Fatal(err) } if !strings.HasPrefix(upgraded.PasswordHash, "argon2id:") { t.Errorf("legacy hash not upgraded on login: %s", upgraded.PasswordHash[:12]) } if !player.CheckPassword("pw", upgraded.PasswordHash) { t.Error("upgraded hash no longer verifies the password") } } // TestNewAccountThroughServerEntryHashesOffOwner drives account creation // through the real server entry point. func TestNewAccountThroughServerEntryHashesOffOwner(t *testing.T) { h := newHarness(t) conn := newChanConn() sess := &net.Session{Conn: conn} sess.SetState(net.StateAccountName) sess.StartWriter() lines := make(chan string, 8) go h.input(sess, lines) lines <- "newbie" // unknown name → new-account password prompt lines <- "hunter2pass" lines <- "hunter2pw" // mismatch → restart account entry (Account reset) waitForOutput(t, conn, "Passwords do not match.") lines <- "newbie" // account name again lines <- "hunter2pw" lines <- "hunter2pw" lines <- "n" // color prompt waitForOutput(t, conn, "Welcome back to Vesta, newbie.") created, err := h.g.AccountStore.LoadAccount("newbie") if err != nil { t.Fatal(err) } if !strings.HasPrefix(created.PasswordHash, "argon2id:") { t.Errorf("created account did not hash with Argon2id: %s", created.PasswordHash[:12]) } if !player.CheckPassword("hunter2pw", created.PasswordHash) { t.Error("created hash does not verify the password") } } // sha256HashHex formats like hex.EncodeToString for the legacy hash check. func sha256HashHex(sum [32]byte) string { const hexdigits = "0123456789abcdef" out := make([]byte, 64) for i, b := range sum { out[i*2] = hexdigits[b>>4] out[i*2+1] = hexdigits[b&0x0f] } return string(out) }