diff --git a/cmd/generate.go b/cmd/generate.go index f72ee16390486..44dfce5700636 100644 --- a/cmd/generate.go +++ b/cmd/generate.go @@ -5,10 +5,14 @@ package cmd import ( + "encoding/base64" "fmt" "os" "code.gitea.io/gitea/modules/generate" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/services/secrets" "github.com/mattn/go-isatty" "github.com/urfave/cli" @@ -31,6 +35,7 @@ var ( microcmdGenerateInternalToken, microcmdGenerateLfsJwtSecret, microcmdGenerateSecretKey, + microcmdGenerateMasterKey, }, } @@ -52,6 +57,12 @@ var ( Usage: "Generate a new SECRET_KEY", Action: runGenerateSecretKey, } + + microcmdGenerateMasterKey = cli.Command{ + Name: "MASTER_KEY", + Usage: "Generate a new MASTER_KEY", + Action: runGenerateMasterKey, + } ) func runGenerateInternalToken(c *cli.Context) error { @@ -98,3 +109,46 @@ func runGenerateSecretKey(c *cli.Context) error { return nil } + +func runGenerateMasterKey(c *cli.Context) error { + // Silence the console logger + log.DelNamedLogger("console") + log.DelNamedLogger(log.DEFAULT) + + // Read configuration file + setting.LoadFromExisting() + + providerType := secrets.MasterKeyProviderType(setting.MasterKeyProvider) + if providerType == secrets.MasterKeyProviderTypeNone { + return fmt.Errorf("configured master key provider does not support key generation") + } + + if err := secrets.Init(); err != nil { + return err + } + + scrts, err := secrets.GenerateMasterKey() + if err != nil { + return err + } + + if len(scrts) > 1 { + fmt.Println("Unseal secrets:") + for i, secret := range scrts { + if i > 0 { + fmt.Printf("\n") + } + fmt.Printf("%s\n", base64.StdEncoding.EncodeToString(secret)) + } + } + + if providerType == secrets.MasterKeyProviderTypePlain && len(scrts) == 1 { + fmt.Printf("%s", base64.StdEncoding.EncodeToString(scrts[0])) + + if isatty.IsTerminal(os.Stdout.Fd()) { + fmt.Printf("\n") + } + } + + return nil +} diff --git a/models/auth/secret.go b/models/auth/secret.go new file mode 100644 index 0000000000000..dfbe1a8a15885 --- /dev/null +++ b/models/auth/secret.go @@ -0,0 +1,62 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "fmt" + "regexp" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/modules/timeutil" + "code.gitea.io/gitea/modules/util" +) + +type ErrSecretInvalidValue struct { + Name *string + Data *string +} + +func (err ErrSecretInvalidValue) Error() string { + if err.Name != nil { + return fmt.Sprintf("secret name %q is invalid", *err.Name) + } + if err.Data != nil { + return fmt.Sprintf("secret data %q is invalid", *err.Data) + } + return util.ErrInvalidArgument.Error() +} + +func (err ErrSecretInvalidValue) Unwrap() error { + return util.ErrInvalidArgument +} + +var nameRE = regexp.MustCompile("^[a-zA-Z_][a-zA-Z0-9-_.]*$") + +// Secret represents a secret +type Secret struct { + ID int64 + OwnerID int64 `xorm:"UNIQUE(owner_repo_name) NOTNULL"` + RepoID int64 `xorm:"UNIQUE(owner_repo_name) NOTNULL"` + Name string `xorm:"UNIQUE(owner_repo_name) NOTNULL"` + Data string `xorm:"LONGTEXT"` // encrypted data, or plaintext data if there's no master key + CreatedUnix timeutil.TimeStamp `xorm:"created NOTNULL"` +} + +func init() { + db.RegisterModel(new(Secret)) +} + +// Validate validates the required fields and formats. +func (s *Secret) Validate() error { + switch { + case len(s.Name) == 0: + return ErrSecretInvalidValue{Name: &s.Name} + case len(s.Data) == 0: + return ErrSecretInvalidValue{Data: &s.Data} + case nameRE.MatchString(s.Name): + return ErrSecretInvalidValue{Name: &s.Name} + default: + return nil + } +} diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index e718355f8349f..591bfa3e86a63 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -442,6 +442,8 @@ var migrations = []Migration{ NewMigration("Add package cleanup rule table", v1_19.CreatePackageCleanupRuleTable), // v235 -> v236 NewMigration("Add index for access_token", v1_19.AddIndexForAccessToken), + // v236 -> v237 + NewMigration("Create secrets table", v1_19.CreateSecretsTable), } // GetCurrentDBVersion returns the current db version diff --git a/models/migrations/v1_19/v236.go b/models/migrations/v1_19/v236.go new file mode 100644 index 0000000000000..330df0bc8994b --- /dev/null +++ b/models/migrations/v1_19/v236.go @@ -0,0 +1,23 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_19 //nolint + +import ( + "code.gitea.io/gitea/modules/timeutil" + + "xorm.io/xorm" +) + +func CreateSecretsTable(x *xorm.Engine) error { + type Secret struct { + ID int64 + OwnerID int64 `xorm:"UNIQUE(owner_repo_name) NOTNULL"` + RepoID int64 `xorm:"UNIQUE(owner_repo_name) NOTNULL"` + Name string `xorm:"UNIQUE(owner_repo_name) NOTNULL"` + Data string `xorm:"LONGTEXT"` + CreatedUnix timeutil.TimeStamp `xorm:"created NOTNULL"` + } + + return x.Sync(new(Secret)) +} diff --git a/modules/generate/generate.go b/modules/generate/generate.go index f29634e05e663..2b7423b9d9542 100644 --- a/modules/generate/generate.go +++ b/modules/generate/generate.go @@ -66,3 +66,8 @@ func NewSecretKey() (string, error) { return secretKey, nil } + +// NewMasterKey generate a new value intended to be used by MASTER_KEY. +func NewMasterKey() ([]byte, error) { + return util.CryptoRandomBytes(64) +} diff --git a/modules/setting/setting.go b/modules/setting/setting.go index 47e0ae2cda1f5..f678fe0e35f98 100644 --- a/modules/setting/setting.go +++ b/modules/setting/setting.go @@ -5,6 +5,7 @@ package setting import ( + "crypto/sha1" "encoding/base64" "fmt" "math" @@ -27,6 +28,7 @@ import ( "code.gitea.io/gitea/modules/user" "code.gitea.io/gitea/modules/util" + "golang.org/x/crypto/pbkdf2" gossh "golang.org/x/crypto/ssh" ini "gopkg.in/ini.v1" ) @@ -214,6 +216,8 @@ var ( HMACKey string `ini:"HMAC_KEY"` Allways bool }{} + MasterKeyProvider string + MasterKey []byte // UI settings UI = struct { @@ -973,6 +977,19 @@ func loadFromConf(allowEmpty bool, extraConfig string) { PasswordCheckPwn = sec.Key("PASSWORD_CHECK_PWN").MustBool(false) SuccessfulTokensCacheSize = sec.Key("SUCCESSFUL_TOKENS_CACHE_SIZE").MustInt(20) + // Master key provider configuration + MasterKeyProvider = sec.Key("MASTER_KEY_PROVIDER").MustString("plain") + switch MasterKeyProvider { + case "plain": + tempSalt := []byte{'g', 'i', 't', 'e', 'a'} + MasterKey = []byte(sec.Key("MASTER_KEY").MustString(SecretKey)) + MasterKey = pbkdf2.Key(MasterKey, tempSalt, 4096, 32, sha1.New) + case "none": + default: + log.Fatal("invalid master key provider type: %v", MasterKeyProvider) + return + } + InternalToken = loadSecret(sec, "INTERNAL_TOKEN_URI", "INTERNAL_TOKEN") if InstallLock && InternalToken == "" { // if Gitea has been installed but the InternalToken hasn't been generated (upgrade from an old release), we should generate diff --git a/modules/templates/helper.go b/modules/templates/helper.go index 7b997b49d9e7d..b4f46d4c6d45e 100644 --- a/modules/templates/helper.go +++ b/modules/templates/helper.go @@ -46,6 +46,7 @@ import ( "code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/services/gitdiff" + secret_service "code.gitea.io/gitea/services/secrets" "github.com/editorconfig/editorconfig-core-go/v2" ) @@ -459,6 +460,13 @@ func NewFuncMap() []template.FuncMap { return items }, "HasPrefix": strings.HasPrefix, + "Shadow": func(s string) string { + return "******" + }, + "DecryptSecret": func(s string) string { + v, _ := secret_service.DecryptString(s) + return v + }, "CompareLink": func(baseRepo, repo *repo_model.Repository, branchName string) string { var curBranch string if repo.ID != baseRepo.ID { diff --git a/modules/web/middleware/binding.go b/modules/web/middleware/binding.go index 733f00a1d5662..4e025e6dfa2df 100644 --- a/modules/web/middleware/binding.go +++ b/modules/web/middleware/binding.go @@ -78,6 +78,11 @@ func GetInclude(field reflect.StructField) string { return getRuleBody(field, "Include(") } +// GetIn get allowed values in form tag +func GetIn(field reflect.StructField) string { + return getRuleBody(field, "In(") +} + // Validate validate TODO: func Validate(errs binding.Errors, data map[string]interface{}, f Form, l translation.Locale) binding.Errors { if errs.Len() == 0 { @@ -130,6 +135,8 @@ func Validate(errs binding.Errors, data map[string]interface{}, f Form, l transl data["ErrorMsg"] = trName + l.Tr("form.url_error", errs[0].Message) case binding.ERR_INCLUDE: data["ErrorMsg"] = trName + l.Tr("form.include_error", GetInclude(field)) + case binding.ERR_IN: + data["ErrorMsg"] = trName + l.Tr("form.in_error", strings.Join(strings.Split(GetIn(field), ","), ", ")) case validation.ErrGlobPattern: data["ErrorMsg"] = trName + l.Tr("form.glob_pattern_error", errs[0].Message) case validation.ErrRegexPattern: diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index 70f982b8dcc59..105dee9cdddec 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -185,6 +185,12 @@ app_url_helper = Base address for HTTP(S) clone URLs and email notifications. log_root_path = Log Path log_root_path_helper = Log files will be written to this directory. +security_title = Security Settings +master_key_provider = Master Key Provider +master_key_provider_none = None +master_key_provider_plain = Plain +master_key_provider_helper = Master Key Provider to use to store secret key that will be used for other secret encryption. Use "None" to not encrypt secrets. Use "Plain" to store automatically generated secret in configuration file. + optional_title = Optional Settings email_title = Email Settings smtp_addr = SMTP Host @@ -243,6 +249,7 @@ no_reply_address = Hidden Email Domain no_reply_address_helper = Domain name for users with a hidden email address. For example, the username 'joe' will be logged in Git as 'joe@noreply.example.org' if the hidden email domain is set to 'noreply.example.org'. password_algorithm = Password Hash Algorithm password_algorithm_helper = Set the password hashing algorithm. Algorithms have differing requirements and strength. `argon2` whilst having good characteristics uses a lot of memory and may be inappropriate for small systems. +master_key_failed = Failed to generate master key: %v enable_update_checker = Enable Update Checker enable_update_checker_helper = Checks for new version releases periodically by connecting to gitea.io. @@ -466,6 +473,7 @@ max_size_error = ` must contain at most %s characters.` email_error = ` is not a valid email address.` url_error = `'%s' is not a valid URL.` include_error = ` must contain substring '%s'.` +in_error = ` can only contain specific values: %s.` glob_pattern_error = ` glob pattern is invalid: %s.` regex_pattern_error = ` regex pattern is invalid: %s.` username_error = ` can only contain alphanumeric chars ('0-9','a-z','A-Z'), dash ('-'), underscore ('_') and dot ('.'). It cannot begin or end with non-alphanumeric chars, and consecutive non-alphanumeric chars are also forbidden.` @@ -2059,6 +2067,18 @@ settings.deploy_key_desc = Deploy keys have read-only pull access to the reposit settings.is_writable = Enable Write Access settings.is_writable_info = Allow this deploy key to push to the repository. settings.no_deploy_keys = There are no deploy keys yet. +settings.secrets = Secrets +settings.add_secret = Add Secret +settings.add_secret_success = The secret '%s' has been added. +settings.secret_value_content_placeholder = Input any content +settings.secret_desc = Secrets will be passed to certain actions and cannot be read otherwise. +settings.secret_content = Value +settings.secret_name = Name +settings.no_secret = There are no secrets yet. +settings.secret_deletion = Remove secret +settings.secret_deletion_desc = Removing a secret will revoke its access to this repository. Continue? +settings.secret_deletion_success = The secret has been removed. +settings.secret_deletion_failed = Failed to remove secret. settings.title = Title settings.deploy_key_content = Content settings.key_been_used = A deploy key with identical content is already in use. @@ -2377,6 +2397,7 @@ settings.update_setting_success = Organization settings have been updated. settings.change_orgname_prompt = Note: changing the organization name also changes the organization's URL. settings.change_orgname_redirect_prompt = The old name will redirect until it is claimed. settings.update_avatar_success = The organization's avatar has been updated. +settings.secrets = Secrets settings.delete = Delete Organization settings.delete_account = Delete This Organization settings.delete_prompt = The organization will be permanently removed. This CANNOT be undone! diff --git a/routers/init.go b/routers/init.go index 670191debc664..4345b60291705 100644 --- a/routers/init.go +++ b/routers/init.go @@ -46,6 +46,7 @@ import ( pull_service "code.gitea.io/gitea/services/pull" repo_service "code.gitea.io/gitea/services/repository" "code.gitea.io/gitea/services/repository/archiver" + secret_service "code.gitea.io/gitea/services/secrets" "code.gitea.io/gitea/services/task" "code.gitea.io/gitea/services/webhook" ) @@ -149,6 +150,8 @@ func GlobalInitInstalled(ctx context.Context) { mustInit(models.Init) mustInit(repo_service.Init) + mustInit(secret_service.Init) + // Booting long running goroutines. issue_indexer.InitIssueIndexer(false) code_indexer.Init() diff --git a/routers/install/install.go b/routers/install/install.go index ab37f9ba35fed..96785ad3af0f3 100644 --- a/routers/install/install.go +++ b/routers/install/install.go @@ -6,6 +6,7 @@ package install import ( goctx "context" + "encoding/base64" "fmt" "net/http" "os" @@ -33,6 +34,7 @@ import ( "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/modules/web/middleware" "code.gitea.io/gitea/services/forms" + "code.gitea.io/gitea/services/secrets" "gitea.com/go-chi/session" "gopkg.in/ini.v1" @@ -162,6 +164,7 @@ func Install(ctx *context.Context) { form.DefaultEnableTimetracking = setting.Service.DefaultEnableTimetracking form.NoReplyAddress = setting.Service.NoReplyAddress form.PasswordAlgorithm = setting.PasswordHashAlgo + form.MasterKeyProvider = secrets.MasterKeyProviderTypePlain middleware.AssignForm(form, ctx.Data) ctx.HTML(http.StatusOK, tplInstall) @@ -387,10 +390,40 @@ func SubmitInstall(ctx *context.Context) { log.Error("Failed to load custom conf '%s': %v", setting.CustomConf, err) } } + + // Setup master key provider + cfg.Section("security").Key("MASTER_KEY_PROVIDER").SetValue(string(form.MasterKeyProvider)) + var provider secrets.MasterKeyProvider + switch form.MasterKeyProvider { + case secrets.MasterKeyProviderTypePlain: + provider = secrets.NewPlainMasterKeyProvider() + } + var masterKey []byte + if provider != nil { + if err = provider.Init(); err != nil { + ctx.RenderWithErr(ctx.Tr("install.master_key_failed", err), tplInstall, &form) + return + } + // Generate master key + if _, err = provider.GenerateMasterKey(); err != nil { + ctx.RenderWithErr(ctx.Tr("install.master_key_failed", err), tplInstall, &form) + return + } + masterKey, err = provider.GetMasterKey() + if err != nil { + ctx.RenderWithErr(ctx.Tr("install.master_key_failed", err), tplInstall, &form) + return + } + if form.MasterKeyProvider == secrets.MasterKeyProviderTypePlain { + cfg.Section("security").Key("MASTER_KEY").SetValue(base64.StdEncoding.EncodeToString(masterKey)) + } + } + cfg.Section("database").Key("DB_TYPE").SetValue(setting.Database.Type) cfg.Section("database").Key("HOST").SetValue(setting.Database.Host) cfg.Section("database").Key("NAME").SetValue(setting.Database.Name) cfg.Section("database").Key("USER").SetValue(setting.Database.User) + // TODO: Encrypt secret cfg.Section("database").Key("PASSWD").SetValue(setting.Database.Passwd) cfg.Section("database").Key("SCHEMA").SetValue(setting.Database.Schema) cfg.Section("database").Key("SSL_MODE").SetValue(setting.Database.SSLMode) @@ -432,6 +465,7 @@ func SubmitInstall(ctx *context.Context) { cfg.Section("mailer").Key("SMTP_PORT").SetValue(form.SMTPPort) cfg.Section("mailer").Key("FROM").SetValue(form.SMTPFrom) cfg.Section("mailer").Key("USER").SetValue(form.SMTPUser) + // TODO: Encrypt secret cfg.Section("mailer").Key("PASSWD").SetValue(form.SMTPPasswd) } else { cfg.Section("mailer").Key("ENABLED").SetValue("false") diff --git a/routers/web/org/setting.go b/routers/web/org/setting.go index 899e554ba0dce..7704c18ad2007 100644 --- a/routers/web/org/setting.go +++ b/routers/web/org/setting.go @@ -25,6 +25,7 @@ import ( "code.gitea.io/gitea/services/org" container_service "code.gitea.io/gitea/services/packages/container" repo_service "code.gitea.io/gitea/services/repository" + secret_service "code.gitea.io/gitea/services/secrets" user_service "code.gitea.io/gitea/services/user" ) @@ -37,6 +38,8 @@ const ( tplSettingsHooks base.TplName = "org/settings/hooks" // tplSettingsLabels template path for render labels settings tplSettingsLabels base.TplName = "org/settings/labels" + // tplSettingsSecrets template path for render secrets settings + tplSettingsSecrets base.TplName = "org/settings/secrets" ) // Settings render the main settings page @@ -246,3 +249,47 @@ func Labels(ctx *context.Context) { ctx.Data["LabelTemplates"] = repo_module.LabelTemplates ctx.HTML(http.StatusOK, tplSettingsLabels) } + +// Secrets render organization secrets page +func Secrets(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("repo.secrets") + ctx.Data["PageIsOrgSettings"] = true + ctx.Data["PageIsOrgSettingsSecrets"] = true + ctx.Data["RequireTribute"] = true + + secrets, err := secret_service.FindOwnerSecrets(ctx, ctx.Org.Organization.ID) + if err != nil { + ctx.ServerError("FindOwnerSecrets", err) + return + } + ctx.Data["Secrets"] = secrets + + ctx.HTML(http.StatusOK, tplSettingsSecrets) +} + +// SecretsPost add secrets +func SecretsPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.AddSecretForm) + if err := secret_service.InsertOwnerSecret(ctx, ctx.Org.Organization.ID, form.Title, form.Content); err != nil { + ctx.ServerError("InsertOwnerSecret", err) + return + } + + log.Trace("Org %d: secret added", ctx.Org.Organization.ID) + ctx.Flash.Success(ctx.Tr("repo.settings.add_secret_success", form.Title)) + ctx.Redirect(ctx.Org.OrgLink + "/settings/secrets") +} + +// SecretsDelete delete secrets +func SecretsDelete(ctx *context.Context) { + if err := secret_service.DeleteSecretByID(ctx, ctx.FormInt64("id")); err != nil { + ctx.Flash.Error(ctx.Tr("repo.settings.secret_deletion_failed")) + log.Error("delete secret %d: %v", ctx.FormInt64("id"), err) + } else { + ctx.Flash.Success(ctx.Tr("repo.settings.secret_deletion_success")) + } + + ctx.JSON(http.StatusOK, map[string]interface{}{ + "redirect": ctx.Org.OrgLink + "/settings/secrets", + }) +} diff --git a/routers/web/repo/setting.go b/routers/web/repo/setting.go index f35adcaa10e2a..a42a063915fb5 100644 --- a/routers/web/repo/setting.go +++ b/routers/web/repo/setting.go @@ -44,6 +44,7 @@ import ( mirror_service "code.gitea.io/gitea/services/mirror" org_service "code.gitea.io/gitea/services/org" repo_service "code.gitea.io/gitea/services/repository" + secret_service "code.gitea.io/gitea/services/secrets" wiki_service "code.gitea.io/gitea/services/wiki" ) @@ -1113,12 +1114,38 @@ func DeployKeys(ctx *context.Context) { } ctx.Data["Deploykeys"] = keys + secrets, err := secret_service.FindRepoSecrets(ctx, ctx.Repo.Repository.ID) + if err != nil { + ctx.ServerError("FindRepoSecrets", err) + return + } + ctx.Data["Secrets"] = secrets + ctx.HTML(http.StatusOK, tplDeployKeys) } +// SecretsPost response for creating a new secret +func SecretsPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.AddKeyForm) + if err := secret_service.InsertRepoSecret(ctx, ctx.Repo.Repository.ID, form.Title, form.Content); err != nil { + ctx.ServerError("InsertRepoSecret", err) + return + } + + log.Trace("Secret added: %d", ctx.Repo.Repository.ID) + ctx.Flash.Success(ctx.Tr("repo.settings.add_secret_success", form.Title)) + ctx.Redirect(ctx.Repo.RepoLink + "/settings/keys") +} + // DeployKeysPost response for adding a deploy key of a repository func DeployKeysPost(ctx *context.Context) { + if ctx.FormString("act") == "secret" { + SecretsPost(ctx) + return + } + form := web.GetForm(ctx).(*forms.AddKeyForm) + ctx.Data["Title"] = ctx.Tr("repo.settings.deploy_keys") ctx.Data["PageIsSettingsKeys"] = true ctx.Data["DisableSSH"] = setting.SSH.Disabled @@ -1177,8 +1204,26 @@ func DeployKeysPost(ctx *context.Context) { ctx.Redirect(ctx.Repo.RepoLink + "/settings/keys") } +func DeleteSecret(ctx *context.Context) { + if err := secret_service.DeleteSecretByID(ctx, ctx.FormInt64("id")); err != nil { + ctx.Flash.Error(ctx.Tr("repo.settings.secret_deletion_failed")) + log.Error("delete secret %d: %v", ctx.FormInt64("id"), err) + } else { + ctx.Flash.Success(ctx.Tr("repo.settings.secret_deletion_success")) + } + + ctx.JSON(http.StatusOK, map[string]interface{}{ + "redirect": ctx.Repo.RepoLink + "/settings/keys", + }) +} + // DeleteDeployKey response for deleting a deploy key func DeleteDeployKey(ctx *context.Context) { + if ctx.FormString("act") == "secret" { + DeleteSecret(ctx) + return + } + if err := asymkey_service.DeleteDeployKey(ctx.Doer, ctx.FormInt64("id")); err != nil { ctx.Flash.Error("DeleteDeployKey: " + err.Error()) } else { diff --git a/routers/web/web.go b/routers/web/web.go index f9d97758a1bde..dda65fb848d9a 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -774,6 +774,12 @@ func RegisterRoutes(m *web.Route) { m.Post("/initialize", web.Bind(forms.InitializeLabelsForm{}), org.InitializeLabels) }) + m.Group("/secrets", func() { + m.Get("", org.Secrets) + m.Post("", web.Bind(forms.AddSecretForm{}), org.SecretsPost) + m.Post("/delete", org.SecretsDelete) + }) + m.Route("/delete", "GET,POST", org.SettingsDelete) m.Group("/packages", func() { diff --git a/services/forms/user_form.go b/services/forms/user_form.go index cd2c45261b90e..4d9107df84fc1 100644 --- a/services/forms/user_form.go +++ b/services/forms/user_form.go @@ -13,6 +13,7 @@ import ( "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/web/middleware" + "code.gitea.io/gitea/services/secrets" "gitea.com/go-chi/binding" ) @@ -63,6 +64,7 @@ type InstallForm struct { NoReplyAddress string PasswordAlgorithm string + MasterKeyProvider secrets.MasterKeyProviderType `binding:"Required;In(none,plain)"` AdminName string `binding:"OmitEmpty;Username;MaxSize(30)" locale:"install.admin_name"` AdminPasswd string `binding:"OmitEmpty;MaxSize(255)" locale:"install.admin_password"` @@ -363,6 +365,18 @@ func (f *AddKeyForm) Validate(req *http.Request, errs binding.Errors) binding.Er return middleware.Validate(errs, ctx.Data, f, ctx.Locale) } +// AddSecretForm for adding secrets +type AddSecretForm struct { + Title string `binding:"Required;MaxSize(50)"` + Content string `binding:"Required"` +} + +// Validate validates the fields +func (f *AddSecretForm) Validate(req *http.Request, errs binding.Errors) binding.Errors { + ctx := context.GetContext(req) + return middleware.Validate(errs, ctx.Data, f, ctx.Locale) +} + // NewAccessTokenForm form for creating access token type NewAccessTokenForm struct { Name string `binding:"Required;MaxSize(255)"` diff --git a/services/org/org.go b/services/org/org.go index e45fb305debe8..06c7ced5a1ba3 100644 --- a/services/org/org.go +++ b/services/org/org.go @@ -14,6 +14,7 @@ import ( user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/storage" "code.gitea.io/gitea/modules/util" + secret_service "code.gitea.io/gitea/services/secrets" ) // DeleteOrganization completely and permanently deletes everything of organization. @@ -39,6 +40,10 @@ func DeleteOrganization(org *organization.Organization) error { return models.ErrUserOwnPackages{UID: org.ID} } + if err := secret_service.DeleteSecretsByOwnerID(ctx, org.ID); err != nil { + return err + } + if err := organization.DeleteOrganization(ctx, org); err != nil { return fmt.Errorf("DeleteOrganization: %w", err) } diff --git a/services/repository/repository.go b/services/repository/repository.go index 3c3e7e82c3f8f..bf4c5fb35b2c1 100644 --- a/services/repository/repository.go +++ b/services/repository/repository.go @@ -21,6 +21,7 @@ import ( repo_module "code.gitea.io/gitea/modules/repository" "code.gitea.io/gitea/modules/setting" pull_service "code.gitea.io/gitea/services/pull" + secret_service "code.gitea.io/gitea/services/secrets" ) // CreateRepository creates a repository for the user/organization. @@ -51,6 +52,10 @@ func DeleteRepository(ctx context.Context, doer *user_model.User, repo *repo_mod return err } + if err := secret_service.DeleteSecretsByRepoID(ctx, repo.ID); err != nil { + return err + } + return packages_model.UnlinkRepositoryFromAllPackages(ctx, repo.ID) } diff --git a/services/secrets/encryption.go b/services/secrets/encryption.go new file mode 100644 index 0000000000000..2f07d05df9e4d --- /dev/null +++ b/services/secrets/encryption.go @@ -0,0 +1,15 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package secrets + +// EncryptionProvider encrypts and decrypts secrets +type EncryptionProvider interface { + Encrypt(secret, key []byte) ([]byte, error) + + EncryptString(secret string, key []byte) (string, error) + + Decrypt(enc, key []byte) ([]byte, error) + + DecryptString(enc string, key []byte) (string, error) +} diff --git a/services/secrets/encryption_aes.go b/services/secrets/encryption_aes.go new file mode 100644 index 0000000000000..d8785ecb95a10 --- /dev/null +++ b/services/secrets/encryption_aes.go @@ -0,0 +1,87 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package secrets + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/base64" + "fmt" + "io" +) + +type aesEncryptionProvider struct{} + +func NewAesEncryptionProvider() EncryptionProvider { + return &aesEncryptionProvider{} +} + +func (e *aesEncryptionProvider) Encrypt(secret, key []byte) ([]byte, error) { + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + + c, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + + nonce := make([]byte, c.NonceSize(), c.NonceSize()+c.Overhead()+len(secret)) + if _, err = io.ReadFull(rand.Reader, nonce); err != nil { + return nil, err + } + out := c.Seal(nil, nonce, secret, nil) + + return append(nonce, out...), nil +} + +func (e *aesEncryptionProvider) EncryptString(secret string, key []byte) (string, error) { + out, err := e.Encrypt([]byte(secret), key) + if err != nil { + return "", err + } + return base64.StdEncoding.EncodeToString(out), nil +} + +func (e *aesEncryptionProvider) Decrypt(enc, key []byte) ([]byte, error) { + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + + c, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + + if len(enc) < c.NonceSize() { + return nil, fmt.Errorf("encrypted value has length %d, which is too short for expected %d", len(enc), c.NonceSize()) + } + + nonce := enc[:c.NonceSize()] + ciphertext := enc[c.NonceSize():] + + out, err := c.Open(nil, nonce, ciphertext, nil) + if err != nil { + return nil, err + } + + return out, nil +} + +func (e *aesEncryptionProvider) DecryptString(enc string, key []byte) (string, error) { + encb, err := base64.StdEncoding.DecodeString(enc) + if err != nil { + return "", err + } + + out, err := e.Decrypt(encb, key) + if err != nil { + return "", err + } + + return string(out), nil +} diff --git a/services/secrets/encryption_aes_test.go b/services/secrets/encryption_aes_test.go new file mode 100644 index 0000000000000..18e0fd10697f1 --- /dev/null +++ b/services/secrets/encryption_aes_test.go @@ -0,0 +1,21 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package secrets + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestEncryptDecrypt(t *testing.T) { + provider := NewAesEncryptionProvider() + key := []byte("1111111111111111") + pri := "vvvvvvv" + enc, err := provider.EncryptString(pri, key) + assert.NoError(t, err) + v, err := provider.DecryptString(enc, key) + assert.NoError(t, err) + assert.EqualValues(t, pri, v) +} diff --git a/services/secrets/masterkey.go b/services/secrets/masterkey.go new file mode 100644 index 0000000000000..2ad18954d8f81 --- /dev/null +++ b/services/secrets/masterkey.go @@ -0,0 +1,26 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package secrets + +import ( + "fmt" +) + +// ErrMasterKeySealed is returned when trying to use master key that is sealed +var ErrMasterKeySealed = fmt.Errorf("master key sealed") + +// MasterKeyProvider provides master key used for encryption +type MasterKeyProvider interface { + Init() error + + GenerateMasterKey() ([][]byte, error) + + Unseal(secret []byte) error + + Seal() error + + IsSealed() bool + + GetMasterKey() ([]byte, error) +} diff --git a/services/secrets/masterkey_nop.go b/services/secrets/masterkey_nop.go new file mode 100644 index 0000000000000..dc9b93a7ca432 --- /dev/null +++ b/services/secrets/masterkey_nop.go @@ -0,0 +1,41 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package secrets + +type nopMasterKeyProvider struct{} + +// NewNopMasterKeyProvider returns master key provider that holds no master key and is always unsealed +func NewNopMasterKeyProvider() MasterKeyProvider { + return &nopMasterKeyProvider{} +} + +// Init initializes master key provider +func (k *nopMasterKeyProvider) Init() error { + return nil +} + +// GenerateMasterKey always returns empty master key +func (k *nopMasterKeyProvider) GenerateMasterKey() ([][]byte, error) { + return nil, nil +} + +// Unseal master key by providing unsealing secret +func (k *nopMasterKeyProvider) Unseal(secret []byte) error { + return nil +} + +// Seal master key +func (k *nopMasterKeyProvider) Seal() error { + return nil +} + +// IsSealed always returns false +func (k *nopMasterKeyProvider) IsSealed() bool { + return false +} + +// GetMasterKey returns empty master key +func (k *nopMasterKeyProvider) GetMasterKey() ([]byte, error) { + return nil, nil +} diff --git a/services/secrets/masterkey_nop_test.go b/services/secrets/masterkey_nop_test.go new file mode 100644 index 0000000000000..330ecf9919903 --- /dev/null +++ b/services/secrets/masterkey_nop_test.go @@ -0,0 +1,15 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package secrets + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNopMasterKey_IsSealed(t *testing.T) { + k := NewNopMasterKeyProvider() + assert.False(t, k.IsSealed()) +} diff --git a/services/secrets/masterkey_plain.go b/services/secrets/masterkey_plain.go new file mode 100644 index 0000000000000..d299bdbf63826 --- /dev/null +++ b/services/secrets/masterkey_plain.go @@ -0,0 +1,58 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package secrets + +import ( + "code.gitea.io/gitea/modules/generate" + "code.gitea.io/gitea/modules/setting" +) + +type plainMasterKeyProvider struct { + key []byte +} + +// NewPlainMasterKeyProvider returns unsecured static master key provider +func NewPlainMasterKeyProvider() MasterKeyProvider { + return &plainMasterKeyProvider{} +} + +// Init initializes master key provider +func (k *plainMasterKeyProvider) Init() error { + return k.Unseal(nil) +} + +// GenerateMasterKey generates a new master key and returns secret or secrets for unsealing +func (k *plainMasterKeyProvider) GenerateMasterKey() ([][]byte, error) { + key, err := generate.NewMasterKey() + if err != nil { + return nil, err + } + k.key = key + return [][]byte{key}, nil +} + +// Unseal master key by providing unsealing secret +func (k *plainMasterKeyProvider) Unseal(secret []byte) error { + k.key = setting.MasterKey + return nil +} + +// Seal master key +func (k *plainMasterKeyProvider) Seal() error { + k.key = nil + return nil +} + +// IsSealed returns if master key is sealed +func (k *plainMasterKeyProvider) IsSealed() bool { + return len(k.key) == 0 +} + +// GetMasterKey returns master key +func (k *plainMasterKeyProvider) GetMasterKey() ([]byte, error) { + if k.IsSealed() { + return nil, ErrMasterKeySealed + } + return k.key, nil +} diff --git a/services/secrets/secrets.go b/services/secrets/secrets.go new file mode 100644 index 0000000000000..4d304e2aca42f --- /dev/null +++ b/services/secrets/secrets.go @@ -0,0 +1,155 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package secrets + +import ( + "context" + "fmt" + + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/modules/setting" + + "xorm.io/builder" +) + +// MasterKeyProviderType is the type of master key provider +type MasterKeyProviderType string + +// Types of master key providers +const ( + MasterKeyProviderTypeNone MasterKeyProviderType = "none" + MasterKeyProviderTypePlain MasterKeyProviderType = "plain" +) + +var ( + masterKey MasterKeyProvider + encProvider EncryptionProvider +) + +// Init initializes master key provider based on settings +func Init() error { + switch MasterKeyProviderType(setting.MasterKeyProvider) { + case MasterKeyProviderTypeNone: + masterKey = NewNopMasterKeyProvider() + case MasterKeyProviderTypePlain: + masterKey = NewPlainMasterKeyProvider() + default: + return fmt.Errorf("invalid master key provider %v", setting.MasterKeyProvider) + } + + if err := masterKey.Init(); err != nil { + return err + } + + encProvider = NewAesEncryptionProvider() + + return nil +} + +// GenerateMasterKey generates a new master key and returns secret or secrets for unsealing +func GenerateMasterKey() ([][]byte, error) { + return masterKey.GenerateMasterKey() +} + +func Encrypt(secret []byte) ([]byte, error) { + key, err := masterKey.GetMasterKey() + if err != nil { + return nil, err + } + + if len(key) == 0 { + return secret, nil + } + + return encProvider.Encrypt(secret, key) +} + +func EncryptString(secret string) (string, error) { + key, err := masterKey.GetMasterKey() + if err != nil { + return "", err + } + + if len(key) == 0 { + return secret, nil + } + + return encProvider.EncryptString(secret, key) +} + +func Decrypt(enc []byte) ([]byte, error) { + key, err := masterKey.GetMasterKey() + if err != nil { + return nil, err + } + + if len(key) == 0 { + return enc, nil + } + + return encProvider.Decrypt(enc, key) +} + +func DecryptString(enc string) (string, error) { + key, err := masterKey.GetMasterKey() + if err != nil { + return "", err + } + + if len(key) == 0 { + return enc, nil + } + + return encProvider.DecryptString(enc, key) +} + +func InsertRepoSecret(ctx context.Context, repoID int64, key, data string) error { + v, err := EncryptString(data) + if err != nil { + return err + } + return db.Insert(ctx, &auth_model.Secret{ + RepoID: repoID, + Name: key, + Data: v, + }) +} + +func InsertOwnerSecret(ctx context.Context, ownerID int64, key, data string) error { + v, err := EncryptString(data) + if err != nil { + return err + } + return db.Insert(ctx, &auth_model.Secret{ + OwnerID: ownerID, + Name: key, + Data: v, + }) +} + +func DeleteSecretByID(ctx context.Context, id int64) error { + _, err := db.DeleteByBean(ctx, &auth_model.Secret{ID: id}) + return err +} + +func DeleteSecretsByRepoID(ctx context.Context, repoID int64) error { + _, err := db.DeleteByBean(ctx, &auth_model.Secret{RepoID: repoID}) + return err +} + +func DeleteSecretsByOwnerID(ctx context.Context, ownerID int64) error { + _, err := db.DeleteByBean(ctx, &auth_model.Secret{ID: ownerID}) + return err +} + +func FindRepoSecrets(ctx context.Context, repoID int64) ([]*auth_model.Secret, error) { + var res []*auth_model.Secret + return res, db.GetEngine(ctx).Where(builder.Eq{"repo_id": repoID}).Find(&res) +} + +func FindOwnerSecrets(ctx context.Context, ownerID int64) ([]*auth_model.Secret, error) { + var res []*auth_model.Secret + return res, db.GetEngine(ctx).Where(builder.Eq{"owner_id": ownerID}).Find(&res) +} diff --git a/templates/install.tmpl b/templates/install.tmpl index 0625f43cc4e62..e8cbdccc659ef 100644 --- a/templates/install.tmpl +++ b/templates/install.tmpl @@ -170,6 +170,22 @@ {{.locale.Tr "install.enable_update_checker_helper"}} + +
{{.locale.Tr "repo.settings.secret_deletion_desc"}}
+