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