package auth import ( "encoding/json" "fmt" "strings" "sync" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "forge.lthn.ai/core/go-crypt/crypt/lthn" "forge.lthn.ai/core/go-crypt/crypt/pgp" "forge.lthn.ai/core/go/pkg/io" ) // helper creates a fresh Authenticator backed by MockMedium. func newTestAuth(opts ...Option) (*Authenticator, *io.MockMedium) { m := io.NewMockMedium() a := New(m, opts...) return a, m } // --- Register --- func TestRegister_Good(t *testing.T) { a, m := newTestAuth() user, err := a.Register("alice", "hunter2") require.NoError(t, err) require.NotNil(t, user) userID := lthn.Hash("alice") // Verify public key is stored assert.True(t, m.IsFile(userPath(userID, ".pub"))) assert.True(t, m.IsFile(userPath(userID, ".key"))) assert.True(t, m.IsFile(userPath(userID, ".rev"))) assert.True(t, m.IsFile(userPath(userID, ".json"))) assert.True(t, m.IsFile(userPath(userID, ".lthn"))) // Verify user fields assert.NotEmpty(t, user.PublicKey) assert.Equal(t, userID, user.KeyID) assert.NotEmpty(t, user.Fingerprint) assert.Equal(t, lthn.Hash("hunter2"), user.PasswordHash) assert.False(t, user.Created.IsZero()) } func TestRegister_Bad(t *testing.T) { a, _ := newTestAuth() // Register first time succeeds _, err := a.Register("bob", "pass1") require.NoError(t, err) // Duplicate registration should fail _, err = a.Register("bob", "pass2") assert.Error(t, err) assert.Contains(t, err.Error(), "user already exists") } func TestRegister_Ugly(t *testing.T) { a, _ := newTestAuth() // Empty username/password should still work (PGP allows it) user, err := a.Register("", "") require.NoError(t, err) require.NotNil(t, user) } // --- CreateChallenge --- func TestCreateChallenge_Good(t *testing.T) { a, _ := newTestAuth() user, err := a.Register("charlie", "pass") require.NoError(t, err) challenge, err := a.CreateChallenge(user.KeyID) require.NoError(t, err) require.NotNil(t, challenge) assert.Len(t, challenge.Nonce, nonceBytes) assert.NotEmpty(t, challenge.Encrypted) assert.True(t, challenge.ExpiresAt.After(time.Now())) } func TestCreateChallenge_Bad(t *testing.T) { a, _ := newTestAuth() // Challenge for non-existent user _, err := a.CreateChallenge("nonexistent-user-id") assert.Error(t, err) assert.Contains(t, err.Error(), "user not found") } func TestCreateChallenge_Ugly(t *testing.T) { a, _ := newTestAuth() // Empty userID _, err := a.CreateChallenge("") assert.Error(t, err) } // --- ValidateResponse (full challenge-response flow) --- func TestValidateResponse_Good(t *testing.T) { a, m := newTestAuth() // Register user _, err := a.Register("dave", "password123") require.NoError(t, err) userID := lthn.Hash("dave") // Create challenge challenge, err := a.CreateChallenge(userID) require.NoError(t, err) // Client-side: decrypt nonce, then sign it privKey, err := m.Read(userPath(userID, ".key")) require.NoError(t, err) decryptedNonce, err := pgp.Decrypt([]byte(challenge.Encrypted), privKey, "password123") require.NoError(t, err) assert.Equal(t, challenge.Nonce, decryptedNonce) signedNonce, err := pgp.Sign(decryptedNonce, privKey, "password123") require.NoError(t, err) // Validate response session, err := a.ValidateResponse(userID, signedNonce) require.NoError(t, err) require.NotNil(t, session) assert.NotEmpty(t, session.Token) assert.Equal(t, userID, session.UserID) assert.True(t, session.ExpiresAt.After(time.Now())) } func TestValidateResponse_Bad(t *testing.T) { a, _ := newTestAuth() _, err := a.Register("eve", "pass") require.NoError(t, err) userID := lthn.Hash("eve") // No pending challenge _, err = a.ValidateResponse(userID, []byte("fake-signature")) assert.Error(t, err) assert.Contains(t, err.Error(), "no pending challenge") } func TestValidateResponse_Ugly(t *testing.T) { a, m := newTestAuth(WithChallengeTTL(1 * time.Millisecond)) _, err := a.Register("frank", "pass") require.NoError(t, err) userID := lthn.Hash("frank") // Create challenge and let it expire challenge, err := a.CreateChallenge(userID) require.NoError(t, err) time.Sleep(5 * time.Millisecond) // Sign with valid key but expired challenge privKey, err := m.Read(userPath(userID, ".key")) require.NoError(t, err) signedNonce, err := pgp.Sign(challenge.Nonce, privKey, "pass") require.NoError(t, err) _, err = a.ValidateResponse(userID, signedNonce) assert.Error(t, err) assert.Contains(t, err.Error(), "challenge expired") } // --- ValidateSession --- func TestValidateSession_Good(t *testing.T) { a, _ := newTestAuth() _, err := a.Register("grace", "pass") require.NoError(t, err) userID := lthn.Hash("grace") session, err := a.Login(userID, "pass") require.NoError(t, err) validated, err := a.ValidateSession(session.Token) require.NoError(t, err) assert.Equal(t, session.Token, validated.Token) assert.Equal(t, userID, validated.UserID) } func TestValidateSession_Bad(t *testing.T) { a, _ := newTestAuth() _, err := a.ValidateSession("nonexistent-token") assert.Error(t, err) assert.Contains(t, err.Error(), "session not found") } func TestValidateSession_Ugly(t *testing.T) { a, _ := newTestAuth(WithSessionTTL(1 * time.Millisecond)) _, err := a.Register("heidi", "pass") require.NoError(t, err) userID := lthn.Hash("heidi") session, err := a.Login(userID, "pass") require.NoError(t, err) time.Sleep(5 * time.Millisecond) _, err = a.ValidateSession(session.Token) assert.Error(t, err) assert.Contains(t, err.Error(), "session expired") } // --- RefreshSession --- func TestRefreshSession_Good(t *testing.T) { a, _ := newTestAuth(WithSessionTTL(1 * time.Hour)) _, err := a.Register("ivan", "pass") require.NoError(t, err) userID := lthn.Hash("ivan") session, err := a.Login(userID, "pass") require.NoError(t, err) originalExpiry := session.ExpiresAt // Small delay to ensure time moves forward time.Sleep(2 * time.Millisecond) refreshed, err := a.RefreshSession(session.Token) require.NoError(t, err) assert.True(t, refreshed.ExpiresAt.After(originalExpiry)) } func TestRefreshSession_Bad(t *testing.T) { a, _ := newTestAuth() _, err := a.RefreshSession("nonexistent-token") assert.Error(t, err) assert.Contains(t, err.Error(), "session not found") } func TestRefreshSession_Ugly(t *testing.T) { a, _ := newTestAuth(WithSessionTTL(1 * time.Millisecond)) _, err := a.Register("judy", "pass") require.NoError(t, err) userID := lthn.Hash("judy") session, err := a.Login(userID, "pass") require.NoError(t, err) time.Sleep(5 * time.Millisecond) _, err = a.RefreshSession(session.Token) assert.Error(t, err) assert.Contains(t, err.Error(), "session expired") } // --- RevokeSession --- func TestRevokeSession_Good(t *testing.T) { a, _ := newTestAuth() _, err := a.Register("karl", "pass") require.NoError(t, err) userID := lthn.Hash("karl") session, err := a.Login(userID, "pass") require.NoError(t, err) err = a.RevokeSession(session.Token) require.NoError(t, err) // Token should no longer be valid _, err = a.ValidateSession(session.Token) assert.Error(t, err) } func TestRevokeSession_Bad(t *testing.T) { a, _ := newTestAuth() err := a.RevokeSession("nonexistent-token") assert.Error(t, err) assert.Contains(t, err.Error(), "session not found") } func TestRevokeSession_Ugly(t *testing.T) { a, _ := newTestAuth() // Revoke empty token err := a.RevokeSession("") assert.Error(t, err) } // --- DeleteUser --- func TestDeleteUser_Good(t *testing.T) { a, m := newTestAuth() _, err := a.Register("larry", "pass") require.NoError(t, err) userID := lthn.Hash("larry") // Also create a session that should be cleaned up session, err := a.Login(userID, "pass") require.NoError(t, err) err = a.DeleteUser(userID) require.NoError(t, err) // All files should be gone assert.False(t, m.IsFile(userPath(userID, ".pub"))) assert.False(t, m.IsFile(userPath(userID, ".key"))) assert.False(t, m.IsFile(userPath(userID, ".rev"))) assert.False(t, m.IsFile(userPath(userID, ".json"))) assert.False(t, m.IsFile(userPath(userID, ".lthn"))) // Session should be gone (validate returns error) _, err = a.ValidateSession(session.Token) assert.Error(t, err) assert.Contains(t, err.Error(), "session not found") } func TestDeleteUser_Bad(t *testing.T) { a, _ := newTestAuth() // Protected user "server" cannot be deleted err := a.DeleteUser("server") assert.Error(t, err) assert.Contains(t, err.Error(), "cannot delete protected user") } func TestDeleteUser_Ugly(t *testing.T) { a, _ := newTestAuth() // Non-existent user err := a.DeleteUser("nonexistent-user-id") assert.Error(t, err) assert.Contains(t, err.Error(), "user not found") } // --- Login --- func TestLogin_Good(t *testing.T) { a, _ := newTestAuth() _, err := a.Register("mallory", "secret") require.NoError(t, err) userID := lthn.Hash("mallory") session, err := a.Login(userID, "secret") require.NoError(t, err) require.NotNil(t, session) assert.NotEmpty(t, session.Token) assert.Equal(t, userID, session.UserID) assert.True(t, session.ExpiresAt.After(time.Now())) } func TestLogin_Bad(t *testing.T) { a, _ := newTestAuth() _, err := a.Register("nancy", "correct-password") require.NoError(t, err) userID := lthn.Hash("nancy") // Wrong password _, err = a.Login(userID, "wrong-password") assert.Error(t, err) assert.Contains(t, err.Error(), "invalid password") } func TestLogin_Ugly(t *testing.T) { a, _ := newTestAuth() // Login for non-existent user _, err := a.Login("nonexistent-user-id", "pass") assert.Error(t, err) assert.Contains(t, err.Error(), "user not found") } // --- WriteChallengeFile / ReadResponseFile (Air-Gapped) --- func TestAirGappedFlow_Good(t *testing.T) { a, m := newTestAuth() _, err := a.Register("oscar", "airgap-pass") require.NoError(t, err) userID := lthn.Hash("oscar") // Write challenge to file challengePath := "transfer/challenge.json" err = a.WriteChallengeFile(userID, challengePath) require.NoError(t, err) assert.True(t, m.IsFile(challengePath)) // Read challenge file to get the encrypted nonce (simulating courier) challengeData, err := m.Read(challengePath) require.NoError(t, err) var challenge Challenge err = json.Unmarshal([]byte(challengeData), &challenge) require.NoError(t, err) // Client-side: decrypt nonce and sign it privKey, err := m.Read(userPath(userID, ".key")) require.NoError(t, err) decryptedNonce, err := pgp.Decrypt([]byte(challenge.Encrypted), privKey, "airgap-pass") require.NoError(t, err) signedNonce, err := pgp.Sign(decryptedNonce, privKey, "airgap-pass") require.NoError(t, err) // Write signed response to file responsePath := "transfer/response.sig" err = m.Write(responsePath, string(signedNonce)) require.NoError(t, err) // Server reads response file session, err := a.ReadResponseFile(userID, responsePath) require.NoError(t, err) require.NotNil(t, session) assert.NotEmpty(t, session.Token) assert.Equal(t, userID, session.UserID) } func TestWriteChallengeFile_Bad(t *testing.T) { a, _ := newTestAuth() // Challenge for non-existent user err := a.WriteChallengeFile("nonexistent-user", "challenge.json") assert.Error(t, err) } func TestReadResponseFile_Bad(t *testing.T) { a, _ := newTestAuth() // Response file does not exist _, err := a.ReadResponseFile("some-user", "nonexistent-file.sig") assert.Error(t, err) } func TestReadResponseFile_Ugly(t *testing.T) { a, m := newTestAuth() _, err := a.Register("peggy", "pass") require.NoError(t, err) userID := lthn.Hash("peggy") // Create a challenge _, err = a.CreateChallenge(userID) require.NoError(t, err) // Write garbage to response file responsePath := "transfer/bad-response.sig" err = m.Write(responsePath, "not-a-valid-signature") require.NoError(t, err) _, err = a.ReadResponseFile(userID, responsePath) assert.Error(t, err) } // --- Options --- func TestWithChallengeTTL_Good(t *testing.T) { ttl := 30 * time.Second a, _ := newTestAuth(WithChallengeTTL(ttl)) assert.Equal(t, ttl, a.challengeTTL) } func TestWithSessionTTL_Good(t *testing.T) { ttl := 2 * time.Hour a, _ := newTestAuth(WithSessionTTL(ttl)) assert.Equal(t, ttl, a.sessionTTL) } // --- Full Round-Trip (Online Flow) --- func TestFullRoundTrip_Good(t *testing.T) { a, m := newTestAuth() // 1. Register user, err := a.Register("quinn", "roundtrip-pass") require.NoError(t, err) require.NotNil(t, user) userID := lthn.Hash("quinn") // 2. Create challenge challenge, err := a.CreateChallenge(userID) require.NoError(t, err) // 3. Client decrypts + signs privKey, err := m.Read(userPath(userID, ".key")) require.NoError(t, err) nonce, err := pgp.Decrypt([]byte(challenge.Encrypted), privKey, "roundtrip-pass") require.NoError(t, err) sig, err := pgp.Sign(nonce, privKey, "roundtrip-pass") require.NoError(t, err) // 4. Server validates, issues session session, err := a.ValidateResponse(userID, sig) require.NoError(t, err) require.NotNil(t, session) // 5. Validate session validated, err := a.ValidateSession(session.Token) require.NoError(t, err) assert.Equal(t, session.Token, validated.Token) // 6. Refresh session refreshed, err := a.RefreshSession(session.Token) require.NoError(t, err) assert.Equal(t, session.Token, refreshed.Token) // 7. Revoke session err = a.RevokeSession(session.Token) require.NoError(t, err) // 8. Session should be invalid now _, err = a.ValidateSession(session.Token) assert.Error(t, err) } // --- Concurrent Access --- func TestConcurrentSessions_Good(t *testing.T) { a, _ := newTestAuth() _, err := a.Register("ruth", "pass") require.NoError(t, err) userID := lthn.Hash("ruth") // Create multiple sessions concurrently const n = 10 sessions := make(chan *Session, n) errs := make(chan error, n) for i := 0; i < n; i++ { go func() { s, err := a.Login(userID, "pass") if err != nil { errs <- err return } sessions <- s }() } for i := 0; i < n; i++ { select { case s := <-sessions: require.NotNil(t, s) // Validate each session _, err := a.ValidateSession(s.Token) assert.NoError(t, err) case err := <-errs: t.Fatalf("concurrent login failed: %v", err) } } } // --- Phase 0 Additions --- // TestConcurrentSessionCreation_Good verifies that 10 goroutines creating // sessions simultaneously do not produce data races or errors. func TestConcurrentSessionCreation_Good(t *testing.T) { a, _ := newTestAuth() // Register 10 distinct users to avoid contention on a single user record const n = 10 userIDs := make([]string, n) for i := 0; i < n; i++ { username := fmt.Sprintf("concurrent-user-%d", i) _, err := a.Register(username, "pass") require.NoError(t, err) userIDs[i] = lthn.Hash(username) } var wg sync.WaitGroup wg.Add(n) sessions := make([]*Session, n) errs := make([]error, n) for i := 0; i < n; i++ { go func(idx int) { defer wg.Done() s, err := a.Login(userIDs[idx], "pass") sessions[idx] = s errs[idx] = err }(i) } wg.Wait() for i := 0; i < n; i++ { require.NoError(t, errs[i], "goroutine %d failed", i) require.NotNil(t, sessions[i], "goroutine %d returned nil session", i) // Each session token must be valid _, err := a.ValidateSession(sessions[i].Token) assert.NoError(t, err, "session from goroutine %d should be valid", i) } } // TestSessionTokenUniqueness_Good generates 1000 tokens and verifies no collisions. func TestSessionTokenUniqueness_Good(t *testing.T) { a, _ := newTestAuth() _, err := a.Register("uniqueness-test", "pass") require.NoError(t, err) userID := lthn.Hash("uniqueness-test") const n = 1000 tokens := make(map[string]bool, n) for i := 0; i < n; i++ { session, err := a.Login(userID, "pass") require.NoError(t, err) require.NotNil(t, session) if tokens[session.Token] { t.Fatalf("duplicate token detected at iteration %d: %s", i, session.Token) } tokens[session.Token] = true } assert.Len(t, tokens, n, "all 1000 tokens should be unique") } // TestChallengeExpiryBoundary_Ugly tests validation right at the 5-minute boundary. // The challenge should still be valid just before expiry and rejected after. func TestChallengeExpiryBoundary_Ugly(t *testing.T) { // Use a very short TTL to test the boundary without sleeping 5 minutes ttl := 50 * time.Millisecond a, m := newTestAuth(WithChallengeTTL(ttl)) _, err := a.Register("boundary-user", "pass") require.NoError(t, err) userID := lthn.Hash("boundary-user") // Create a challenge and respond immediately (should succeed) challenge, err := a.CreateChallenge(userID) require.NoError(t, err) privKey, err := m.Read(userPath(userID, ".key")) require.NoError(t, err) decryptedNonce, err := pgp.Decrypt([]byte(challenge.Encrypted), privKey, "pass") require.NoError(t, err) signedNonce, err := pgp.Sign(decryptedNonce, privKey, "pass") require.NoError(t, err) session, err := a.ValidateResponse(userID, signedNonce) require.NoError(t, err) assert.NotNil(t, session) // Now create another challenge and let it expire challenge2, err := a.CreateChallenge(userID) require.NoError(t, err) // Wait past the TTL time.Sleep(ttl + 10*time.Millisecond) decryptedNonce2, err := pgp.Decrypt([]byte(challenge2.Encrypted), privKey, "pass") require.NoError(t, err) signedNonce2, err := pgp.Sign(decryptedNonce2, privKey, "pass") require.NoError(t, err) _, err = a.ValidateResponse(userID, signedNonce2) assert.Error(t, err) assert.Contains(t, err.Error(), "challenge expired") } // TestEmptyPasswordRegistration_Good verifies that empty password registration works. // PGP key is generated unencrypted in this case. func TestEmptyPasswordRegistration_Good(t *testing.T) { a, m := newTestAuth() user, err := a.Register("no-password-user", "") require.NoError(t, err) require.NotNil(t, user) userID := lthn.Hash("no-password-user") // Verify all files are stored assert.True(t, m.IsFile(userPath(userID, ".pub"))) assert.True(t, m.IsFile(userPath(userID, ".key"))) assert.True(t, m.IsFile(userPath(userID, ".json"))) // Login with empty password should work session, err := a.Login(userID, "") require.NoError(t, err) assert.NotNil(t, session) // Challenge-response flow should also work with empty password challenge, err := a.CreateChallenge(userID) require.NoError(t, err) privKey, err := m.Read(userPath(userID, ".key")) require.NoError(t, err) decryptedNonce, err := pgp.Decrypt([]byte(challenge.Encrypted), privKey, "") require.NoError(t, err) signedNonce, err := pgp.Sign(decryptedNonce, privKey, "") require.NoError(t, err) crSession, err := a.ValidateResponse(userID, signedNonce) require.NoError(t, err) assert.NotNil(t, crSession) } // TestVeryLongUsername_Ugly verifies behaviour with a 10K character username. func TestVeryLongUsername_Ugly(t *testing.T) { a, _ := newTestAuth() longUsername := strings.Repeat("a", 10000) user, err := a.Register(longUsername, "pass") require.NoError(t, err) require.NotNil(t, user) // The LTHN hash of the long username should still be a fixed-length identifier userID := lthn.Hash(longUsername) assert.Len(t, userID, 64, "LTHN hash should always be 64 hex chars (SHA-256)") // Login should work session, err := a.Login(userID, "pass") require.NoError(t, err) assert.NotNil(t, session) } // TestUnicodeUsernamePassword_Good verifies registration and login with Unicode characters. func TestUnicodeUsernamePassword_Good(t *testing.T) { a, _ := newTestAuth() // Japanese + emoji + Chinese + Arabic username := "\u65e5\u672c\u8a9e\u30c6\u30b9\u30c8\U0001F680\u4e2d\u6587\u0627\u0644\u0639\u0631\u0628\u064a\u0629" password := "\u00fc\u00f1\u00ee\u00e7\u00f6\u00f0\u00ea\u2603\u2764" user, err := a.Register(username, password) require.NoError(t, err) require.NotNil(t, user) userID := lthn.Hash(username) // Login with correct Unicode password session, err := a.Login(userID, password) require.NoError(t, err) assert.NotNil(t, session) // Login with wrong Unicode password should fail _, err = a.Login(userID, "wrong-\u00fc\u00f1\u00ee") assert.Error(t, err) } // TestAirGappedRoundTrip_Good tests the full air-gapped flow: // WriteChallengeFile -> client signs offline -> ReadResponseFile func TestAirGappedRoundTrip_Good(t *testing.T) { a, m := newTestAuth() _, err := a.Register("airgap-roundtrip", "courier-pass") require.NoError(t, err) userID := lthn.Hash("airgap-roundtrip") // Step 1: Server writes challenge file challengePath := "airgap/challenge.json" err = a.WriteChallengeFile(userID, challengePath) require.NoError(t, err) assert.True(t, m.IsFile(challengePath)) // Step 2: Client reads challenge file (simulating courier transport) challengeData, err := m.Read(challengePath) require.NoError(t, err) var challenge Challenge err = json.Unmarshal([]byte(challengeData), &challenge) require.NoError(t, err) assert.NotEmpty(t, challenge.Encrypted) assert.True(t, challenge.ExpiresAt.After(time.Now())) // Step 3: Client decrypts nonce, signs it, writes response privKey, err := m.Read(userPath(userID, ".key")) require.NoError(t, err) decryptedNonce, err := pgp.Decrypt([]byte(challenge.Encrypted), privKey, "courier-pass") require.NoError(t, err) assert.Equal(t, challenge.Nonce, decryptedNonce) signedNonce, err := pgp.Sign(decryptedNonce, privKey, "courier-pass") require.NoError(t, err) responsePath := "airgap/response.sig" err = m.Write(responsePath, string(signedNonce)) require.NoError(t, err) // Step 4: Server reads response file and validates session, err := a.ReadResponseFile(userID, responsePath) require.NoError(t, err) require.NotNil(t, session) assert.NotEmpty(t, session.Token) assert.Equal(t, userID, session.UserID) assert.True(t, session.ExpiresAt.After(time.Now())) // Step 5: Session should be valid validated, err := a.ValidateSession(session.Token) require.NoError(t, err) assert.Equal(t, session.Token, validated.Token) } // TestRefreshExpiredSession_Bad verifies that refreshing an already-expired session fails. func TestRefreshExpiredSession_Bad(t *testing.T) { a, _ := newTestAuth(WithSessionTTL(1 * time.Millisecond)) _, err := a.Register("expired-refresh", "pass") require.NoError(t, err) userID := lthn.Hash("expired-refresh") session, err := a.Login(userID, "pass") require.NoError(t, err) // Wait for session to expire time.Sleep(10 * time.Millisecond) // Refresh should fail _, err = a.RefreshSession(session.Token) assert.Error(t, err) assert.Contains(t, err.Error(), "session expired") // The expired session should now be cleaned up (removed from map) _, err = a.ValidateSession(session.Token) assert.Error(t, err) assert.Contains(t, err.Error(), "session not found") }