v0.2.1 c196c81

auth.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 9

↑↓ moveenter openesc close