aboutsummaryrefslogtreecommitdiffstats
path: root/cmd/git-lfs-authenticate
diff options
context:
space:
mode:
Diffstat (limited to 'cmd/git-lfs-authenticate')
-rw-r--r--cmd/git-lfs-authenticate/main.c254
-rw-r--r--cmd/git-lfs-authenticate/main.go188
2 files changed, 309 insertions, 133 deletions
diff --git a/cmd/git-lfs-authenticate/main.c b/cmd/git-lfs-authenticate/main.c
new file mode 100644
index 0000000..0f45e49
--- /dev/null
+++ b/cmd/git-lfs-authenticate/main.c
@@ -0,0 +1,254 @@
1#include <assert.h>
2#include <ctype.h>
3#include <errno.h>
4#include <stdbool.h>
5#include <stdio.h>
6#include <string.h>
7
8#include <sys/stat.h>
9
10#include <openssl/evp.h>
11#include <openssl/hmac.h>
12
13void die(const char *format, ...) {
14 fputs("Fatal: ", stderr);
15 va_list ap;
16 va_start(ap, format);
17 vfprintf(stderr, format, ap);
18 va_end(ap);
19 fputc('\n', stderr);
20 exit(EXIT_FAILURE);
21}
22
23#define USAGE "Usage: git-lfs-authenticate <REPO> upload/download"
24
25bool hasprefix(const char *str, const char *prefix) {
26 if (strlen(prefix) > strlen(str))
27 return false;
28 return !strncmp(str, prefix, strlen(prefix));
29}
30
31bool hassuffix(const char *str, const char *suffix) {
32 if (strlen(suffix) > strlen(str))
33 return false;
34 str += strlen(str) - strlen(suffix);
35 return !strcmp(str, suffix);
36}
37
38const char *trimspace(const char *str, size_t *length) {
39 while (*length > 0 && isspace(str[0])) {
40 str++; (*length)--;
41 }
42 while (*length > 0 && isspace(str[*length - 1]))
43 (*length)--;
44 return str;
45}
46
47void printescjson(const char *str) {
48 for (size_t i = 0; i < strlen(str); i++) {
49 switch (str[i]) {
50 case '"': fputs("\\\"", stdout); break;
51 case '\\': fputs("\\\\", stdout); break;
52 case '\b': fputs("\\b", stdout); break;
53 case '\f': fputs("\\f", stdout); break;
54 case '\n': fputs("\\n", stdout); break;
55 case '\r': fputs("\\r", stdout); break;
56 case '\t': fputs("\\t", stdout); break;
57 default: fputc(str[i], stdout);
58 }
59 }
60}
61
62void checkrepopath(const char *path) {
63 if (strstr(path, "//") || strstr(path, "/./") || strstr(path, "/../")
64 || hasprefix(path, "./") || hasprefix(path, "../") || hasprefix(path, "/../"))
65 die("Bad repository name: is unresolved path");
66 if (strlen(path) > 100)
67 die("Bad repository name: longer than 100 characters");
68 if (hassuffix(path, "/"))
69 die("Bad repositry name: unexpected trailing slash");
70 if (hasprefix(path, "/"))
71 die("Bad repository name: unexpected absolute path");
72 if (!hassuffix(path, ".git"))
73 die("Bad repository name: expected '.git' repo path suffix");
74
75 struct stat statbuf;
76 if (stat(path, &statbuf)) {
77 if (errno == ENOENT)
78 die("Repo not found");
79 die("Could not stat repo: %s", strerror(errno));
80 }
81 if (!S_ISDIR(statbuf.st_mode)) {
82 die("Repo not found");
83 }
84}
85
86char *readkeyfile(const char *path, size_t *len) {
87 FILE *f = fopen(path, "r");
88 if (!f)
89 die("Cannot read key file: %s", strerror(errno));
90 *len = 0;
91 size_t bufcap = 4096;
92 char *buf = malloc(bufcap);
93 while (!feof(f) && !ferror(f)) {
94 if (*len + 4096 > bufcap) {
95 bufcap *= 2;
96 buf = realloc(buf, bufcap);
97 }
98 *len += fread(buf + *len, sizeof(char), 4096, f);
99 }
100 if (ferror(f) && !feof(f)) {
101 OPENSSL_cleanse(buf, *len);
102 die("Failed to read key file (length: %lu)", *len);
103 }
104 fclose(f);
105 return buf;
106}
107
108#define KEYSIZE 64
109
110void readkey(const char *path, uint8_t dest[KEYSIZE]) {
111 size_t keybuf_len = 0;
112 char *keybuf = readkeyfile(path, &keybuf_len);
113
114 size_t keystr_len = keybuf_len;
115 const char *keystr = trimspace(keybuf, &keystr_len);
116 if (keystr_len != 2 * KEYSIZE) {
117 OPENSSL_cleanse(keybuf, keybuf_len);
118 die("Bad key length");
119 }
120
121 for (size_t i = 0; i < KEYSIZE; i++) {
122 const char c = keystr[i];
123 uint8_t nibble = 0;
124 if (c >= '0' && c <= '9')
125 nibble = c - '0';
126 else if (c >= 'a' && c <= 'f')
127 nibble = c - 'a' + 10;
128 else if (c >= 'A' && c <= 'F')
129 nibble = c - 'A' + 10;
130 else {
131 OPENSSL_cleanse(keybuf, keybuf_len);
132 OPENSSL_cleanse(dest, KEYSIZE);
133 die("Cannot decode key");
134 }
135 size_t ikey = i / 2;
136 if (i % 2) dest[ikey] |= nibble;
137 else dest[ikey] = nibble << 4;
138 }
139
140 OPENSSL_cleanse(keybuf, keybuf_len);
141 free(keybuf);
142}
143
144void u64tobe(uint64_t x, uint8_t b[8]) {
145 b[0] = (uint8_t)(x >> 56);
146 b[1] = (uint8_t)(x >> 48);
147 b[2] = (uint8_t)(x >> 40);
148 b[3] = (uint8_t)(x >> 32);
149 b[4] = (uint8_t)(x >> 24);
150 b[5] = (uint8_t)(x >> 16);
151 b[6] = (uint8_t)(x >> 8);
152 b[7] = (uint8_t)(x >> 0);
153}
154
155#define MAX_TAG_SIZE EVP_MAX_MD_SIZE
156
157typedef struct taginfo {
158 const char *authtype;
159 const char *repopath;
160 const char *operation;
161 const int64_t expiresat_s;
162} taginfo_t;
163
164void *memcat(void *dest, const void *src, size_t n) {
165 return memcpy(dest, src, n) + n;
166}
167
168void maketag(const taginfo_t info, uint8_t key[KEYSIZE], uint8_t dest[MAX_TAG_SIZE], uint32_t *len) {
169 uint8_t expiresat_b[8];
170 u64tobe(info.expiresat_s, expiresat_b);
171
172 const uint8_t zero[1] = { 0 };
173 const size_t fullsize = strlen(info.authtype) +
174 1 + strlen(info.repopath) +
175 1 + strlen(info.operation) +
176 1 + sizeof(expiresat_b);
177 uint8_t *claimbuf = alloca(fullsize);
178 uint8_t *head = claimbuf;
179 head = memcat(head, info.authtype, strlen(info.authtype));
180 head = memcat(head, zero, 1);
181 head = memcat(head, info.repopath, strlen(info.repopath));
182 head = memcat(head, zero, 1);
183 head = memcat(head, info.operation, strlen(info.operation));
184 head = memcat(head, zero, 1);
185 head = memcat(head, expiresat_b, sizeof(expiresat_b));
186 assert(head == claimbuf + fullsize);
187
188 memset(dest, 0, MAX_TAG_SIZE);
189 *len = 0;
190 if (!HMAC(EVP_sha256(), key, KEYSIZE, claimbuf, fullsize, dest, len)) {
191 OPENSSL_cleanse(key, KEYSIZE);
192 die("Failed to generate tag");
193 }
194}
195
196#define MAX_HEXTAG_STRLEN MAX_TAG_SIZE * 2
197
198void makehextag(const taginfo_t info, uint8_t key[KEYSIZE], char dest[MAX_HEXTAG_STRLEN + 1]) {
199 uint8_t rawtag[MAX_TAG_SIZE];
200 uint32_t rawtag_len;
201 maketag(info, key, rawtag, &rawtag_len);
202
203 memset(dest, 0, MAX_HEXTAG_STRLEN + 1);
204 for (size_t i = 0; i < rawtag_len; i++) {
205 uint8_t b = rawtag[i];
206 dest[i] = (b >> 4) + ((b >> 4) < 10 ? '0' : 'a');
207 dest[i + 1] = (b & 0x0F) + ((b & 0x0F) < 10 ? '0' : 'a');
208 }
209}
210
211int main(int argc, char *argv[]) {
212 if (argc != 3) {
213 puts(USAGE);
214 exit(EXIT_FAILURE);
215 }
216
217 const char *repopath = argv[1];
218 const char *operation = argv[2];
219 if (strcmp(operation, "download") && strcmp(operation, "upload")) {
220 puts(USAGE);
221 exit(EXIT_FAILURE);
222 }
223 checkrepopath(repopath);
224
225 const char *hrefbase = getenv("GITOLFS3_HREF_BASE");
226 const char *keypath = getenv("GITOLFS3_KEY_PATH");
227
228 if (!hrefbase || strlen(hrefbase) == 0)
229 die("Incomplete configuration: base URL not provided");
230 if (hrefbase[strlen(hrefbase) - 1] != '/')
231 die("Bad configuration: base URL should end with slash");
232 if (!keypath || strlen(keypath) == 0)
233 die("Incomplete configuration: key path not provided");
234
235 uint8_t key[64];
236 readkey(keypath, key);
237
238 int64_t expiresin_s = 5 * 60;
239 int64_t expiresat_s = (int64_t)time(NULL) + expiresin_s;
240
241 taginfo_t taginfo = {
242 .authtype = "git-lfs-authenticate",
243 .repopath = repopath,
244 .operation = operation,
245 .expiresat_s = expiresat_s,
246 };
247 char hextag[MAX_HEXTAG_STRLEN + 1];
248 makehextag(taginfo, key, hextag);
249
250 printf("{\"header\":{\"Authorization\":\"Gitolfs3-Hmac-Sha256 %s\"},\"expires_in\":%ld,\"href\":\"", hextag, expiresin_s);
251 printescjson(hrefbase);
252 printescjson(repopath);
253 printf("/info/lfs?p=1&te=%ld\"}\n", expiresat_s);
254}
diff --git a/cmd/git-lfs-authenticate/main.go b/cmd/git-lfs-authenticate/main.go
index 3d2c1ea..3db0efe 100644
--- a/cmd/git-lfs-authenticate/main.go
+++ b/cmd/git-lfs-authenticate/main.go
@@ -2,91 +2,28 @@ package main
2 2
3import ( 3import (
4 "bytes" 4 "bytes"
5 "crypto/ed25519" 5 "crypto/hmac"
6 "crypto/sha256"
7 "encoding/binary"
6 "encoding/hex" 8 "encoding/hex"
7 "encoding/json" 9 "encoding/json"
8 "errors" 10 "errors"
9 "fmt" 11 "fmt"
10 "io" 12 "io"
11 "net/url" 13 "io/fs"
12 "os" 14 "os"
13 "os/exec"
14 "path" 15 "path"
15 "strings" 16 "strings"
16 "time" 17 "time"
17
18 "github.com/golang-jwt/jwt/v5"
19 "github.com/rs/xid"
20) 18)
21 19
22type logger struct {
23 reqID string
24 time time.Time
25 wc io.WriteCloser
26}
27
28func newLogger(reqID string) *logger {
29 return &logger{reqID: reqID, time: time.Now()}
30}
31
32func (l *logger) writer() io.WriteCloser {
33 if l.wc == nil {
34 os.MkdirAll(".gitolfs3/logs/", 0o700) // Mode: drwx------
35 ts := l.time.Format("2006-01-02")
36 path := fmt.Sprintf(".gitolfs3/logs/gitolfs3-%s-%s.log", ts, l.reqID)
37 l.wc, _ = os.Create(path)
38 }
39 return l.wc
40}
41
42func (l *logger) logf(msg string, args ...any) {
43 fmt.Fprintf(l.writer(), msg, args...)
44}
45
46func (l *logger) close() {
47 if l.wc != nil {
48 l.wc.Close()
49 }
50}
51
52func die(msg string, args ...any) { 20func die(msg string, args ...any) {
53 fmt.Fprint(os.Stderr, "Error: ") 21 fmt.Fprint(os.Stderr, "Fatal: ")
54 fmt.Fprintf(os.Stderr, msg, args...) 22 fmt.Fprintf(os.Stderr, msg, args...)
55 fmt.Fprint(os.Stderr, "\n") 23 fmt.Fprint(os.Stderr, "\n")
56 os.Exit(1) 24 os.Exit(1)
57} 25}
58 26
59func dieReqID(reqID string, msg string, args ...any) {
60 fmt.Fprint(os.Stderr, "Error: ")
61 fmt.Fprintf(os.Stderr, msg, args...)
62 fmt.Fprintf(os.Stderr, " (request ID: %s)\n", reqID)
63 os.Exit(1)
64}
65
66func getGitoliteAccess(logger *logger, reqID, path, user, gitolitePerm string) bool {
67 // gitolite access -q: returns only exit code
68 cmd := exec.Command("gitolite", "access", "-q", path, user, gitolitePerm)
69 err := cmd.Run()
70 permGranted := err == nil
71 var exitErr *exec.ExitError
72 if err != nil && !errors.As(err, &exitErr) {
73 logger.logf("Failed to query access information (%s): %s", cmd, err)
74 dieReqID(reqID, "failed to query access information")
75 }
76 return permGranted
77}
78
79type gitolfs3Claims struct {
80 Type string `json:"type"`
81 Repository string `json:"repository"`
82 Permission string `json:"permission"`
83}
84
85type customClaims struct {
86 Gitolfs3 gitolfs3Claims `json:"gitolfs3"`
87 *jwt.RegisteredClaims
88}
89
90type authenticateResponse struct { 27type authenticateResponse struct {
91 // When providing href, the Git LFS client will use href as the base URL 28 // When providing href, the Git LFS client will use href as the base URL
92 // instead of building the base URL using the Service Discovery mechanism. 29 // instead of building the base URL using the Service Discovery mechanism.
@@ -97,7 +34,8 @@ type authenticateResponse struct {
97 // In seconds. 34 // In seconds.
98 ExpiresIn int64 `json:"expires_in,omitempty"` 35 ExpiresIn int64 `json:"expires_in,omitempty"`
99 // The expires_at (RFC3339) property could also be used, but we leave it 36 // The expires_at (RFC3339) property could also be used, but we leave it
100 // out since we don't use it. 37 // out since we don't use it. The Git LFS docs recommend using expires_in
38 // instead (???)
101} 39}
102 40
103func wipe(b []byte) { 41func wipe(b []byte) {
@@ -106,7 +44,7 @@ func wipe(b []byte) {
106 } 44 }
107} 45}
108 46
109const usage = "Usage: git-lfs-authenticate <REPO> <OPERATION (upload/download)>" 47const usage = "Usage: git-lfs-authenticate <REPO> upload/download"
110 48
111func main() { 49func main() {
112 // Even though not explicitly described in the Git LFS documentation, the 50 // Even though not explicitly described in the Git LFS documentation, the
@@ -116,101 +54,85 @@ func main() {
116 // code and print the error message in plain text to standard error. See 54 // code and print the error message in plain text to standard error. See
117 // https://github.com/git-lfs/git-lfs/blob/baf40ac99850a62fe98515175d52df5c513463ec/lfshttp/ssh.go#L76-L117 55 // https://github.com/git-lfs/git-lfs/blob/baf40ac99850a62fe98515175d52df5c513463ec/lfshttp/ssh.go#L76-L117
118 56
119 reqID := xid.New().String()
120 logger := newLogger(reqID)
121
122 if len(os.Args) != 3 { 57 if len(os.Args) != 3 {
123 fmt.Println(usage) 58 fmt.Println(usage)
124 os.Exit(1) 59 os.Exit(1)
125 } 60 }
126 61
127 repo := strings.TrimPrefix(strings.TrimSuffix(os.Args[1], ".git"), "/") 62 repo := strings.TrimPrefix(path.Clean(strings.TrimSuffix(os.Args[1], ".git")), "/")
128 operation := os.Args[2] 63 operation := os.Args[2]
129 if operation != "download" && operation != "upload" { 64 if operation != "download" && operation != "upload" {
130 fmt.Println(usage) 65 fmt.Println(usage)
131 os.Exit(1) 66 os.Exit(1)
132 } 67 }
68 if repo == ".." || strings.HasPrefix(repo, "../") {
69 die("highly illegal repo name (Anzeige ist raus)")
70 }
133 71
134 repoHRefBaseStr := os.Getenv("REPO_HREF_BASE") 72 repoDir := path.Join(repo + ".git")
135 var repoHRefBase *url.URL 73 finfo, err := os.Stat(repoDir)
136 var err error 74 if err != nil {
137 if repoHRefBaseStr != "" { 75 if errors.Is(err, fs.ErrNotExist) {
138 if repoHRefBase, err = url.Parse(repoHRefBaseStr); err != nil { 76 die("repo not found")
139 logger.logf("Failed to parse URL in environment variable REPO_HREF_BASE: %s", err)
140 dieReqID(reqID, "internal error")
141 } 77 }
78 die("could not stat repo: %s", err)
79 }
80 if !finfo.IsDir() {
81 die("repo not found")
142 } 82 }
143 83
144 user := os.Getenv("GL_USER") 84 hrefBase := os.Getenv("GITOLFS3_HREF_BASE")
145 if user == "" { 85 if hrefBase == "" {
146 logger.logf("Environment variable GL_USER is not set") 86 die("incomplete configuration: base URL not provided")
147 dieReqID(reqID, "internal error")
148 } 87 }
88 if !strings.HasSuffix(hrefBase, "/") {
89 hrefBase += "/"
90 }
91
149 keyPath := os.Getenv("GITOLFS3_KEY_PATH") 92 keyPath := os.Getenv("GITOLFS3_KEY_PATH")
150 if keyPath == "" { 93 if keyPath == "" {
151 logger.logf("Environment variable GITOLFS3_KEY_PATH is not set") 94 die("incomplete configuration: key path not provided")
152 dieReqID(reqID, "internal error")
153 } 95 }
96
154 keyStr, err := os.ReadFile(keyPath) 97 keyStr, err := os.ReadFile(keyPath)
155 if err != nil { 98 if err != nil {
156 logger.logf("Cannot read key in GITOLFS3_KEY_PATH: %s", err) 99 wipe(keyStr)
157 dieReqID(reqID, "internal error") 100 die("cannot read key")
158 } 101 }
159 keyStr = bytes.TrimSpace(keyStr) 102 keyStr = bytes.TrimSpace(keyStr)
160 defer wipe(keyStr) 103 defer wipe(keyStr)
161 104 if hex.DecodedLen(len(keyStr)) != 64 {
162 if hex.DecodedLen(len(keyStr)) != ed25519.SeedSize { 105 die("bad key length")
163 logger.logf("Fatal: provided private key (seed) is invalid: does not have expected length")
164 dieReqID(reqID, "internal error")
165 } 106 }
166 107 key := make([]byte, 64)
167 seed := make([]byte, ed25519.SeedSize) 108 defer wipe(key)
168 defer wipe(seed) 109 if _, err = hex.Decode(key, keyStr); err != nil {
169 if _, err = hex.Decode(seed, keyStr); err != nil { 110 die("cannot decode key")
170 logger.logf("Fatal: cannot decode provided private key (seed): %s", err)
171 dieReqID(reqID, "internal error")
172 }
173 privateKey := ed25519.NewKeyFromSeed(seed)
174
175 if !getGitoliteAccess(logger, reqID, repo, user, "R") {
176 die("repository not found")
177 }
178 if operation == "upload" && !getGitoliteAccess(logger, reqID, repo, user, "W") {
179 // User has read access but no write access
180 die("forbidden")
181 } 111 }
182 112
183 expiresIn := time.Minute * 5 113 expiresIn := time.Minute * 5
184 claims := customClaims{ 114 expiresAtUnix := time.Now().Add(expiresIn).Unix()
185 Gitolfs3: gitolfs3Claims{ 115
186 Type: "batch-api", 116 tag := hmac.New(sha256.New, key)
187 Repository: repo, 117 io.WriteString(tag, "git-lfs-authenticate")
188 Permission: operation, 118 tag.Write([]byte{0})
189 }, 119 io.WriteString(tag, repo)
190 RegisteredClaims: &jwt.RegisteredClaims{ 120 tag.Write([]byte{0})
191 Subject: user, 121 io.WriteString(tag, operation)
192 IssuedAt: jwt.NewNumericDate(time.Now()), 122 tag.Write([]byte{0})
193 ExpiresAt: jwt.NewNumericDate(time.Now().Add(expiresIn)), 123 binary.Write(tag, binary.BigEndian, &expiresAtUnix)
194 }, 124 tagStr := hex.EncodeToString(tag.Sum(nil))
195 }
196
197 token := jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims)
198 ss, err := token.SignedString(privateKey)
199 if err != nil {
200 logger.logf("Fatal: failed to generate JWT: %s", err)
201 die("failed to generate token")
202 }
203 125
204 response := authenticateResponse{ 126 response := authenticateResponse{
205 Header: map[string]string{ 127 Header: map[string]string{
206 "Authorization": "Bearer " + ss, 128 "Authorization": "Tag " + tagStr,
207 }, 129 },
208 ExpiresIn: int64(expiresIn.Seconds()), 130 ExpiresIn: int64(expiresIn.Seconds()),
209 } 131 HRef: fmt.Sprintf("%s%s?p=1&te=%d",
210 if repoHRefBase != nil { 132 hrefBase,
211 response.HRef = repoHRefBase.ResolveReference(&url.URL{ 133 path.Join(repo+".git", "/info/lfs"),
212 Path: path.Join(repo+".git", "/info/lfs"), 134 expiresAtUnix,
213 }).String() 135 ),
214 } 136 }
215 json.NewEncoder(os.Stdout).Encode(response) 137 json.NewEncoder(os.Stdout).Encode(response)
216} 138}