diff options
author | Daniel Baumann <daniel@debian.org> | 2024-10-18 20:33:49 +0200 |
---|---|---|
committer | Daniel Baumann <daniel@debian.org> | 2024-12-12 23:57:56 +0100 |
commit | e68b9d00a6e05b3a941f63ffb696f91e554ac5ec (patch) | |
tree | 97775d6c13b0f416af55314eb6a89ef792474615 /routers/api/packages/chef | |
parent | Initial commit. (diff) | |
download | forgejo-e68b9d00a6e05b3a941f63ffb696f91e554ac5ec.tar.xz forgejo-e68b9d00a6e05b3a941f63ffb696f91e554ac5ec.zip |
Adding upstream version 9.0.3.
Signed-off-by: Daniel Baumann <daniel@debian.org>
Diffstat (limited to '')
-rw-r--r-- | routers/api/packages/chef/auth.go | 274 | ||||
-rw-r--r-- | routers/api/packages/chef/chef.go | 403 |
2 files changed, 677 insertions, 0 deletions
diff --git a/routers/api/packages/chef/auth.go b/routers/api/packages/chef/auth.go new file mode 100644 index 0000000..a790e9a --- /dev/null +++ b/routers/api/packages/chef/auth.go @@ -0,0 +1,274 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package chef + +import ( + "context" + "crypto" + "crypto/rsa" + "crypto/sha1" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/pem" + "fmt" + "hash" + "math/big" + "net/http" + "path" + "regexp" + "slices" + "strconv" + "strings" + "time" + + user_model "code.gitea.io/gitea/models/user" + chef_module "code.gitea.io/gitea/modules/packages/chef" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/services/auth" +) + +const ( + maxTimeDifference = 10 * time.Minute +) + +var ( + algorithmPattern = regexp.MustCompile(`algorithm=(\w+)`) + versionPattern = regexp.MustCompile(`version=(\d+\.\d+)`) + authorizationPattern = regexp.MustCompile(`\AX-Ops-Authorization-(\d+)`) + + _ auth.Method = &Auth{} +) + +// Documentation: +// https://docs.chef.io/server/api_chef_server/#required-headers +// https://github.com/chef-boneyard/chef-rfc/blob/master/rfc065-sign-v1.3.md +// https://github.com/chef/mixlib-authentication/blob/bc8adbef833d4be23dc78cb23e6fe44b51ebc34f/lib/mixlib/authentication/signedheaderauth.rb + +type Auth struct{} + +func (a *Auth) Name() string { + return "chef" +} + +// Verify extracts the user from the signed request +// If the request is signed with the user private key the user is verified. +func (a *Auth) Verify(req *http.Request, w http.ResponseWriter, store auth.DataStore, sess auth.SessionStore) (*user_model.User, error) { + u, err := getUserFromRequest(req) + if err != nil { + return nil, err + } + if u == nil { + return nil, nil + } + + pub, err := getUserPublicKey(req.Context(), u) + if err != nil { + return nil, err + } + + if err := verifyTimestamp(req); err != nil { + return nil, err + } + + version, err := getSignVersion(req) + if err != nil { + return nil, err + } + + if err := verifySignedHeaders(req, version, pub.(*rsa.PublicKey)); err != nil { + return nil, err + } + + return u, nil +} + +func getUserFromRequest(req *http.Request) (*user_model.User, error) { + username := req.Header.Get("X-Ops-Userid") + if username == "" { + return nil, nil + } + + return user_model.GetUserByName(req.Context(), username) +} + +func getUserPublicKey(ctx context.Context, u *user_model.User) (crypto.PublicKey, error) { + pubKey, err := user_model.GetSetting(ctx, u.ID, chef_module.SettingPublicPem) + if err != nil { + return nil, err + } + + pubPem, _ := pem.Decode([]byte(pubKey)) + + return x509.ParsePKIXPublicKey(pubPem.Bytes) +} + +func verifyTimestamp(req *http.Request) error { + hdr := req.Header.Get("X-Ops-Timestamp") + if hdr == "" { + return util.NewInvalidArgumentErrorf("X-Ops-Timestamp header missing") + } + + ts, err := time.Parse(time.RFC3339, hdr) + if err != nil { + return err + } + + diff := time.Now().UTC().Sub(ts) + if diff < 0 { + diff = -diff + } + + if diff > maxTimeDifference { + return fmt.Errorf("time difference") + } + + return nil +} + +func getSignVersion(req *http.Request) (string, error) { + hdr := req.Header.Get("X-Ops-Sign") + if hdr == "" { + return "", util.NewInvalidArgumentErrorf("X-Ops-Sign header missing") + } + + m := versionPattern.FindStringSubmatch(hdr) + if len(m) != 2 { + return "", util.NewInvalidArgumentErrorf("invalid X-Ops-Sign header") + } + + switch m[1] { + case "1.0", "1.1", "1.2", "1.3": + default: + return "", util.NewInvalidArgumentErrorf("unsupported version") + } + + version := m[1] + + m = algorithmPattern.FindStringSubmatch(hdr) + if len(m) == 2 && m[1] != "sha1" && !(m[1] == "sha256" && version == "1.3") { + return "", util.NewInvalidArgumentErrorf("unsupported algorithm") + } + + return version, nil +} + +func verifySignedHeaders(req *http.Request, version string, pub *rsa.PublicKey) error { + authorizationData, err := getAuthorizationData(req) + if err != nil { + return err + } + + checkData := buildCheckData(req, version) + + switch version { + case "1.3": + return verifyDataNew(authorizationData, checkData, pub, crypto.SHA256) + case "1.2": + return verifyDataNew(authorizationData, checkData, pub, crypto.SHA1) + default: + return verifyDataOld(authorizationData, checkData, pub) + } +} + +func getAuthorizationData(req *http.Request) ([]byte, error) { + valueList := make(map[int]string) + for k, vs := range req.Header { + if m := authorizationPattern.FindStringSubmatch(k); m != nil { + index, _ := strconv.Atoi(m[1]) + var v string + if len(vs) == 0 { + v = "" + } else { + v = vs[0] + } + valueList[index] = v + } + } + + tmp := make([]string, len(valueList)) + for k, v := range valueList { + if k > len(tmp) { + return nil, fmt.Errorf("invalid X-Ops-Authorization headers") + } + tmp[k-1] = v + } + + return base64.StdEncoding.DecodeString(strings.Join(tmp, "")) +} + +func buildCheckData(req *http.Request, version string) []byte { + username := req.Header.Get("X-Ops-Userid") + if version != "1.0" && version != "1.3" { + sum := sha1.Sum([]byte(username)) + username = base64.StdEncoding.EncodeToString(sum[:]) + } + + var data string + if version == "1.3" { + data = fmt.Sprintf( + "Method:%s\nPath:%s\nX-Ops-Content-Hash:%s\nX-Ops-Sign:version=%s\nX-Ops-Timestamp:%s\nX-Ops-UserId:%s\nX-Ops-Server-API-Version:%s", + req.Method, + path.Clean(req.URL.Path), + req.Header.Get("X-Ops-Content-Hash"), + version, + req.Header.Get("X-Ops-Timestamp"), + username, + req.Header.Get("X-Ops-Server-Api-Version"), + ) + } else { + sum := sha1.Sum([]byte(path.Clean(req.URL.Path))) + data = fmt.Sprintf( + "Method:%s\nHashed Path:%s\nX-Ops-Content-Hash:%s\nX-Ops-Timestamp:%s\nX-Ops-UserId:%s", + req.Method, + base64.StdEncoding.EncodeToString(sum[:]), + req.Header.Get("X-Ops-Content-Hash"), + req.Header.Get("X-Ops-Timestamp"), + username, + ) + } + + return []byte(data) +} + +func verifyDataNew(signature, data []byte, pub *rsa.PublicKey, algo crypto.Hash) error { + var h hash.Hash + if algo == crypto.SHA256 { + h = sha256.New() + } else { + h = sha1.New() + } + if _, err := h.Write(data); err != nil { + return err + } + + return rsa.VerifyPKCS1v15(pub, algo, h.Sum(nil), signature) +} + +func verifyDataOld(signature, data []byte, pub *rsa.PublicKey) error { + c := new(big.Int) + m := new(big.Int) + m.SetBytes(signature) + e := big.NewInt(int64(pub.E)) + c.Exp(m, e, pub.N) + + out := c.Bytes() + + skip := 0 + for i := 2; i < len(out); i++ { + if i+1 >= len(out) { + break + } + if out[i] == 0xFF && out[i+1] == 0 { + skip = i + 2 + break + } + } + + if !slices.Equal(out[skip:], data) { + return fmt.Errorf("could not verify signature") + } + + return nil +} diff --git a/routers/api/packages/chef/chef.go b/routers/api/packages/chef/chef.go new file mode 100644 index 0000000..b49f4e9 --- /dev/null +++ b/routers/api/packages/chef/chef.go @@ -0,0 +1,403 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package chef + +import ( + "errors" + "fmt" + "io" + "net/http" + "net/url" + "sort" + "strings" + "time" + + "code.gitea.io/gitea/models/db" + packages_model "code.gitea.io/gitea/models/packages" + "code.gitea.io/gitea/modules/optional" + packages_module "code.gitea.io/gitea/modules/packages" + chef_module "code.gitea.io/gitea/modules/packages/chef" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/routers/api/packages/helper" + "code.gitea.io/gitea/services/context" + packages_service "code.gitea.io/gitea/services/packages" +) + +func apiError(ctx *context.Context, status int, obj any) { + type Error struct { + ErrorMessages []string `json:"error_messages"` + } + + helper.LogAndProcessError(ctx, status, obj, func(message string) { + ctx.JSON(status, Error{ + ErrorMessages: []string{message}, + }) + }) +} + +func PackagesUniverse(ctx *context.Context) { + pvs, _, err := packages_model.SearchVersions(ctx, &packages_model.PackageSearchOptions{ + OwnerID: ctx.Package.Owner.ID, + Type: packages_model.TypeChef, + IsInternal: optional.Some(false), + }) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + pds, err := packages_model.GetPackageDescriptors(ctx, pvs) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + type VersionInfo struct { + LocationType string `json:"location_type"` + LocationPath string `json:"location_path"` + DownloadURL string `json:"download_url"` + Dependencies map[string]string `json:"dependencies"` + } + + baseURL := setting.AppURL + "api/packages/" + ctx.Package.Owner.Name + "/chef/api/v1" + + universe := make(map[string]map[string]*VersionInfo) + for _, pd := range pds { + if _, ok := universe[pd.Package.Name]; !ok { + universe[pd.Package.Name] = make(map[string]*VersionInfo) + } + universe[pd.Package.Name][pd.Version.Version] = &VersionInfo{ + LocationType: "opscode", + LocationPath: baseURL, + DownloadURL: fmt.Sprintf("%s/cookbooks/%s/versions/%s/download", baseURL, url.PathEscape(pd.Package.Name), pd.Version.Version), + Dependencies: pd.Metadata.(*chef_module.Metadata).Dependencies, + } + } + + ctx.JSON(http.StatusOK, universe) +} + +// https://github.com/chef/chef/blob/main/knife/lib/chef/knife/supermarket_list.rb +// https://github.com/chef/chef/blob/main/knife/lib/chef/knife/supermarket_search.rb +func EnumeratePackages(ctx *context.Context) { + opts := &packages_model.PackageSearchOptions{ + OwnerID: ctx.Package.Owner.ID, + Type: packages_model.TypeChef, + Name: packages_model.SearchValue{Value: ctx.FormTrim("q")}, + IsInternal: optional.Some(false), + Paginator: db.NewAbsoluteListOptions( + ctx.FormInt("start"), + ctx.FormInt("items"), + ), + } + + switch strings.ToLower(ctx.FormTrim("order")) { + case "recently_updated", "recently_added": + opts.Sort = packages_model.SortCreatedDesc + default: + opts.Sort = packages_model.SortNameAsc + } + + pvs, total, err := packages_model.SearchLatestVersions(ctx, opts) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + pds, err := packages_model.GetPackageDescriptors(ctx, pvs) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + type Item struct { + CookbookName string `json:"cookbook_name"` + CookbookMaintainer string `json:"cookbook_maintainer"` + CookbookDescription string `json:"cookbook_description"` + Cookbook string `json:"cookbook"` + } + + type Result struct { + Start int `json:"start"` + Total int `json:"total"` + Items []*Item `json:"items"` + } + + baseURL := setting.AppURL + "api/packages/" + ctx.Package.Owner.Name + "/chef/api/v1/cookbooks/" + + items := make([]*Item, 0, len(pds)) + for _, pd := range pds { + metadata := pd.Metadata.(*chef_module.Metadata) + + items = append(items, &Item{ + CookbookName: pd.Package.Name, + CookbookMaintainer: metadata.Author, + CookbookDescription: metadata.Description, + Cookbook: baseURL + url.PathEscape(pd.Package.Name), + }) + } + + skip, _ := opts.Paginator.GetSkipTake() + + ctx.JSON(http.StatusOK, &Result{ + Start: skip, + Total: int(total), + Items: items, + }) +} + +// https://github.com/chef/chef/blob/main/knife/lib/chef/knife/supermarket_show.rb +func PackageMetadata(ctx *context.Context) { + packageName := ctx.Params("name") + + pvs, err := packages_model.GetVersionsByPackageName(ctx, ctx.Package.Owner.ID, packages_model.TypeChef, packageName) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + if len(pvs) == 0 { + apiError(ctx, http.StatusNotFound, nil) + return + } + + pds, err := packages_model.GetPackageDescriptors(ctx, pvs) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + sort.Slice(pds, func(i, j int) bool { + return pds[i].SemVer.LessThan(pds[j].SemVer) + }) + + type Result struct { + Name string `json:"name"` + Maintainer string `json:"maintainer"` + Description string `json:"description"` + Category string `json:"category"` + LatestVersion string `json:"latest_version"` + SourceURL string `json:"source_url"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Deprecated bool `json:"deprecated"` + Versions []string `json:"versions"` + } + + baseURL := fmt.Sprintf("%sapi/packages/%s/chef/api/v1/cookbooks/%s/versions/", setting.AppURL, ctx.Package.Owner.Name, url.PathEscape(packageName)) + + versions := make([]string, 0, len(pds)) + for _, pd := range pds { + versions = append(versions, baseURL+pd.Version.Version) + } + + latest := pds[len(pds)-1] + + metadata := latest.Metadata.(*chef_module.Metadata) + + ctx.JSON(http.StatusOK, &Result{ + Name: latest.Package.Name, + Maintainer: metadata.Author, + Description: metadata.Description, + LatestVersion: baseURL + latest.Version.Version, + SourceURL: metadata.RepositoryURL, + CreatedAt: latest.Version.CreatedUnix.AsLocalTime(), + UpdatedAt: latest.Version.CreatedUnix.AsLocalTime(), + Deprecated: false, + Versions: versions, + }) +} + +// https://github.com/chef/chef/blob/main/knife/lib/chef/knife/supermarket_show.rb +func PackageVersionMetadata(ctx *context.Context) { + packageName := ctx.Params("name") + packageVersion := strings.ReplaceAll(ctx.Params("version"), "_", ".") // Chef calls this endpoint with "_" instead of "."?! + + pv, err := packages_model.GetVersionByNameAndVersion(ctx, ctx.Package.Owner.ID, packages_model.TypeChef, packageName, packageVersion) + if err != nil { + if err == packages_model.ErrPackageNotExist { + apiError(ctx, http.StatusNotFound, err) + return + } + apiError(ctx, http.StatusInternalServerError, err) + return + } + + pd, err := packages_model.GetPackageDescriptor(ctx, pv) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + type Result struct { + Version string `json:"version"` + TarballFileSize int64 `json:"tarball_file_size"` + PublishedAt time.Time `json:"published_at"` + Cookbook string `json:"cookbook"` + File string `json:"file"` + License string `json:"license"` + Dependencies map[string]string `json:"dependencies"` + } + + baseURL := fmt.Sprintf("%sapi/packages/%s/chef/api/v1/cookbooks/%s", setting.AppURL, ctx.Package.Owner.Name, url.PathEscape(pd.Package.Name)) + + metadata := pd.Metadata.(*chef_module.Metadata) + + ctx.JSON(http.StatusOK, &Result{ + Version: pd.Version.Version, + TarballFileSize: pd.Files[0].Blob.Size, + PublishedAt: pd.Version.CreatedUnix.AsLocalTime(), + Cookbook: baseURL, + File: fmt.Sprintf("%s/versions/%s/download", baseURL, pd.Version.Version), + License: metadata.License, + Dependencies: metadata.Dependencies, + }) +} + +// https://github.com/chef/chef/blob/main/knife/lib/chef/knife/supermarket_share.rb +func UploadPackage(ctx *context.Context) { + file, _, err := ctx.Req.FormFile("tarball") + if err != nil { + apiError(ctx, http.StatusBadRequest, err) + return + } + defer file.Close() + + buf, err := packages_module.CreateHashedBufferFromReader(file) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + defer buf.Close() + + pck, err := chef_module.ParsePackage(buf) + if err != nil { + if errors.Is(err, util.ErrInvalidArgument) { + apiError(ctx, http.StatusBadRequest, err) + } else { + apiError(ctx, http.StatusInternalServerError, err) + } + return + } + + if _, err := buf.Seek(0, io.SeekStart); err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + _, _, err = packages_service.CreatePackageAndAddFile( + ctx, + &packages_service.PackageCreationInfo{ + PackageInfo: packages_service.PackageInfo{ + Owner: ctx.Package.Owner, + PackageType: packages_model.TypeChef, + Name: pck.Name, + Version: pck.Version, + }, + Creator: ctx.Doer, + SemverCompatible: true, + Metadata: pck.Metadata, + }, + &packages_service.PackageFileCreationInfo{ + PackageFileInfo: packages_service.PackageFileInfo{ + Filename: strings.ToLower(pck.Version + ".tar.gz"), + }, + Creator: ctx.Doer, + Data: buf, + IsLead: true, + }, + ) + if err != nil { + switch err { + case packages_model.ErrDuplicatePackageVersion: + apiError(ctx, http.StatusConflict, err) + case packages_service.ErrQuotaTotalCount, packages_service.ErrQuotaTypeSize, packages_service.ErrQuotaTotalSize: + apiError(ctx, http.StatusForbidden, err) + default: + apiError(ctx, http.StatusInternalServerError, err) + } + return + } + + ctx.JSON(http.StatusCreated, make(map[any]any)) +} + +// https://github.com/chef/chef/blob/main/knife/lib/chef/knife/supermarket_download.rb +func DownloadPackage(ctx *context.Context) { + pv, err := packages_model.GetVersionByNameAndVersion(ctx, ctx.Package.Owner.ID, packages_model.TypeChef, ctx.Params("name"), ctx.Params("version")) + if err != nil { + if err == packages_model.ErrPackageNotExist { + apiError(ctx, http.StatusNotFound, err) + return + } + apiError(ctx, http.StatusInternalServerError, err) + return + } + + pd, err := packages_model.GetPackageDescriptor(ctx, pv) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + pf := pd.Files[0].File + + s, u, _, err := packages_service.GetPackageFileStream(ctx, pf) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + helper.ServePackageFile(ctx, s, u, pf) +} + +// https://github.com/chef/chef/blob/main/knife/lib/chef/knife/supermarket_unshare.rb +func DeletePackageVersion(ctx *context.Context) { + packageName := ctx.Params("name") + packageVersion := ctx.Params("version") + + err := packages_service.RemovePackageVersionByNameAndVersion( + ctx, + ctx.Doer, + &packages_service.PackageInfo{ + Owner: ctx.Package.Owner, + PackageType: packages_model.TypeChef, + Name: packageName, + Version: packageVersion, + }, + ) + if err != nil { + if err == packages_model.ErrPackageNotExist { + apiError(ctx, http.StatusNotFound, err) + } else { + apiError(ctx, http.StatusInternalServerError, err) + } + return + } + + ctx.Status(http.StatusOK) +} + +// https://github.com/chef/chef/blob/main/knife/lib/chef/knife/supermarket_unshare.rb +func DeletePackage(ctx *context.Context) { + pvs, err := packages_model.GetVersionsByPackageName(ctx, ctx.Package.Owner.ID, packages_model.TypeChef, ctx.Params("name")) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + if len(pvs) == 0 { + apiError(ctx, http.StatusNotFound, err) + return + } + + for _, pv := range pvs { + if err := packages_service.RemovePackageVersion(ctx, ctx.Doer, pv); err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + } + + ctx.Status(http.StatusOK) +} |