refactor/kvconf 3421f16

worker_linux.go RawHistoryFolder

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}

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