From f6c92c5e2d87ab1334648b0d1293771de7aae4a5 Mon Sep 17 00:00:00 2001 From: Rutger Broekhoff Date: Sat, 30 Dec 2023 14:00:34 +0100 Subject: Implement git-lfs-authenticate --- cmd/git-lfs-authenticate/main.go | 155 +++++++++++++++++++++++++++++++-------- cmd/git-lfs-server/main.go | 32 ++------ 2 files changed, 130 insertions(+), 57 deletions(-) (limited to 'cmd') 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 @@ package main import ( + "bytes" + "crypto/ed25519" + "encoding/hex" + "encoding/json" + "errors" "fmt" "os" + "os/exec" + "strings" + "time" + + "github.com/golang-jwt/jwt/v5" ) func die(msg string, args ...any) { @@ -12,37 +22,118 @@ func die(msg string, args ...any) { os.Exit(1) } +func getGitoliteAccess(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) { + die("failed to query access information") + } + return permGranted +} + +type gitolfs3Claims struct { + Repository string `json:"repository"` + Permission string `json:"permission"` +} + +type customClaims struct { + Gitolfs3 gitolfs3Claims `json:"gitolfs3"` + *jwt.RegisteredClaims +} + +type authenticateResponse struct { + Header map[string]string `json:"header"` + // In seconds. + ExpiresIn int64 `json:"expires_in,omitempty"` + // expires_at (RFC3339) could also be used, but we leave it out since we + // don't use it. +} + +func wipe(b []byte) { + for i := range b { + b[i] = 0 + } +} + func main() { - // if len(os.Args) != 3 { - // die("expected 2 arguments [path, operation], got %d", len(os.Args)-1) - // } - // - // path := strings.TrimPrefix(strings.TrimSuffix(os.Args[1], ".git"), "/") - // operation := os.Args[2] - // - // if operation != "download" && operation != "upload" { - // die("expected operation to be in {upload, download}, got %s", operation) - // } - // - // user := os.Getenv("GL_USER") - // - // if user == "" { - // die("expected Gitolite user env (GL_USER) to be set") - // } - // - // gitolitePerm := "R" - // - // if operation == "upload" { - // gitolitePerm = "W" - // } - // - // // 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) { - // die("failed to query Gitolite access information") - // } + // Even though not explicitly described in the Git LFS documentation, the + // git-lfs-authenticate command is expected to either exit succesfully with + // exit code 0 and to then print credentials in the prescribed JSON format + // to standard out. On errors, the command should exit with a non-zero exit + // 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 + + if len(os.Args) != 3 { + die("expected 2 arguments (path, operation), got %d", len(os.Args)-1) + } + + path := strings.TrimPrefix(strings.TrimSuffix(os.Args[1], ".git"), "/") + operation := os.Args[2] + if operation != "download" && operation != "upload" { + die("expected operation to be upload or download, got %s", operation) + } + + user := os.Getenv("GL_USER") + if user == "" { + die("internal error") + } + keyPath := os.Getenv("GITOLFS3_KEY_PATH") + if keyPath == "" { + die("internal error") + } + keyStr, err := os.ReadFile(keyPath) + if err != nil { + die("internal error") + } + keyStr = bytes.TrimSpace(keyStr) + defer wipe(keyStr) + + if hex.DecodedLen(len(keyStr)) != ed25519.SeedSize { + die("internal error") + } + + seed := make([]byte, ed25519.SeedSize) + defer wipe(seed) + if _, err = hex.Decode(seed, keyStr); err != nil { + die("internal error") + } + privateKey := ed25519.NewKeyFromSeed(seed) + + if !getGitoliteAccess(path, user, "R") { + die("repository not found") + } + if operation == "upload" && !getGitoliteAccess(path, user, "W") { + // User has read access but not write access + die("forbidden") + } + + expiresIn := time.Hour * 24 + claims := customClaims{ + Gitolfs3: gitolfs3Claims{ + Repository: path, + 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 { + die("failed to generate token") + } + + response := authenticateResponse{ + Header: map[string]string{ + "Authorization": "Bearer " + ss, + }, + ExpiresIn: int64(expiresIn.Seconds()), + } + json.NewEncoder(os.Stdout).Encode(response) } diff --git a/cmd/git-lfs-server/main.go b/cmd/git-lfs-server/main.go index 5e99288..c76c5d9 100644 --- a/cmd/git-lfs-server/main.go +++ b/cmd/git-lfs-server/main.go @@ -14,7 +14,6 @@ import ( "path" "regexp" "slices" - "strconv" "strings" "time" "unicode" @@ -54,29 +53,13 @@ type batchRequest struct { HashAlgo hashAlgo `json:"hash_algo,omitempty"` } -type RFC3339SecondsTime time.Time - -func (t RFC3339SecondsTime) MarshalJSON() ([]byte, error) { - b := make([]byte, 0, len(time.RFC3339)+len(`""`)) - b = append(b, '"') - b = time.Time(t).AppendFormat(b, time.RFC3339) - b = append(b, '"') - return b, nil -} - -type SecondDuration time.Duration - -func (d SecondDuration) MarshalJSON() ([]byte, error) { - var b []byte - b = strconv.AppendInt(b, int64(time.Duration(d).Seconds()), 10) - return b, nil -} - type batchAction struct { - HRef string `json:"href"` - Header map[string]string `json:"header,omitempty"` - ExpiresIn *SecondDuration `json:"expires_in,omitempty"` - ExpiresAt *RFC3339SecondsTime `json:"expires_at,omitempty"` + HRef string `json:"href"` + Header map[string]string `json:"header,omitempty"` + // In seconds. + ExpiresIn int64 `json:"expires_in,omitempty"` + // expires_at (RFC3339) could also be used, but we leave it out since we + // don't use it. } type batchError struct { @@ -148,7 +131,6 @@ func makeObjError(obj parsedBatchObject, message string, code int) batchResponse func (h *handler) handleDownloadObject(ctx context.Context, repo string, obj parsedBatchObject) batchResponseObject { fullPath := path.Join(repo+".git", "lfs/objects", obj.firstByte, obj.secondByte, obj.fullHash) expiresIn := time.Hour * 24 - expiresInSeconds := SecondDuration(expiresIn) info, err := h.mc.StatObject(ctx, h.bucket, fullPath, minio.StatObjectOptions{Checksum: true}) if err != nil { @@ -184,7 +166,7 @@ func (h *handler) handleDownloadObject(ctx context.Context, repo string, obj par Actions: map[operation]batchAction{ operationDownload: { HRef: presigned.String(), - ExpiresIn: &expiresInSeconds, + ExpiresIn: int64(expiresIn.Seconds()), }, }, } -- cgit v1.2.3