refactor/kvconf 3421f16

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/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}

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