diff options
Diffstat (limited to 'cmd/git-lfs-authenticate')
-rw-r--r-- | cmd/git-lfs-authenticate/main.go | 155 |
1 files changed, 123 insertions, 32 deletions
diff --git a/cmd/git-lfs-authenticate/main.go b/cmd/git-lfs-authenticate/main.go index 41e2dbc..56b9d78 100644 --- a/cmd/git-lfs-authenticate/main.go +++ b/cmd/git-lfs-authenticate/main.go | |||
@@ -1,8 +1,18 @@ | |||
1 | package main | 1 | package main |
2 | 2 | ||
3 | import ( | 3 | import ( |
4 | "bytes" | ||
5 | "crypto/ed25519" | ||
6 | "encoding/hex" | ||
7 | "encoding/json" | ||
8 | "errors" | ||
4 | "fmt" | 9 | "fmt" |
5 | "os" | 10 | "os" |
11 | "os/exec" | ||
12 | "strings" | ||
13 | "time" | ||
14 | |||
15 | "github.com/golang-jwt/jwt/v5" | ||
6 | ) | 16 | ) |
7 | 17 | ||
8 | func die(msg string, args ...any) { | 18 | func die(msg string, args ...any) { |
@@ -12,37 +22,118 @@ func die(msg string, args ...any) { | |||
12 | os.Exit(1) | 22 | os.Exit(1) |
13 | } | 23 | } |
14 | 24 | ||
25 | func getGitoliteAccess(path, user, gitolitePerm string) bool { | ||
26 | // gitolite access -q: returns only exit code | ||
27 | cmd := exec.Command("gitolite", "access", "-q", path, user, gitolitePerm) | ||
28 | err := cmd.Run() | ||
29 | permGranted := err == nil | ||
30 | var exitErr *exec.ExitError | ||
31 | if err != nil && !errors.As(err, &exitErr) { | ||
32 | die("failed to query access information") | ||
33 | } | ||
34 | return permGranted | ||
35 | } | ||
36 | |||
37 | type gitolfs3Claims struct { | ||
38 | Repository string `json:"repository"` | ||
39 | Permission string `json:"permission"` | ||
40 | } | ||
41 | |||
42 | type customClaims struct { | ||
43 | Gitolfs3 gitolfs3Claims `json:"gitolfs3"` | ||
44 | *jwt.RegisteredClaims | ||
45 | } | ||
46 | |||
47 | type authenticateResponse struct { | ||
48 | Header map[string]string `json:"header"` | ||
49 | // In seconds. | ||
50 | ExpiresIn int64 `json:"expires_in,omitempty"` | ||
51 | // expires_at (RFC3339) could also be used, but we leave it out since we | ||
52 | // don't use it. | ||
53 | } | ||
54 | |||
55 | func wipe(b []byte) { | ||
56 | for i := range b { | ||
57 | b[i] = 0 | ||
58 | } | ||
59 | } | ||
60 | |||
15 | func main() { | 61 | func main() { |
16 | // if len(os.Args) != 3 { | 62 | // Even though not explicitly described in the Git LFS documentation, the |
17 | // die("expected 2 arguments [path, operation], got %d", len(os.Args)-1) | 63 | // git-lfs-authenticate command is expected to either exit succesfully with |
18 | // } | 64 | // exit code 0 and to then print credentials in the prescribed JSON format |
19 | // | 65 | // to standard out. On errors, the command should exit with a non-zero exit |
20 | // path := strings.TrimPrefix(strings.TrimSuffix(os.Args[1], ".git"), "/") | 66 | // code and print the error message in plain text to standard error. See |
21 | // operation := os.Args[2] | 67 | // https://github.com/git-lfs/git-lfs/blob/baf40ac99850a62fe98515175d52df5c513463ec/lfshttp/ssh.go#L76-L117 |
22 | // | 68 | |
23 | // if operation != "download" && operation != "upload" { | 69 | if len(os.Args) != 3 { |
24 | // die("expected operation to be in {upload, download}, got %s", operation) | 70 | die("expected 2 arguments (path, operation), got %d", len(os.Args)-1) |
25 | // } | 71 | } |
26 | // | 72 | |
27 | // user := os.Getenv("GL_USER") | 73 | path := strings.TrimPrefix(strings.TrimSuffix(os.Args[1], ".git"), "/") |
28 | // | 74 | operation := os.Args[2] |
29 | // if user == "" { | 75 | if operation != "download" && operation != "upload" { |
30 | // die("expected Gitolite user env (GL_USER) to be set") | 76 | die("expected operation to be upload or download, got %s", operation) |
31 | // } | 77 | } |
32 | // | 78 | |
33 | // gitolitePerm := "R" | 79 | user := os.Getenv("GL_USER") |
34 | // | 80 | if user == "" { |
35 | // if operation == "upload" { | 81 | die("internal error") |
36 | // gitolitePerm = "W" | 82 | } |
37 | // } | 83 | keyPath := os.Getenv("GITOLFS3_KEY_PATH") |
38 | // | 84 | if keyPath == "" { |
39 | // // gitolite access -q: returns only exit code | 85 | die("internal error") |
40 | // cmd := exec.Command("gitolite", "access", "-q", path, user, gitolitePerm) | 86 | } |
41 | // err := cmd.Run() | 87 | keyStr, err := os.ReadFile(keyPath) |
42 | // permGranted := err == nil | 88 | if err != nil { |
43 | // var exitErr *exec.ExitError | 89 | die("internal error") |
44 | // | 90 | } |
45 | // if err != nil && !errors.As(err, &exitErr) { | 91 | keyStr = bytes.TrimSpace(keyStr) |
46 | // die("failed to query Gitolite access information") | 92 | defer wipe(keyStr) |
47 | // } | 93 | |
94 | if hex.DecodedLen(len(keyStr)) != ed25519.SeedSize { | ||
95 | die("internal error") | ||
96 | } | ||
97 | |||
98 | seed := make([]byte, ed25519.SeedSize) | ||
99 | defer wipe(seed) | ||
100 | if _, err = hex.Decode(seed, keyStr); err != nil { | ||
101 | die("internal error") | ||
102 | } | ||
103 | privateKey := ed25519.NewKeyFromSeed(seed) | ||
104 | |||
105 | if !getGitoliteAccess(path, user, "R") { | ||
106 | die("repository not found") | ||
107 | } | ||
108 | if operation == "upload" && !getGitoliteAccess(path, user, "W") { | ||
109 | // User has read access but not write access | ||
110 | die("forbidden") | ||
111 | } | ||
112 | |||
113 | expiresIn := time.Hour * 24 | ||
114 | claims := customClaims{ | ||
115 | Gitolfs3: gitolfs3Claims{ | ||
116 | Repository: path, | ||
117 | Permission: operation, | ||
118 | }, | ||
119 | RegisteredClaims: &jwt.RegisteredClaims{ | ||
120 | Subject: user, | ||
121 | IssuedAt: jwt.NewNumericDate(time.Now()), | ||
122 | ExpiresAt: jwt.NewNumericDate(time.Now().Add(expiresIn)), | ||
123 | }, | ||
124 | } | ||
125 | |||
126 | token := jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims) | ||
127 | ss, err := token.SignedString(privateKey) | ||
128 | if err != nil { | ||
129 | die("failed to generate token") | ||
130 | } | ||
131 | |||
132 | response := authenticateResponse{ | ||
133 | Header: map[string]string{ | ||
134 | "Authorization": "Bearer " + ss, | ||
135 | }, | ||
136 | ExpiresIn: int64(expiresIn.Seconds()), | ||
137 | } | ||
138 | json.NewEncoder(os.Stdout).Encode(response) | ||
48 | } | 139 | } |