1package auth
2
3import (
4 "context"
5 "crypto/subtle"
6 "io"
7 "os"
8 "os/user"
9 "strconv"
10 "time"
11
12 "github.com/bnema/neferafk/internal/ports"
13 pam "github.com/bnema/purego-pam"
14 "golang.org/x/sys/unix"
15)
16
17// These seams cover only managed PAM calls; no native credentials in tests.
18type pamTransaction interface {
19 Authenticate(pam.Flags) error
20 End() error
21}
22type pamBackend interface {
23 start(service, user string, conversation pam.Conversation) (pamTransaction, error)
24}
25type managedPAM struct{ library *pam.Library }
26
27func (p managedPAM) start(service, user string, conversation pam.Conversation) (pamTransaction, error) {
28 return p.library.StartConversation(service, user, conversation)
29}
30
31func authenticate(backend pamBackend, name string, conversation pam.Conversation) ports.AuthCode {
32 t, err := backend.start("neferafk", name, conversation)
33 if err != nil || t == nil {
34 return ports.AuthUnavailable
35 }
36 result := ports.AuthDenied
37 // Unlocking re-verifies the logged-in user; like other screen lockers it
38 // runs only the auth stack. The service file needs no account section.
39 if t.Authenticate(pam.DisallowNullAuthtok) == nil {
40 result = ports.AuthSuccess
41 }
42 if t.End() != nil {
43 result = ports.AuthDenied
44 }
45 return result
46}
47
48// HardenProcess disables core dumps and ptrace/dump access for the calling
49// process. It changes process-wide state: call it only at the entrypoint of a
50// process dedicated to a secret-handling role (the isolated auth worker and the
51// visual process, which holds the typed secret and PIN bootstrap), never in the
52// policy daemon. PR_SET_DUMPABLE is reset by execve, and children spawned later
53// inherit RLIMIT_CORE=0 only. CLI spawning remains external.
54func HardenProcess() error {
55 if err := unix.Prctl(unix.PR_SET_DUMPABLE, 0, 0, 0, 0); err != nil {
56 return errSource
57 }
58 if err := unix.Setrlimit(unix.RLIMIT_CORE, &unix.Rlimit{}); err != nil {
59 return errSource
60 }
61 return nil
62}
63
64// MaxDenyDelay caps the pause applied before answering a wrong PIN.
65const MaxDenyDelay = 30 * time.Second
66
67// denyDelay is the pause before answering the nth consecutive wrong PIN
68// (n >= 1): 1s, 2s, 4s, 8s, then MaxDenyDelay.
69func denyDelay(failures int) time.Duration {
70 if failures < 1 {
71 return 0
72 }
73 if failures > 4 {
74 return MaxDenyDelay
75 }
76 return time.Second << (failures - 1)
77}
78
79// delayClock is the time seam for the wrong-PIN delay.
80type delayClock interface {
81 // after returns a channel that delivers once d has elapsed.
82 after(d time.Duration) <-chan time.Time
83}
84
85type realClock struct{}
86
87func (realClock) after(d time.Duration) <-chan time.Time { return time.After(d) }
88
89// lookahead reads frames, optionally ahead of the main loop while a delay runs,
90// so a closed input pipe ends the delay at once. Frames read early are kept in
91// order for the next read.
92type lookahead struct {
93 r io.Reader
94 ch chan frameRead // pending background read
95 held *frameRead // frame already read during a delay
96}
97
98type frameRead struct {
99 f ports.AuthFrame
100 err error
101}
102
103func (l *lookahead) read() (ports.AuthFrame, error) {
104 switch {
105 case l.held != nil:
106 fr := *l.held
107 l.held = nil
108 return fr.f, fr.err
109 case l.ch != nil:
110 fr := <-l.ch
111 l.ch = nil
112 return fr.f, fr.err
113 }
114 return ReadFrame(l.r)
115}
116
117// wait returns nil once d elapsed on the clock, or the read error / context
118// error that interrupted it (EOF: the visual is gone).
119func (l *lookahead) wait(ctx context.Context, clock delayClock, d time.Duration) error {
120 if l.held == nil && l.ch == nil {
121 l.ch = make(chan frameRead, 1)
122 go func(ch chan<- frameRead) {
123 f, err := ReadFrame(l.r)
124 ch <- frameRead{f, err}
125 }(l.ch)
126 }
127 timer := clock.after(d)
128 ch := l.ch
129 for {
130 select {
131 case <-timer:
132 return nil
133 case <-ctx.Done():
134 if l.held != nil {
135 clear(l.held.f.Payload) // never leave a read-ahead secret behind
136 l.held = nil
137 }
138 return ctx.Err()
139 case fr := <-ch:
140 l.ch, ch = nil, nil
141 if fr.err != nil {
142 clear(fr.f.Payload)
143 return fr.err
144 }
145 l.held = &fr
146 }
147 }
148}
149
150type worker struct {
151 r io.Reader
152 w io.Writer
153 generation uint64
154 attempt uint64
155 deadline time.Time
156 secret []byte // dedicated secretBuffer page in production
157 secretLen int
158 first bool
159}
160
161func (w *worker) frame(kind ports.AuthKind, code ports.AuthCode) error {
162 return WriteFrame(w.w, ports.AuthFrame{Kind: kind, Code: code, Generation: w.generation, Attempt: w.attempt})
163}
164
165// Respond deliberately ignores PAM text, including usernames/prompts/errors.
166// All prompts use one generic masked field; multiple prompts are sequential.
167func (w *worker) Respond(message pam.Message, answer []byte) (int, error) {
168 if time.Now().After(w.deadline) {
169 return 0, ErrProtocol
170 }
171 switch message.Style {
172 case pam.TextInfo, pam.ErrorMsg:
173 return 0, w.frame(ports.AuthStatus, ports.AuthPassword)
174 case pam.PromptEchoOff, pam.PromptEchoOn:
175 default:
176 return 0, ErrProtocol
177 }
178 if w.first && message.Style == pam.PromptEchoOff {
179 if w.secretLen > len(answer) {
180 return 0, ErrProtocol
181 }
182 for _, b := range w.secret[:w.secretLen] {
183 if b == 0 {
184 return 0, ErrProtocol
185 }
186 }
187 w.first = false
188 n := copy(answer, w.secret[:w.secretLen])
189 clear(w.secret[:])
190 w.secretLen = 0
191 return n, nil
192 }
193 if err := w.frame(ports.AuthPrompt, ports.AuthPassword); err != nil {
194 return 0, err
195 }
196 f, err := ReadFrame(w.r)
197 if err != nil {
198 return 0, err
199 }
200 defer clear(f.Payload)
201 if f.Generation != w.generation || f.Attempt != w.attempt || f.Kind != ports.AuthSecret || len(f.Payload) > len(answer) || time.Now().After(w.deadline) {
202 return 0, ErrProtocol
203 }
204 for _, b := range f.Payload {
205 if b == 0 {
206 return 0, ErrProtocol
207 }
208 }
209 return copy(answer, f.Payload), nil
210}
211
212// Run is a synchronous per-lock worker loop. Reader/writer must be private
213// anonymous CLOEXEC pipes established by the visual process. The visual owner
214// enforces the 30s watchdog by terminating this process: native PAM calls and
215// blocked pipe reads cannot be interrupted safely by a Go goroutine.
216// Bootstrap is consumed once; no PIN re-resolution occurs on wrong attempts.
217func Run(ctx context.Context, r io.Reader, out io.Writer, bootstrap ports.AuthBootstrap) error {
218 defer clear(bootstrap.EnvPIN)
219 if bootstrap.Generation == 0 || len(bootstrap.PINReference) > ports.AuthMaxMetadata || len(bootstrap.EnvPIN) > ports.AuthMaxSecret {
220 return ErrProtocol
221 }
222 pin := ResolvePIN(ctx, bootstrap)
223 clear(bootstrap.EnvPIN)
224 defer clear(pin)
225 var backend pamBackend
226 var library *pam.Library
227 name := ""
228 if pin == nil {
229 if info, err := os.Stat("/etc/pam.d/neferafk"); err == nil && info.Mode().IsRegular() {
230 if u, err := user.LookupId(strconv.Itoa(os.Getuid())); err == nil {
231 name = u.Username
232 library, _ = pam.Open()
233 if library != nil {
234 backend = managedPAM{library}
235 }
236 }
237 }
238 }
239 if library != nil {
240 defer library.Close()
241 }
242 return runWith(ctx, realClock{}, r, out, bootstrap.Generation, pin, backend, name, bootstrap.PINSource != "" && pin == nil)
243}
244
245func run(r io.Reader, out io.Writer, generation uint64, pin []byte, backend pamBackend, name string) error {
246 return runScoped(r, out, generation, pin, backend, name, false)
247}
248
249func runScoped(r io.Reader, out io.Writer, generation uint64, pin []byte, backend pamBackend, name string, fallback bool) error {
250 return runWith(context.Background(), realClock{}, r, out, generation, pin, backend, name, fallback)
251}
252
253func runWith(ctx context.Context, clock delayClock, r io.Reader, out io.Writer, generation uint64, pin []byte, backend pamBackend, name string, fallback bool) error {
254 secret, err := newSecret(ports.AuthMaxSecret)
255 if err != nil {
256 return err
257 }
258 defer secret.close()
259 w := &worker{r: r, w: out, generation: generation, secret: secret.data}
260 if pin != nil {
261 if !validPIN(pin) {
262 return ErrProtocol
263 }
264 stored, err := newSecret(32)
265 if err != nil {
266 return err
267 }
268 defer stored.close()
269 copy(stored.data, pin)
270 n := len(pin)
271 clear(pin) // transient Go-heap resolution copy, replaced by native page
272 pin = stored.data[:n]
273 }
274 ready := ports.AuthPIN
275 if pin == nil {
276 ready = ports.AuthPassword
277 if fallback {
278 ready = ports.AuthFallbackPassword
279 }
280 if backend == nil {
281 ready = ports.AuthUnavailable
282 }
283 }
284 readyFrame := ports.AuthFrame{Kind: ports.AuthReady, Code: ready, Generation: generation}
285 if fallback && backend == nil {
286 readyFrame.Payload = []byte{byte(ports.AuthFallbackToPassword)}
287 }
288 if err := WriteFrame(out, readyFrame); err != nil {
289 return err
290 }
291 in := &lookahead{r: r}
292 failures := 0 // consecutive wrong PINs; the PAM path has pam_faildelay
293 for {
294 f, err := in.read()
295 if err != nil {
296 return err
297 }
298 if f.Kind != ports.AuthAttempt || f.Generation != generation || f.Attempt == 0 || f.Attempt <= w.attempt {
299 clear(f.Payload)
300 return ErrProtocol
301 }
302 for _, b := range f.Payload {
303 if b == 0 {
304 clear(f.Payload)
305 return ErrProtocol
306 }
307 }
308 w.attempt, w.deadline, w.first = f.Attempt, time.Now().Add(30*time.Second), true
309 w.secretLen = copy(w.secret[:], f.Payload)
310 clear(f.Payload)
311 result := ports.AuthDenied
312 if pin != nil {
313 if subtle.ConstantTimeCompare(w.secret[:w.secretLen], pin) == 1 {
314 result = ports.AuthSuccess
315 failures = 0
316 } else {
317 failures++
318 }
319 } else if backend == nil {
320 result = ports.AuthUnavailable
321 } else {
322 result = authenticate(backend, name, w)
323 }
324 if time.Now().After(w.deadline) {
325 result = ports.AuthDenied
326 }
327 clear(w.secret[:])
328 w.secretLen = 0
329 if pin != nil && result == ports.AuthDenied {
330 // Growing pause before every wrong-PIN answer; it ends early on
331 // cancellation or when the visual closes the pipe.
332 if err := in.wait(ctx, clock, denyDelay(failures)); err != nil {
333 return err
334 }
335 }
336 if err := w.frame(ports.AuthResult, result); err != nil {
337 return err
338 }
339 if result == ports.AuthSuccess {
340 return nil
341 }
342 }
343}