1package auth
2
3import (
4 "bytes"
5 "context"
6 "errors"
7 "io"
8 "testing"
9 "time"
10
11 "github.com/bnema/neferafk/internal/ports"
12 pam "github.com/bnema/purego-pam"
13 "github.com/stretchr/testify/mock"
14)
15
16// Unlock runs only the PAM auth stack (like swaylock); account management is
17// never called, so a service file with only `auth` lines works.
18func TestPAMRequiresAuthenticationAndEnd(t *testing.T) {
19 for _, stage := range []string{"success", "start", "authenticate", "end"} {
20 t.Run(stage, func(t *testing.T) {
21 b := newMockpamBackend(t)
22 tx := newMockpamTransaction(t)
23 boom := errors.New("sensitive native message")
24 if stage == "start" {
25 b.EXPECT().start("neferafk", "real-user", mock.Anything).Return(nil, boom).Once()
26 } else {
27 b.EXPECT().start("neferafk", "real-user", mock.Anything).Return(tx, nil).Once()
28 var authErr, endErr error
29 if stage == "authenticate" {
30 authErr = boom
31 }
32 if stage == "end" {
33 endErr = boom
34 }
35 tx.EXPECT().Authenticate(pam.DisallowNullAuthtok).Return(authErr).Once()
36 tx.EXPECT().End().Return(endErr).Once()
37 }
38 code := authenticate(b, "real-user", &worker{})
39 if (code == ports.AuthSuccess) != (stage == "success") {
40 t.Fatalf("stage %s code %d", stage, code)
41 }
42 })
43 }
44}
45
46func TestPINWrongNeverFallsBackAndAttemptsScoped(t *testing.T) {
47 var input, output bytes.Buffer
48 for i, secret := range []string{"000000", "123456"} {
49 if err := WriteFrame(&input, ports.AuthFrame{Kind: ports.AuthAttempt, Generation: 8, Attempt: uint64(i + 1), Payload: []byte(secret)}); err != nil {
50 t.Fatal(err)
51 }
52 }
53 b := newMockpamBackend(t) // no PAM call allowed for valid resolved PIN
54 if err := run(&input, &output, 8, []byte("123456"), b, "user"); err != nil {
55 t.Fatal(err)
56 }
57 for i, want := range []ports.AuthCode{ports.AuthPIN, ports.AuthDenied, ports.AuthSuccess} {
58 f, err := ReadFrame(&output)
59 if err != nil || f.Code != want || f.Generation != 8 || f.Attempt != uint64(i) || len(f.Payload) != 0 {
60 t.Fatalf("result %+v err=%v", f, err)
61 }
62 }
63}
64
65func TestRunConsumesBootstrapOnceAndWipes(t *testing.T) {
66 var input, output bytes.Buffer
67 if err := WriteFrame(&input, ports.AuthFrame{Kind: ports.AuthAttempt, Generation: 8, Attempt: 1, Payload: []byte("123456")}); err != nil {
68 t.Fatal(err)
69 }
70 bootstrap := ports.AuthBootstrap{Generation: 8, PINSource: "env", EnvPIN: []byte("123456")}
71 if err := Run(context.Background(), &input, &output, bootstrap); err != nil {
72 t.Fatal(err)
73 }
74 if !bytes.Equal(bootstrap.EnvPIN, make([]byte, 6)) {
75 t.Fatal("bootstrap retained secret")
76 }
77 for _, kind := range []ports.AuthKind{ports.AuthReady, ports.AuthResult} {
78 f, err := ReadFrame(&output)
79 if err != nil || f.Kind != kind {
80 t.Fatalf("frame %+v %v", f, err)
81 }
82 }
83}
84
85func TestUnavailablePAMAndReplayFailClosed(t *testing.T) {
86 var input, output bytes.Buffer
87 for range 2 {
88 if err := WriteFrame(&input, ports.AuthFrame{Kind: ports.AuthAttempt, Generation: 2, Attempt: 1}); err != nil {
89 t.Fatal(err)
90 }
91 }
92 if err := run(&input, &output, 2, nil, nil, ""); !errors.Is(err, ErrProtocol) {
93 t.Fatal(err)
94 }
95 for range 2 {
96 f, err := ReadFrame(&output)
97 if err != nil || f.Code != ports.AuthUnavailable {
98 t.Fatalf("result %+v %v", f, err)
99 }
100 }
101 if _, err := ReadFrame(&output); !errors.Is(err, io.EOF) {
102 t.Fatal("replay produced result")
103 }
104}
105
106func TestConversationGenericMultiplePromptsAndWipes(t *testing.T) {
107 var input, output bytes.Buffer
108 if err := WriteFrame(&input, ports.AuthFrame{Kind: ports.AuthSecret, Generation: 3, Attempt: 9, Payload: []byte("second")}); err != nil {
109 t.Fatal(err)
110 }
111 w := &worker{r: &input, w: &output, generation: 3, attempt: 9, deadline: time.Now().Add(time.Second), first: true, secretLen: 5, secret: make([]byte, 512)}
112 copy(w.secret[:], "first")
113 answer := make([]byte, 512)
114 if n, err := w.Respond(pam.Message{Style: pam.PromptEchoOff, Text: []byte("do not forward")}, answer); err != nil || string(answer[:n]) != "first" {
115 t.Fatalf("first %d %v", n, err)
116 }
117 if !bytes.Equal(w.secret[:], make([]byte, 512)) {
118 t.Fatal("first secret retained")
119 }
120 clear(answer)
121 if n, err := w.Respond(pam.Message{Style: pam.PromptEchoOn, Text: []byte("raw native prompt")}, answer); err != nil || string(answer[:n]) != "second" {
122 t.Fatalf("second %d %v", n, err)
123 }
124 if _, err := w.Respond(pam.Message{Style: pam.ErrorMsg, Text: []byte("native secret error")}, answer); err != nil {
125 t.Fatal(err)
126 }
127 for _, kind := range []ports.AuthKind{ports.AuthPrompt, ports.AuthStatus} {
128 f, err := ReadFrame(&output)
129 if err != nil || f.Kind != kind || len(f.Payload) != 0 || f.Attempt != 9 {
130 t.Fatalf("generic %+v %v", f, err)
131 }
132 }
133}