1package auth
2
3import (
4 "bytes"
5 "context"
6 "errors"
7 "os"
8 "os/exec"
9 "strings"
10 "syscall"
11 "time"
12
13 "github.com/bnema/neferafk/internal/ports"
14)
15
16const sourceLimit = 4096
17
18var errSource = errors.New("PIN source unavailable")
19
20func validPIN(b []byte) bool {
21 if len(b) < 6 || len(b) > 32 {
22 return false
23 }
24 for _, v := range b {
25 if v < '0' || v > '9' {
26 return false
27 }
28 }
29 return true
30}
31
32func passEntry(entry string) bool {
33 if entry == "" || len(entry) > ports.AuthMaxMetadata || strings.HasPrefix(entry, "-") || strings.HasPrefix(entry, "/") || strings.ContainsAny(entry, "\\\x00\r\n") {
34 return false
35 }
36 for _, part := range strings.Split(entry, "/") {
37 if part == "" || part == "." || part == ".." {
38 return false
39 }
40 }
41 return true
42}
43
44// fixedOutput bounds stdout without retaining raw errors or growing buffers.
45type fixedOutput struct {
46 data [sourceLimit]byte
47 n int
48}
49
50func (b *fixedOutput) Write(p []byte) (int, error) {
51 if len(p) > len(b.data)-b.n {
52 return 0, errSource
53 }
54 copy(b.data[b.n:], p)
55 b.n += len(p)
56 return len(p), nil
57}
58
59func passEnvironment() []string {
60 var env []string
61 for _, key := range []string{"HOME", "PATH", "GNUPGHOME", "PASSWORD_STORE_DIR"} {
62 if v, ok := os.LookupEnv(key); ok {
63 env = append(env, key+"="+v)
64 }
65 }
66 // Installed pass uses PASSWORD_STORE_GPG_OPTS; prevent TTY/pinentry and
67 // extension execution. No startup PIN or typed secret enters this env.
68 return append(env, "PASSWORD_STORE_GPG_OPTS=--batch --no-tty --pinentry-mode error", "PASSWORD_STORE_ENABLE_EXTENSIONS=false", "GPG_TTY=", "LC_ALL=C")
69}
70
71func readPass(ctx context.Context, entry string) ([]byte, error) {
72 if !passEntry(entry) {
73 return nil, errSource
74 }
75 return commandPIN(ctx, "pass", []string{"show", entry}, passEnvironment())
76}
77
78// commandPIN owns only the bounded source subprocess, never a shell command
79// assembled from metadata. Tests use a generic child, not a real pass store.
80func commandPIN(ctx context.Context, executable string, args, env []string) ([]byte, error) {
81 ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
82 defer cancel()
83 cmd := exec.CommandContext(ctx, executable, args...)
84 cmd.Env = env
85 cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
86 // Kill the complete source group, then Run's Wait reaps the child.
87 cmd.Cancel = func() error { return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) }
88 cmd.WaitDelay = 100 * time.Millisecond
89 var out fixedOutput
90 cmd.Stdout = &out
91 defer clear(out.data[:])
92 if err := cmd.Run(); err != nil {
93 return nil, errSource
94 }
95 line := out.data[:out.n]
96 if i := bytes.IndexByte(line, '\n'); i >= 0 {
97 line = line[:i]
98 }
99 if !validPIN(line) {
100 return nil, errSource
101 }
102 return bytes.Clone(line), nil
103}
104
105// ResolvePIN is called once per lock, not once per attempt. The caller owns and
106// must wipe the returned mutable slice. Invalid/unavailable source means PAM.
107func ResolvePIN(ctx context.Context, bootstrap ports.AuthBootstrap) []byte {
108 switch bootstrap.PINSource {
109 case ports.PINSourceEnv:
110 if validPIN(bootstrap.EnvPIN) {
111 return bytes.Clone(bootstrap.EnvPIN)
112 }
113 case ports.PINSourcePass:
114 pin, err := readPass(ctx, bootstrap.PINReference)
115 if err == nil {
116 return pin
117 }
118 }
119 return nil
120}