1package visual
2
3import (
4 "context"
5 "io"
6 "sync/atomic"
7 "time"
8
9 "github.com/bnema/neferafk/internal/adapters/auth"
10 "github.com/bnema/neferafk/internal/ports"
11 "github.com/bnema/nefergui"
12 "github.com/bnema/zerowrap"
13)
14
15// workerTimeout is the watchdog for worker startup and for one attempt. PAM
16// calls cannot be interrupted from Go, so expiry kills the worker process. It
17// includes the worker's longest wrong-PIN delay: killing a worker mid-delay
18// would respawn it with the delay schedule reset.
19const workerTimeout = 30*time.Second + auth.MaxDenyDelay
20
21// workerLauncher starts one isolated `neferafk auth-worker` process.
22type workerLauncher interface {
23 launch(ctx context.Context) (workerProcess, error)
24}
25
26// workerProcess is one running worker with its private pipes: stdin carries the
27// bootstrap record and frames to the worker, stdout carries frames back.
28type workerProcess interface {
29 pipes() (stdin io.WriteCloser, stdout io.ReadCloser)
30 // kill terminates the process (not a foreign call in this process).
31 kill() error
32 // wait reaps the process; it blocks until the process is gone.
33 wait() error
34}
35
36// promptKind is what the lock box asks for. It is metadata, never typed input.
37type promptKind uint32
38
39const (
40 promptNone promptKind = iota // worker not ready yet
41 promptPIN // PIN source resolved
42 promptPassword // plain account password
43 promptFallback // PIN source unavailable: password
44 promptUnavailable // no usable authentication
45 promptMore // PAM asked for another response
46)
47
48type phase uint8
49
50const (
51 phaseNoWorker phase = iota // none alive; the next attempt respawns
52 phaseStarting // bootstrap sent, waiting for Ready
53 phaseReady // waiting for a submission
54 phaseAttempt // attempt sent, waiting for the result
55 phasePrompt // worker asked for another secret
56 phaseDone // success; nothing further is accepted
57)
58
59type frameMsg struct {
60 f ports.AuthFrame
61 err error
62}
63
64type liveWorker struct {
65 proc workerProcess
66 in io.WriteCloser
67 out io.ReadCloser
68 frames chan frameMsg
69 stop chan struct{}
70}
71
72// authController owns the auth state for one lock. Only its run goroutine
73// touches the fields below "owned"; it never blocks the UI loop, which talks to
74// it through channels. Typed secrets arrive as private copies on submits and
75// are wiped as soon as they have been written to the worker pipe.
76type authController struct {
77 log zerowrap.Logger
78 launcher workerLauncher
79 boot ports.AuthBootstrap // EnvPIN is owned by the caller
80 timeout time.Duration
81
82 submits chan []byte
83 status chan nefergui.LockStatus
84 unlock chan struct{}
85 wake chan struct{}
86
87 prompt atomic.Uint32
88 succeeded atomic.Bool
89
90 // owned by run:
91 w *liveWorker
92 ph phase
93 attempt uint64
94 pending []byte
95 dog *time.Timer
96 dogC <-chan time.Time
97}
98
99func newAuthController(log zerowrap.Logger, l workerLauncher, boot ports.AuthBootstrap, timeout time.Duration) *authController {
100 return &authController{
101 log: log.WithField("component", "visual-auth"), launcher: l, boot: boot, timeout: timeout,
102 submits: make(chan []byte, 1), status: make(chan nefergui.LockStatus, 8),
103 unlock: make(chan struct{}, 1), wake: make(chan struct{}, 1),
104 }
105}
106
107// submit is called on the UI loop. It copies the secret (the caller wipes its
108// own) and never blocks; a submission racing a running attempt is dropped.
109func (c *authController) submit(secret []byte) {
110 cp := append([]byte(nil), secret...)
111 select {
112 case c.submits <- cp:
113 default:
114 clear(cp)
115 }
116}
117
118func (c *authController) setStatus(s nefergui.LockStatus) {
119 for {
120 select {
121 case c.status <- s:
122 return
123 default:
124 select {
125 case <-c.status:
126 default:
127 }
128 }
129 }
130}
131
132func (c *authController) setPrompt(p promptKind) {
133 if c.prompt.Swap(uint32(p)) != uint32(p) {
134 select {
135 case c.wake <- struct{}{}:
136 default:
137 }
138 }
139}
140
141func (c *authController) arm() {
142 if c.dog == nil {
143 c.dog = time.NewTimer(c.timeout)
144 } else {
145 if !c.dog.Stop() {
146 select {
147 case <-c.dog.C:
148 default:
149 }
150 }
151 c.dog.Reset(c.timeout)
152 }
153 c.dogC = c.dog.C
154}
155
156func (c *authController) disarm() {
157 if c.dog != nil && !c.dog.Stop() {
158 select {
159 case <-c.dog.C:
160 default:
161 }
162 }
163 c.dogC = nil
164}
165
166// run serves the lock until ctx ends or authentication succeeded.
167func (c *authController) run(ctx context.Context) {
168 defer c.drop()
169 c.spawn(ctx)
170 for {
171 var frames <-chan frameMsg
172 if c.w != nil {
173 frames = c.w.frames
174 }
175 select {
176 case <-ctx.Done():
177 return
178 case s := <-c.submits:
179 c.onSubmit(ctx, s)
180 case m := <-frames:
181 if c.onFrame(m) {
182 return
183 }
184 case <-c.dogC:
185 c.log.Warn().Msg("authentication worker timed out; killing it")
186 c.dogC = nil
187 c.workerLost(true)
188 }
189 }
190}
191
192func (c *authController) drop() {
193 c.disarm()
194 c.killWorker()
195 clear(c.pending)
196 c.pending = nil
197 c.drainSubmits()
198}
199
200// drainSubmits wipes any submission left in the queue (the UI may have handed
201// one over after the last receive). Safe to call again once run has returned.
202func (c *authController) drainSubmits() {
203 for {
204 select {
205 case s := <-c.submits:
206 clear(s)
207 default:
208 return
209 }
210 }
211}
212
213func (c *authController) spawn(ctx context.Context) {
214 proc, err := c.launcher.launch(ctx)
215 if err != nil {
216 c.log.Warn().Err(err).Msg("authentication worker launch failed")
217 c.ph = phaseNoWorker
218 return
219 }
220 in, out := proc.pipes()
221 w := &liveWorker{proc: proc, in: in, out: out, frames: make(chan frameMsg), stop: make(chan struct{})}
222 if err := auth.WriteBootstrap(in, c.boot); err != nil {
223 c.log.Warn().Msg("authentication worker bootstrap failed")
224 c.w = w
225 c.killWorker()
226 return
227 }
228 c.w, c.ph = w, phaseStarting
229 go readFrames(w)
230 c.arm()
231}
232
233func readFrames(w *liveWorker) {
234 for {
235 f, err := auth.ReadFrame(w.out)
236 select {
237 case w.frames <- frameMsg{f: f, err: err}:
238 case <-w.stop:
239 clear(f.Payload)
240 return
241 }
242 if err != nil {
243 return
244 }
245 }
246}
247
248// killWorker terminates and reaps the current worker, if any.
249func (c *authController) killWorker() {
250 w := c.w
251 if w == nil {
252 return
253 }
254 c.w = nil
255 close(w.stop)
256 _ = w.proc.kill()
257 _ = w.in.Close()
258 _ = w.out.Close()
259 go func() { _ = w.proc.wait() }()
260}
261
262// workerLost handles a dead, timed-out or misbehaving worker: the lock stays,
263// the next attempt respawns.
264func (c *authController) workerLost(failed bool) {
265 inFlight := c.ph == phaseAttempt || c.ph == phasePrompt || c.pending != nil
266 c.disarm()
267 c.killWorker()
268 clear(c.pending)
269 c.pending = nil
270 c.ph = phaseNoWorker
271 if c.prompt.Load() == uint32(promptMore) {
272 c.setPrompt(promptNone)
273 }
274 if failed || inFlight {
275 c.setStatus(nefergui.LockFailed)
276 }
277}
278
279func (c *authController) onSubmit(ctx context.Context, s []byte) {
280 switch c.ph {
281 case phaseReady:
282 c.sendAttempt(s)
283 case phasePrompt:
284 c.sendSecret(s)
285 case phaseNoWorker:
286 clear(c.pending)
287 c.pending = s
288 c.setStatus(nefergui.LockBusy)
289 c.spawn(ctx)
290 if c.w == nil { // launch failed
291 clear(c.pending)
292 c.pending = nil
293 c.setStatus(nefergui.LockFailed)
294 }
295 case phaseStarting: // keep only the latest submission
296 clear(c.pending)
297 c.pending = s
298 default: // attempt running or done: nothing to accept
299 clear(s)
300 }
301}
302
303func (c *authController) write(f ports.AuthFrame) bool {
304 err := auth.WriteFrame(c.w.in, f)
305 clear(f.Payload)
306 return err == nil
307}
308
309func (c *authController) sendAttempt(s []byte) {
310 c.attempt++
311 c.setStatus(nefergui.LockBusy)
312 c.ph = phaseAttempt
313 c.arm()
314 if !c.write(ports.AuthFrame{Kind: ports.AuthAttempt, Generation: c.boot.Generation, Attempt: c.attempt, Payload: s}) {
315 c.workerLost(true)
316 }
317}
318
319func (c *authController) sendSecret(s []byte) {
320 c.setStatus(nefergui.LockBusy)
321 c.ph = phaseAttempt
322 c.arm()
323 if !c.write(ports.AuthFrame{Kind: ports.AuthSecret, Generation: c.boot.Generation, Attempt: c.attempt, Payload: s}) {
324 c.workerLost(true)
325 }
326}
327
328// onFrame applies one worker message; it reports true once authentication
329// succeeded and the unlock was requested.
330func (c *authController) onFrame(m frameMsg) bool {
331 if m.err != nil {
332 c.log.Warn().Msg("authentication worker ended")
333 c.workerLost(false)
334 return false
335 }
336 f := m.f
337 clear(f.Payload)
338 if f.Generation != c.boot.Generation || (f.Kind != ports.AuthReady && f.Attempt != c.attempt) {
339 c.log.Warn().Msg("authentication worker sent a frame for another epoch")
340 c.workerLost(true)
341 return false
342 }
343 switch f.Kind {
344 case ports.AuthReady:
345 if c.ph != phaseStarting {
346 c.workerLost(true)
347 return false
348 }
349 c.disarm()
350 c.ph = phaseReady
351 switch f.Code {
352 case ports.AuthPIN:
353 c.setPrompt(promptPIN)
354 case ports.AuthFallbackPassword:
355 c.setPrompt(promptFallback)
356 case ports.AuthUnavailable:
357 c.setPrompt(promptUnavailable)
358 default:
359 c.setPrompt(promptPassword)
360 }
361 if p := c.pending; p != nil {
362 c.pending = nil
363 c.sendAttempt(p)
364 }
365 case ports.AuthPrompt:
366 if c.ph != phaseAttempt {
367 c.workerLost(true)
368 return false
369 }
370 c.ph = phasePrompt
371 c.setPrompt(promptMore)
372 c.setStatus(nefergui.LockIdle)
373 case ports.AuthStatus:
374 // PAM text is deliberately never shown.
375 case ports.AuthResult:
376 if c.ph != phaseAttempt && c.ph != phasePrompt {
377 c.workerLost(true)
378 return false
379 }
380 c.disarm()
381 if f.Code == ports.AuthSuccess {
382 c.ph = phaseDone
383 c.succeeded.Store(true)
384 c.unlock <- struct{}{} // cap 1, sent once
385 return true
386 }
387 c.ph = phaseReady
388 c.setStatus(nefergui.LockFailed)
389 default:
390 c.workerLost(true)
391 }
392 return false
393}