package handlers import "database/sql" import "encoding/json" import "errors" import "fmt" import "net/http" import "strings" import "codit/internal/db" import "codit/internal/gpg" import "codit/internal/middleware" import "codit/internal/models" const personalGPGKeyUploadMaxBytes int64 = 256 * 1024 // gpgActor returns the acting user from the request context. func (api *API) gpgActor(r *http.Request) (models.User, bool) { return middleware.UserFromContext(r.Context()) } // canManageGPGKey reports whether the user may modify or delete the key. // Admins may manage any key; a regular user may manage only their own // personal keys. func (api *API) canManageGPGKey(r *http.Request, keyID string, user models.User) bool { var owned bool var err error if user.IsAdmin { return true } owned, err = api.store(r).GPGKeyPersonalOwner(keyID, user.ID) if err != nil { return false } return owned } func (api *API) canManageProjectSigningKeys(r *http.Request, projectID string) bool { var user models.User var principal models.ServicePrincipal var ok bool var role string var err error user, ok = middleware.UserFromContext(r.Context()) if ok { if user.IsAdmin { return true } role, err = api.store(r).GetProjectRoleForUser(projectID, user.ID) if err != nil { return false } return roleAllows(role, models.RoleAdmin) } principal, ok = middleware.PrincipalFromContext(r.Context()) if !ok || principal.Disabled { return false } if principal.IsAdmin { return true } role, err = api.store(r).GetPrincipalProjectRole(principal.ID, projectID) if err != nil { return false } return roleAllows(role, models.RoleAdmin) } // ListMyGPGKeys returns the caller's registered personal (public-only) keys, // used for commit/tag signature verification. func (api *API) ListMyGPGKeys(w http.ResponseWriter, r *http.Request, params map[string]string) { var user models.User var ok bool var keys []models.GPGKey var err error var i int user, ok = api.gpgActor(r) if !ok { WriteJSONWithErrorReason(w, r, http.StatusForbidden, "authentication required") return } keys, err = api.store(r).ListPersonalGPGKeys(user.ID) if err != nil { WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error()) return } if keys == nil { keys = []models.GPGKey{} } for i = range keys { keys[i].CanManage = true } WriteJSON(w, http.StatusOK, keys) } // AddMyGPGKey registers a PUBLIC key (pasted by the user) for the caller. The // private half is never sent or stored — the user keeps it locally to sign. func (api *API) AddMyGPGKey(w http.ResponseWriter, r *http.Request, params map[string]string) { var user models.User var ok bool var req struct { Name string `json:"name"` PublicKey string `json:"public_key"` } var km gpg.KeyMaterial var key models.GPGKey var stored models.GPGKey var err error user, ok = api.gpgActor(r) if !ok { WriteJSONWithErrorReason(w, r, http.StatusForbidden, "authentication required") return } r.Body = http.MaxBytesReader(w, r.Body, personalGPGKeyUploadMaxBytes) err = json.NewDecoder(r.Body).Decode(&req) if err != nil { WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "invalid request") return } if strings.TrimSpace(req.PublicKey) == "" { WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "public key is required") return } km, err = gpg.ParsePublic(req.PublicKey) if err != nil { WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "invalid public key: "+err.Error()) return } key = api.buildPersonalKey(user.ID, req.Name, km) // it persists a public-only personal key, rejecting a // duplicate fingerprint already registered by any user. stored, err = api.store(r).CreateGPGKey(r.Context(), key, user.ID, "") if err != nil { if errors.Is(err, db.ErrGPGKeyAlreadyRegistered) { WriteJSONWithErrorReason(w, r, http.StatusConflict, "this key is already registered") return } if errors.Is(err, db.ErrPersonalGPGKeyLimitReached) { WriteJSONWithErrorReason(w, r, http.StatusConflict, "personal gpg key limit reached") return } WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error()) return } stored.CanManage = true WriteJSON(w, http.StatusOK, stored) } // GenerateMyGPGKey is a convenience: it generates a keypair, registers the // PUBLIC half for the caller, and returns the PRIVATE key ONCE for the user to // download and import locally. The private key is never stored server-side. func (api *API) GenerateMyGPGKey(w http.ResponseWriter, r *http.Request, params map[string]string) { var user models.User var ok bool var req struct { Name string `json:"name"` Email string `json:"email"` } var km gpg.KeyMaterial var key models.GPGKey var stored models.GPGKey var err error user, ok = api.gpgActor(r) if !ok { WriteJSONWithErrorReason(w, r, http.StatusForbidden, "authentication required") return } err = json.NewDecoder(r.Body).Decode(&req) if err != nil { WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "invalid request") return } if strings.TrimSpace(req.Name) == "" { WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "name is required") return } km, err = gpg.Generate(req.Name, req.Email) if err != nil { WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error()) return } key = api.buildPersonalKey(user.ID, req.Name, km) stored, err = api.store(r).CreateGPGKey(r.Context(), key, user.ID, "") if err != nil { if errors.Is(err, db.ErrGPGKeyAlreadyRegistered) { WriteJSONWithErrorReason(w, r, http.StatusConflict, "this key is already registered") return } if errors.Is(err, db.ErrPersonalGPGKeyLimitReached) { WriteJSONWithErrorReason(w, r, http.StatusConflict, "personal gpg key limit reached") return } WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error()) return } stored.CanManage = true // Return the private key once, for the user to save and import locally. WriteJSON(w, http.StatusOK, map[string]any{ "key": stored, "private_key": km.PrivateArmored, "public_key": km.PublicArmored, }) } // DeleteMyGPGKey removes one of the caller's registered personal keys. func (api *API) DeleteMyGPGKey(w http.ResponseWriter, r *http.Request, params map[string]string) { var user models.User var ok bool var owned bool var err error user, ok = api.gpgActor(r) if !ok { WriteJSONWithErrorReason(w, r, http.StatusForbidden, "authentication required") return } owned, err = api.store(r).GPGKeyPersonalOwner(params["id"], user.ID) if err != nil { WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error()) return } if !owned { WriteJSONWithErrorReason(w, r, http.StatusForbidden, "not permitted") return } err = api.store(r).DeleteGPGKey(params["id"]) if err != nil { WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error()) return } WriteJSON(w, http.StatusOK, map[string]string{"status": "deleted"}) } // buildPersonalKey assembles a public-only personal GPGKey from key material. func (api *API) buildPersonalKey(_ string, name string, km gpg.KeyMaterial) models.GPGKey { var key models.GPGKey key.Scope = models.GPGKeyScopePersonal key.Name = strings.TrimSpace(name) if key.Name == "" { key.Name = km.KeyID } key.Fingerprint = km.Fingerprint key.KeyID = km.KeyID key.PublicKey = km.PublicArmored key.PrivateKey = "" // public-only: never store the private half return key } // ListAllGPGKeys returns every key for admin oversight (admins may manage any). func (api *API) ListAllGPGKeys(w http.ResponseWriter, r *http.Request, params map[string]string) { var keys []models.GPGKey var err error var i int if !api.requireAdmin(w, r) { return } keys, err = api.store(r).ListGPGKeys() if err != nil { WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error()) return } if keys == nil { keys = []models.GPGKey{} } for i = range keys { keys[i].CanManage = true } WriteJSON(w, http.StatusOK, keys) } func (api *API) GetGPGKey(w http.ResponseWriter, r *http.Request, params map[string]string) { var user models.User var ok bool var key models.GPGKey var err error user, ok = api.gpgActor(r) if !ok { WriteJSONWithErrorReason(w, r, http.StatusForbidden, "authentication required") return } key, err = api.store(r).GetGPGKey(params["id"]) if err != nil { WriteJSONWithErrorReason(w, r, http.StatusNotFound, "gpg key not found") return } // A user may view their own personal key; admins may view any key. if !api.canManageGPGKey(r, key.ID, user) { WriteJSONWithErrorReason(w, r, http.StatusForbidden, "not permitted") return } key.CanManage = true WriteJSON(w, http.StatusOK, key) } // createGlobalGPGKey persists an organization-wide (global) signing key. // Personal keys are public-only and registered via /api/me/gpg-keys instead. func (api *API) createGlobalGPGKey(w http.ResponseWriter, r *http.Request, name string, km gpg.KeyMaterial) { var key models.GPGKey var err error key.Scope = models.GPGKeyScopeGlobal key.Name = strings.TrimSpace(name) key.Fingerprint = km.Fingerprint key.KeyID = km.KeyID key.PublicKey = km.PublicArmored key.PrivateKey = km.PrivateArmored key, err = api.store(r).CreateGPGKey(r.Context(), key, "", "") if err != nil { WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error()) return } key.CanManage = true WriteJSON(w, http.StatusOK, key) } func (api *API) GenerateGPGKey(w http.ResponseWriter, r *http.Request, params map[string]string) { var req struct { Name string `json:"name"` Email string `json:"email"` } var km gpg.KeyMaterial var err error if !api.requireAdmin(w, r) { return } err = json.NewDecoder(r.Body).Decode(&req) if err != nil { WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "invalid request") return } if strings.TrimSpace(req.Name) == "" { WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "name is required") return } km, err = gpg.Generate(req.Name, req.Email) if err != nil { WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error()) return } api.createGlobalGPGKey(w, r, req.Name, km) } func (api *API) ImportGPGKey(w http.ResponseWriter, r *http.Request, params map[string]string) { var req struct { Name string `json:"name"` PrivateKey string `json:"private_key"` } var km gpg.KeyMaterial var err error if !api.requireAdmin(w, r) { return } err = json.NewDecoder(r.Body).Decode(&req) if err != nil { WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "invalid request") return } if strings.TrimSpace(req.Name) == "" { WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "name is required") return } if strings.TrimSpace(req.PrivateKey) == "" { WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "private key is required") return } km, err = gpg.Parse(req.PrivateKey) if err != nil { WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "invalid private key: "+err.Error()) return } api.createGlobalGPGKey(w, r, req.Name, km) } // ListProjectSigningKeys returns the signing keys owned by the project. Any // project viewer may list them. func (api *API) ListProjectSigningKeys(w http.ResponseWriter, r *http.Request, params map[string]string) { var keys []models.GPGKey var err error var i int var canManage bool if !api.requireProjectRole(w, r, params["projectId"], models.RoleViewer) { return } canManage = api.canManageProjectSigningKeys(r, params["projectId"]) keys, err = api.store(r).ListProjectGPGKeys(params["projectId"]) if err != nil { WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error()) return } if keys == nil { keys = []models.GPGKey{} } for i = range keys { keys[i].CanManage = canManage } WriteJSON(w, http.StatusOK, keys) } func (api *API) GetProjectSigningKey(w http.ResponseWriter, r *http.Request, params map[string]string) { var key models.GPGKey var belongs bool var err error var canManage bool if !api.requireProjectRole(w, r, params["projectId"], models.RoleViewer) { return } canManage = api.canManageProjectSigningKeys(r, params["projectId"]) belongs, err = api.store(r).GPGKeyBelongsToProject(params["keyId"], params["projectId"]) if err != nil || !belongs { WriteJSONWithErrorReason(w, r, http.StatusNotFound, "gpg key not found") return } key, err = api.store(r).GetGPGKey(params["keyId"]) if err != nil { WriteJSONWithErrorReason(w, r, http.StatusNotFound, "gpg key not found") return } key.CanManage = canManage WriteJSON(w, http.StatusOK, key) } // ListSelectableSigningKeys returns the keys a project may sign with: the // project's own keys plus all org keys. Used to populate a repo's signing-key // selector. func (api *API) ListSelectableSigningKeys(w http.ResponseWriter, r *http.Request, params map[string]string) { var keys []models.GPGKey var err error if !api.requireProjectRole(w, r, params["projectId"], models.RoleWriter) { return } keys, err = api.store(r).ListSelectableGPGKeys(params["projectId"]) if err != nil { WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error()) return } if keys == nil { keys = []models.GPGKey{} } WriteJSON(w, http.StatusOK, keys) } // createProjectSigningKey persists a project-scoped key after the request has // been authorized as a project admin. func (api *API) createProjectSigningKey(w http.ResponseWriter, r *http.Request, projectID string, name string, km gpg.KeyMaterial) { var key models.GPGKey var err error key = models.GPGKey{ Name: strings.TrimSpace(name), Fingerprint: km.Fingerprint, KeyID: km.KeyID, PublicKey: km.PublicArmored, PrivateKey: km.PrivateArmored, Scope: models.GPGKeyScopeProject, } key, err = api.store(r).CreateGPGKey(r.Context(), key, "", projectID) if err != nil { WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error()) return } key.CanManage = true WriteJSON(w, http.StatusOK, key) } func (api *API) GenerateProjectSigningKey(w http.ResponseWriter, r *http.Request, params map[string]string) { var req struct { Name string `json:"name"` Email string `json:"email"` } var km gpg.KeyMaterial var err error if !api.requireProjectRole(w, r, params["projectId"], models.RoleAdmin) { return } err = json.NewDecoder(r.Body).Decode(&req) if err != nil { WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "invalid request") return } if strings.TrimSpace(req.Name) == "" { WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "name is required") return } km, err = gpg.Generate(req.Name, req.Email) if err != nil { WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error()) return } api.createProjectSigningKey(w, r, params["projectId"], req.Name, km) } func (api *API) ImportProjectSigningKey(w http.ResponseWriter, r *http.Request, params map[string]string) { var req struct { Name string `json:"name"` PrivateKey string `json:"private_key"` } var km gpg.KeyMaterial var err error if !api.requireProjectRole(w, r, params["projectId"], models.RoleAdmin) { return } err = json.NewDecoder(r.Body).Decode(&req) if err != nil { WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "invalid request") return } if strings.TrimSpace(req.Name) == "" { WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "name is required") return } if strings.TrimSpace(req.PrivateKey) == "" { WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "private key is required") return } km, err = gpg.Parse(req.PrivateKey) if err != nil { WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "invalid private key: "+err.Error()) return } api.createProjectSigningKey(w, r, params["projectId"], req.Name, km) } func (api *API) DeleteProjectSigningKey(w http.ResponseWriter, r *http.Request, params map[string]string) { var belongs bool var usage []models.GPGKeyRepoUsage var err error if !api.requireProjectRole(w, r, params["projectId"], models.RoleAdmin) { return } belongs, err = api.store(r).GPGKeyBelongsToProject(params["keyId"], params["projectId"]) if err != nil || !belongs { WriteJSONWithErrorReason(w, r, http.StatusNotFound, "gpg key not found") return } usage, err = api.store(r).ListReposUsingGPGKey(params["keyId"]) if err != nil { WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error()) return } if len(usage) > 0 { var noun string noun = "repository" if len(usage) != 1 { noun = "repositories" } WriteJSONWithErrorReason(w, r, http.StatusConflict, fmt.Sprintf("signing key is in use by %d %s; remove it from them first", len(usage), noun)) return } err = api.store(r).DeleteGPGKey(params["keyId"]) if err != nil { if err == sql.ErrNoRows { WriteJSONWithErrorReason(w, r, http.StatusNotFound, "gpg key not found") return } WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error()) return } WriteJSON(w, http.StatusOK, map[string]string{"status": "deleted"}) } func (api *API) DeleteGPGKey(w http.ResponseWriter, r *http.Request, params map[string]string) { var user models.User var usage []models.GPGKeyRepoUsage var ok bool var err error user, ok = api.gpgActor(r) if !ok { WriteJSONWithErrorReason(w, r, http.StatusForbidden, "authentication required") return } if !api.canManageGPGKey(r, params["id"], user) { WriteJSONWithErrorReason(w, r, http.StatusForbidden, "not permitted") return } usage, err = api.store(r).ListReposUsingGPGKey(params["id"]) if err != nil { WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error()) return } if len(usage) > 0 { var noun string noun = "repository" if len(usage) != 1 { noun = "repositories" } WriteJSONWithErrorReason(w, r, http.StatusConflict, fmt.Sprintf("signing key is in use by %d %s; remove it from them first", len(usage), noun)) return } err = api.store(r).DeleteGPGKey(params["id"]) if err != nil { if err == sql.ErrNoRows { WriteJSONWithErrorReason(w, r, http.StatusNotFound, "gpg key not found") return } WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error()) return } WriteJSON(w, http.StatusOK, map[string]string{"status": "deleted"}) } // GPGKeyUsage returns the repositories that reference this key as their signing // key, so the admin/owner can see who's using it before deleting. func (api *API) GPGKeyUsage(w http.ResponseWriter, r *http.Request, params map[string]string) { var key models.GPGKey var user models.User var ok bool var usage []models.GPGKeyRepoUsage var err error user, ok = api.gpgActor(r) if !ok { WriteJSONWithErrorReason(w, r, http.StatusForbidden, "authentication required") return } key, err = api.store(r).GetGPGKey(params["id"]) if err != nil { WriteJSONWithErrorReason(w, r, http.StatusNotFound, "gpg key not found") return } if !api.canManageGPGKey(r, params["id"], user) { if key.Scope != models.GPGKeyScopeProject || key.OwnerProjectPublicID == "" || !api.canManageProjectSigningKeys(r, key.OwnerProjectPublicID) { WriteJSONWithErrorReason(w, r, http.StatusForbidden, "not permitted") return } } usage, err = api.store(r).ListReposUsingGPGKey(params["id"]) if err != nil { WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error()) return } if usage == nil { usage = []models.GPGKeyRepoUsage{} } WriteJSON(w, http.StatusOK, usage) }