From bc465de960aa5d53b28d51bd6bbead28433f2010 Mon Sep 17 00:00:00 2001 From: Rutger Broekhoff Date: Tue, 9 Jan 2024 18:13:01 +0100 Subject: Simplify git-lfs-authenticate, rip out Gitolite Zero dependencies for git-lfs-authenticate now. Not compatible with the LFS server. Assumes that any user who has access to the Git user, should have access to all repositories. --- cmd/git-lfs-authenticate/main.c | 254 +++++++++++++++++++++++++++++++++++++++ cmd/git-lfs-authenticate/main.go | 188 +++++++++-------------------- 2 files changed, 309 insertions(+), 133 deletions(-) create mode 100644 cmd/git-lfs-authenticate/main.c (limited to 'cmd') 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 @@ +#include +#include +#include +#include +#include +#include + +#include + +#include +#include + +void die(const char *format, ...) { + fputs("Fatal: ", stderr); + va_list ap; + va_start(ap, format); + vfprintf(stderr, format, ap); + va_end(ap); + fputc('\n', stderr); + exit(EXIT_FAILURE); +} + +#define USAGE "Usage: git-lfs-authenticate upload/download" + +bool hasprefix(const char *str, const char *prefix) { + if (strlen(prefix) > strlen(str)) + return false; + return !strncmp(str, prefix, strlen(prefix)); +} + +bool hassuffix(const char *str, const char *suffix) { + if (strlen(suffix) > strlen(str)) + return false; + str += strlen(str) - strlen(suffix); + return !strcmp(str, suffix); +} + +const char *trimspace(const char *str, size_t *length) { + while (*length > 0 && isspace(str[0])) { + str++; (*length)--; + } + while (*length > 0 && isspace(str[*length - 1])) + (*length)--; + return str; +} + +void printescjson(const char *str) { + for (size_t i = 0; i < strlen(str); i++) { + switch (str[i]) { + case '"': fputs("\\\"", stdout); break; + case '\\': fputs("\\\\", stdout); break; + case '\b': fputs("\\b", stdout); break; + case '\f': fputs("\\f", stdout); break; + case '\n': fputs("\\n", stdout); break; + case '\r': fputs("\\r", stdout); break; + case '\t': fputs("\\t", stdout); break; + default: fputc(str[i], stdout); + } + } +} + +void checkrepopath(const char *path) { + if (strstr(path, "//") || strstr(path, "/./") || strstr(path, "/../") + || hasprefix(path, "./") || hasprefix(path, "../") || hasprefix(path, "/../")) + die("Bad repository name: is unresolved path"); + if (strlen(path) > 100) + die("Bad repository name: longer than 100 characters"); + if (hassuffix(path, "/")) + die("Bad repositry name: unexpected trailing slash"); + if (hasprefix(path, "/")) + die("Bad repository name: unexpected absolute path"); + if (!hassuffix(path, ".git")) + die("Bad repository name: expected '.git' repo path suffix"); + + struct stat statbuf; + if (stat(path, &statbuf)) { + if (errno == ENOENT) + die("Repo not found"); + die("Could not stat repo: %s", strerror(errno)); + } + if (!S_ISDIR(statbuf.st_mode)) { + die("Repo not found"); + } +} + +char *readkeyfile(const char *path, size_t *len) { + FILE *f = fopen(path, "r"); + if (!f) + die("Cannot read key file: %s", strerror(errno)); + *len = 0; + size_t bufcap = 4096; + char *buf = malloc(bufcap); + while (!feof(f) && !ferror(f)) { + if (*len + 4096 > bufcap) { + bufcap *= 2; + buf = realloc(buf, bufcap); + } + *len += fread(buf + *len, sizeof(char), 4096, f); + } + if (ferror(f) && !feof(f)) { + OPENSSL_cleanse(buf, *len); + die("Failed to read key file (length: %lu)", *len); + } + fclose(f); + return buf; +} + +#define KEYSIZE 64 + +void readkey(const char *path, uint8_t dest[KEYSIZE]) { + size_t keybuf_len = 0; + char *keybuf = readkeyfile(path, &keybuf_len); + + size_t keystr_len = keybuf_len; + const char *keystr = trimspace(keybuf, &keystr_len); + if (keystr_len != 2 * KEYSIZE) { + OPENSSL_cleanse(keybuf, keybuf_len); + die("Bad key length"); + } + + for (size_t i = 0; i < KEYSIZE; i++) { + const char c = keystr[i]; + uint8_t nibble = 0; + if (c >= '0' && c <= '9') + nibble = c - '0'; + else if (c >= 'a' && c <= 'f') + nibble = c - 'a' + 10; + else if (c >= 'A' && c <= 'F') + nibble = c - 'A' + 10; + else { + OPENSSL_cleanse(keybuf, keybuf_len); + OPENSSL_cleanse(dest, KEYSIZE); + die("Cannot decode key"); + } + size_t ikey = i / 2; + if (i % 2) dest[ikey] |= nibble; + else dest[ikey] = nibble << 4; + } + + OPENSSL_cleanse(keybuf, keybuf_len); + free(keybuf); +} + +void u64tobe(uint64_t x, uint8_t b[8]) { + b[0] = (uint8_t)(x >> 56); + b[1] = (uint8_t)(x >> 48); + b[2] = (uint8_t)(x >> 40); + b[3] = (uint8_t)(x >> 32); + b[4] = (uint8_t)(x >> 24); + b[5] = (uint8_t)(x >> 16); + b[6] = (uint8_t)(x >> 8); + b[7] = (uint8_t)(x >> 0); +} + +#define MAX_TAG_SIZE EVP_MAX_MD_SIZE + +typedef struct taginfo { + const char *authtype; + const char *repopath; + const char *operation; + const int64_t expiresat_s; +} taginfo_t; + +void *memcat(void *dest, const void *src, size_t n) { + return memcpy(dest, src, n) + n; +} + +void maketag(const taginfo_t info, uint8_t key[KEYSIZE], uint8_t dest[MAX_TAG_SIZE], uint32_t *len) { + uint8_t expiresat_b[8]; + u64tobe(info.expiresat_s, expiresat_b); + + const uint8_t zero[1] = { 0 }; + const size_t fullsize = strlen(info.authtype) + + 1 + strlen(info.repopath) + + 1 + strlen(info.operation) + + 1 + sizeof(expiresat_b); + uint8_t *claimbuf = alloca(fullsize); + uint8_t *head = claimbuf; + head = memcat(head, info.authtype, strlen(info.authtype)); + head = memcat(head, zero, 1); + head = memcat(head, info.repopath, strlen(info.repopath)); + head = memcat(head, zero, 1); + head = memcat(head, info.operation, strlen(info.operation)); + head = memcat(head, zero, 1); + head = memcat(head, expiresat_b, sizeof(expiresat_b)); + assert(head == claimbuf + fullsize); + + memset(dest, 0, MAX_TAG_SIZE); + *len = 0; + if (!HMAC(EVP_sha256(), key, KEYSIZE, claimbuf, fullsize, dest, len)) { + OPENSSL_cleanse(key, KEYSIZE); + die("Failed to generate tag"); + } +} + +#define MAX_HEXTAG_STRLEN MAX_TAG_SIZE * 2 + +void makehextag(const taginfo_t info, uint8_t key[KEYSIZE], char dest[MAX_HEXTAG_STRLEN + 1]) { + uint8_t rawtag[MAX_TAG_SIZE]; + uint32_t rawtag_len; + maketag(info, key, rawtag, &rawtag_len); + + memset(dest, 0, MAX_HEXTAG_STRLEN + 1); + for (size_t i = 0; i < rawtag_len; i++) { + uint8_t b = rawtag[i]; + dest[i] = (b >> 4) + ((b >> 4) < 10 ? '0' : 'a'); + dest[i + 1] = (b & 0x0F) + ((b & 0x0F) < 10 ? '0' : 'a'); + } +} + +int main(int argc, char *argv[]) { + if (argc != 3) { + puts(USAGE); + exit(EXIT_FAILURE); + } + + const char *repopath = argv[1]; + const char *operation = argv[2]; + if (strcmp(operation, "download") && strcmp(operation, "upload")) { + puts(USAGE); + exit(EXIT_FAILURE); + } + checkrepopath(repopath); + + const char *hrefbase = getenv("GITOLFS3_HREF_BASE"); + const char *keypath = getenv("GITOLFS3_KEY_PATH"); + + if (!hrefbase || strlen(hrefbase) == 0) + die("Incomplete configuration: base URL not provided"); + if (hrefbase[strlen(hrefbase) - 1] != '/') + die("Bad configuration: base URL should end with slash"); + if (!keypath || strlen(keypath) == 0) + die("Incomplete configuration: key path not provided"); + + uint8_t key[64]; + readkey(keypath, key); + + int64_t expiresin_s = 5 * 60; + int64_t expiresat_s = (int64_t)time(NULL) + expiresin_s; + + taginfo_t taginfo = { + .authtype = "git-lfs-authenticate", + .repopath = repopath, + .operation = operation, + .expiresat_s = expiresat_s, + }; + char hextag[MAX_HEXTAG_STRLEN + 1]; + makehextag(taginfo, key, hextag); + + printf("{\"header\":{\"Authorization\":\"Gitolfs3-Hmac-Sha256 %s\"},\"expires_in\":%ld,\"href\":\"", hextag, expiresin_s); + printescjson(hrefbase); + printescjson(repopath); + printf("/info/lfs?p=1&te=%ld\"}\n", expiresat_s); +} 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 import ( "bytes" - "crypto/ed25519" + "crypto/hmac" + "crypto/sha256" + "encoding/binary" "encoding/hex" "encoding/json" "errors" "fmt" "io" - "net/url" + "io/fs" "os" - "os/exec" "path" "strings" "time" - - "github.com/golang-jwt/jwt/v5" - "github.com/rs/xid" ) -type logger struct { - reqID string - time time.Time - wc io.WriteCloser -} - -func newLogger(reqID string) *logger { - return &logger{reqID: reqID, time: time.Now()} -} - -func (l *logger) writer() io.WriteCloser { - if l.wc == nil { - os.MkdirAll(".gitolfs3/logs/", 0o700) // Mode: drwx------ - ts := l.time.Format("2006-01-02") - path := fmt.Sprintf(".gitolfs3/logs/gitolfs3-%s-%s.log", ts, l.reqID) - l.wc, _ = os.Create(path) - } - return l.wc -} - -func (l *logger) logf(msg string, args ...any) { - fmt.Fprintf(l.writer(), msg, args...) -} - -func (l *logger) close() { - if l.wc != nil { - l.wc.Close() - } -} - func die(msg string, args ...any) { - fmt.Fprint(os.Stderr, "Error: ") + fmt.Fprint(os.Stderr, "Fatal: ") fmt.Fprintf(os.Stderr, msg, args...) fmt.Fprint(os.Stderr, "\n") os.Exit(1) } -func dieReqID(reqID string, msg string, args ...any) { - fmt.Fprint(os.Stderr, "Error: ") - fmt.Fprintf(os.Stderr, msg, args...) - fmt.Fprintf(os.Stderr, " (request ID: %s)\n", reqID) - os.Exit(1) -} - -func getGitoliteAccess(logger *logger, reqID, path, user, gitolitePerm string) bool { - // gitolite access -q: returns only exit code - cmd := exec.Command("gitolite", "access", "-q", path, user, gitolitePerm) - err := cmd.Run() - permGranted := err == nil - var exitErr *exec.ExitError - if err != nil && !errors.As(err, &exitErr) { - logger.logf("Failed to query access information (%s): %s", cmd, err) - dieReqID(reqID, "failed to query access information") - } - return permGranted -} - -type gitolfs3Claims struct { - Type string `json:"type"` - Repository string `json:"repository"` - Permission string `json:"permission"` -} - -type customClaims struct { - Gitolfs3 gitolfs3Claims `json:"gitolfs3"` - *jwt.RegisteredClaims -} - type authenticateResponse struct { // When providing href, the Git LFS client will use href as the base URL // instead of building the base URL using the Service Discovery mechanism. @@ -97,7 +34,8 @@ type authenticateResponse struct { // In seconds. ExpiresIn int64 `json:"expires_in,omitempty"` // The expires_at (RFC3339) property could also be used, but we leave it - // out since we don't use it. + // out since we don't use it. The Git LFS docs recommend using expires_in + // instead (???) } func wipe(b []byte) { @@ -106,7 +44,7 @@ func wipe(b []byte) { } } -const usage = "Usage: git-lfs-authenticate " +const usage = "Usage: git-lfs-authenticate upload/download" func main() { // Even though not explicitly described in the Git LFS documentation, the @@ -116,101 +54,85 @@ func main() { // code and print the error message in plain text to standard error. See // https://github.com/git-lfs/git-lfs/blob/baf40ac99850a62fe98515175d52df5c513463ec/lfshttp/ssh.go#L76-L117 - reqID := xid.New().String() - logger := newLogger(reqID) - if len(os.Args) != 3 { fmt.Println(usage) os.Exit(1) } - repo := strings.TrimPrefix(strings.TrimSuffix(os.Args[1], ".git"), "/") + repo := strings.TrimPrefix(path.Clean(strings.TrimSuffix(os.Args[1], ".git")), "/") operation := os.Args[2] if operation != "download" && operation != "upload" { fmt.Println(usage) os.Exit(1) } + if repo == ".." || strings.HasPrefix(repo, "../") { + die("highly illegal repo name (Anzeige ist raus)") + } - repoHRefBaseStr := os.Getenv("REPO_HREF_BASE") - var repoHRefBase *url.URL - var err error - if repoHRefBaseStr != "" { - if repoHRefBase, err = url.Parse(repoHRefBaseStr); err != nil { - logger.logf("Failed to parse URL in environment variable REPO_HREF_BASE: %s", err) - dieReqID(reqID, "internal error") + repoDir := path.Join(repo + ".git") + finfo, err := os.Stat(repoDir) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + die("repo not found") } + die("could not stat repo: %s", err) + } + if !finfo.IsDir() { + die("repo not found") } - user := os.Getenv("GL_USER") - if user == "" { - logger.logf("Environment variable GL_USER is not set") - dieReqID(reqID, "internal error") + hrefBase := os.Getenv("GITOLFS3_HREF_BASE") + if hrefBase == "" { + die("incomplete configuration: base URL not provided") } + if !strings.HasSuffix(hrefBase, "/") { + hrefBase += "/" + } + keyPath := os.Getenv("GITOLFS3_KEY_PATH") if keyPath == "" { - logger.logf("Environment variable GITOLFS3_KEY_PATH is not set") - dieReqID(reqID, "internal error") + die("incomplete configuration: key path not provided") } + keyStr, err := os.ReadFile(keyPath) if err != nil { - logger.logf("Cannot read key in GITOLFS3_KEY_PATH: %s", err) - dieReqID(reqID, "internal error") + wipe(keyStr) + die("cannot read key") } keyStr = bytes.TrimSpace(keyStr) defer wipe(keyStr) - - if hex.DecodedLen(len(keyStr)) != ed25519.SeedSize { - logger.logf("Fatal: provided private key (seed) is invalid: does not have expected length") - dieReqID(reqID, "internal error") + if hex.DecodedLen(len(keyStr)) != 64 { + die("bad key length") } - - seed := make([]byte, ed25519.SeedSize) - defer wipe(seed) - if _, err = hex.Decode(seed, keyStr); err != nil { - logger.logf("Fatal: cannot decode provided private key (seed): %s", err) - dieReqID(reqID, "internal error") - } - privateKey := ed25519.NewKeyFromSeed(seed) - - if !getGitoliteAccess(logger, reqID, repo, user, "R") { - die("repository not found") - } - if operation == "upload" && !getGitoliteAccess(logger, reqID, repo, user, "W") { - // User has read access but no write access - die("forbidden") + key := make([]byte, 64) + defer wipe(key) + if _, err = hex.Decode(key, keyStr); err != nil { + die("cannot decode key") } expiresIn := time.Minute * 5 - claims := customClaims{ - Gitolfs3: gitolfs3Claims{ - Type: "batch-api", - Repository: repo, - Permission: operation, - }, - RegisteredClaims: &jwt.RegisteredClaims{ - Subject: user, - IssuedAt: jwt.NewNumericDate(time.Now()), - ExpiresAt: jwt.NewNumericDate(time.Now().Add(expiresIn)), - }, - } - - token := jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims) - ss, err := token.SignedString(privateKey) - if err != nil { - logger.logf("Fatal: failed to generate JWT: %s", err) - die("failed to generate token") - } + expiresAtUnix := time.Now().Add(expiresIn).Unix() + + tag := hmac.New(sha256.New, key) + io.WriteString(tag, "git-lfs-authenticate") + tag.Write([]byte{0}) + io.WriteString(tag, repo) + tag.Write([]byte{0}) + io.WriteString(tag, operation) + tag.Write([]byte{0}) + binary.Write(tag, binary.BigEndian, &expiresAtUnix) + tagStr := hex.EncodeToString(tag.Sum(nil)) response := authenticateResponse{ Header: map[string]string{ - "Authorization": "Bearer " + ss, + "Authorization": "Tag " + tagStr, }, ExpiresIn: int64(expiresIn.Seconds()), - } - if repoHRefBase != nil { - response.HRef = repoHRefBase.ResolveReference(&url.URL{ - Path: path.Join(repo+".git", "/info/lfs"), - }).String() + HRef: fmt.Sprintf("%s%s?p=1&te=%d", + hrefBase, + path.Join(repo+".git", "/info/lfs"), + expiresAtUnix, + ), } json.NewEncoder(os.Stdout).Encode(response) } -- cgit v1.2.3