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 | } |