feat: migrate action secrets to keying
to store them more securely (#8692)
- Use the keying module, that was introduced in forgejo/forgejo#5041, to store action secrets safely and securely in the database. - Introduce a central function that sets the secret, `SetSecret` and let the caller do the update call. This is similar to how the twofactor (TOTP) models does it. Ref. https://codeberg.org/forgejo/forgejo/pulls/6074 - Add a relaxed migration, that is run inside a transaction. If it cannot decrypt a action secret, then it's deleted. Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/8692 Reviewed-by: Earl Warren <earl-warren@noreply.codeberg.org> Co-authored-by: Gusted <postmaster@gusted.xyz> Co-committed-by: Gusted <postmaster@gusted.xyz>
This commit is contained in:
parent
bc0d14119c
commit
13e48ead92
9 changed files with 293 additions and 33 deletions
|
@ -115,6 +115,8 @@ var migrations = []*Migration{
|
|||
NewMigration("Add `branch_filter` to `push_mirror` table", AddPushMirrorBranchFilter),
|
||||
// v37 -> v38
|
||||
NewMigration("Add `resolved_unix` column to `abuse_report` table", AddResolvedUnixToAbuseReport),
|
||||
// v38 -> v39
|
||||
NewMigration("Migrate `data` column of `secret` table to store keying material", MigrateActionSecretsToKeying),
|
||||
}
|
||||
|
||||
// GetCurrentDBVersion returns the current Forgejo database version.
|
||||
|
|
78
models/forgejo_migrations/v39.go
Normal file
78
models/forgejo_migrations/v39.go
Normal file
|
@ -0,0 +1,78 @@
|
|||
// Copyright 2025 The Forgejo Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package forgejo_migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"forgejo.org/models/db"
|
||||
secret_model "forgejo.org/models/secret"
|
||||
"forgejo.org/modules/log"
|
||||
"forgejo.org/modules/secret"
|
||||
"forgejo.org/modules/setting"
|
||||
|
||||
"xorm.io/xorm"
|
||||
"xorm.io/xorm/schemas"
|
||||
)
|
||||
|
||||
func MigrateActionSecretsToKeying(x *xorm.Engine) error {
|
||||
return db.WithTx(db.DefaultContext, func(ctx context.Context) error {
|
||||
sess := db.GetEngine(ctx)
|
||||
|
||||
switch x.Dialect().URI().DBType {
|
||||
case schemas.MYSQL:
|
||||
if _, err := sess.Exec("ALTER TABLE `secret` MODIFY `data` BLOB"); err != nil {
|
||||
return err
|
||||
}
|
||||
case schemas.SQLITE:
|
||||
if _, err := sess.Exec("ALTER TABLE `secret` RENAME COLUMN `data` TO `data_backup`"); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := sess.Exec("ALTER TABLE `secret` ADD COLUMN `data` BLOB"); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := sess.Exec("UPDATE `secret` SET `data` = `data_backup`"); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := sess.Exec("ALTER TABLE `secret` DROP COLUMN `data_backup`"); err != nil {
|
||||
return err
|
||||
}
|
||||
case schemas.POSTGRES:
|
||||
if _, err := sess.Exec("ALTER TABLE `secret` ALTER COLUMN `data` SET DATA TYPE bytea USING data::text::bytea"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
oldEncryptionKey := setting.SecretKey
|
||||
|
||||
messages := make([]string, 0, 100)
|
||||
ids := make([]int64, 0, 100)
|
||||
|
||||
err := db.Iterate(ctx, nil, func(ctx context.Context, bean *secret_model.Secret) error {
|
||||
secretBytes, err := secret.DecryptSecret(oldEncryptionKey, string(bean.Data))
|
||||
if err != nil {
|
||||
messages = append(messages, fmt.Sprintf("secret.id=%d, secret.name=%q, secret.repo_id=%d, secret.owner_id=%d: secret.DecryptSecret(): %v", bean.ID, bean.Name, bean.RepoID, bean.OwnerID, err))
|
||||
ids = append(ids, bean.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
bean.SetSecret(secretBytes)
|
||||
_, err = sess.Cols("data").ID(bean.ID).Update(bean)
|
||||
return err
|
||||
})
|
||||
|
||||
if err == nil {
|
||||
if len(ids) > 0 {
|
||||
log.Error("Forgejo migration[37]: The following action secrets were found to be corrupted and removed from the database.")
|
||||
for _, message := range messages {
|
||||
log.Error("Forgejo migration[37]: %s", message)
|
||||
}
|
||||
|
||||
_, err = sess.In("id", ids).NoAutoCondition().NoAutoTime().Delete(&secret_model.Secret{})
|
||||
}
|
||||
}
|
||||
return err
|
||||
})
|
||||
}
|
52
models/forgejo_migrations/v39_test.go
Normal file
52
models/forgejo_migrations/v39_test.go
Normal file
|
@ -0,0 +1,52 @@
|
|||
// Copyright 2025 The Forgejo Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package forgejo_migrations
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
migration_tests "forgejo.org/models/migrations/test"
|
||||
"forgejo.org/models/secret"
|
||||
"forgejo.org/modules/keying"
|
||||
"forgejo.org/modules/timeutil"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func Test_MigrateActionSecretToKeying(t *testing.T) {
|
||||
type Secret struct {
|
||||
ID int64
|
||||
OwnerID int64 `xorm:"INDEX UNIQUE(owner_repo_name) NOT NULL"`
|
||||
RepoID int64 `xorm:"INDEX UNIQUE(owner_repo_name) NOT NULL DEFAULT 0"`
|
||||
Name string `xorm:"UNIQUE(owner_repo_name) NOT NULL"`
|
||||
Data string `xorm:"LONGTEXT"` // encrypted data
|
||||
CreatedUnix timeutil.TimeStamp `xorm:"created NOT NULL"`
|
||||
}
|
||||
|
||||
// Prepare and load the testing database
|
||||
x, deferable := migration_tests.PrepareTestEnv(t, 0, new(Secret))
|
||||
defer deferable()
|
||||
if x == nil || t.Failed() {
|
||||
return
|
||||
}
|
||||
|
||||
cnt, err := x.Table("secret").Count()
|
||||
require.NoError(t, err)
|
||||
assert.EqualValues(t, 2, cnt)
|
||||
|
||||
require.NoError(t, MigrateActionSecretsToKeying(x))
|
||||
|
||||
cnt, err = x.Table("secret").Count()
|
||||
require.NoError(t, err)
|
||||
assert.EqualValues(t, 1, cnt)
|
||||
|
||||
var secret secret.Secret
|
||||
_, err = x.Table("secret").ID(1).Get(&secret)
|
||||
require.NoError(t, err)
|
||||
|
||||
secretBytes, err := keying.DeriveKey(keying.ContextActionSecret).Decrypt(secret.Data, keying.ColumnAndID("data", secret.ID))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []byte("A deep dark secret"), secretBytes)
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
-
|
||||
id: 1
|
||||
owner_id: 2
|
||||
repo_id: 1
|
||||
name: SECRET_1
|
||||
data: 02458e5f341b2d5081a31283559843b6b7543ab98ed213d2b15b5cef94385fa348afa7e0875122e9
|
||||
created_unix: 1753556968
|
||||
-
|
||||
id: 2
|
||||
owner_id: 2
|
||||
repo_id: 1
|
||||
name: BADBAD
|
||||
data: badbad
|
||||
created_unix: 1753556968
|
17
models/secret/main_test.go
Normal file
17
models/secret/main_test.go
Normal file
|
@ -0,0 +1,17 @@
|
|||
// Copyright 2025 The Forgejo Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package secret_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"forgejo.org/models/unittest"
|
||||
|
||||
_ "forgejo.org/models"
|
||||
_ "forgejo.org/models/activities"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
unittest.MainTest(m)
|
||||
}
|
|
@ -11,9 +11,8 @@ import (
|
|||
actions_model "forgejo.org/models/actions"
|
||||
"forgejo.org/models/db"
|
||||
actions_module "forgejo.org/modules/actions"
|
||||
"forgejo.org/modules/keying"
|
||||
"forgejo.org/modules/log"
|
||||
secret_module "forgejo.org/modules/secret"
|
||||
"forgejo.org/modules/setting"
|
||||
"forgejo.org/modules/timeutil"
|
||||
"forgejo.org/modules/util"
|
||||
|
||||
|
@ -39,7 +38,7 @@ type Secret struct {
|
|||
OwnerID int64 `xorm:"INDEX UNIQUE(owner_repo_name) NOT NULL"`
|
||||
RepoID int64 `xorm:"INDEX UNIQUE(owner_repo_name) NOT NULL DEFAULT 0"`
|
||||
Name string `xorm:"UNIQUE(owner_repo_name) NOT NULL"`
|
||||
Data string `xorm:"LONGTEXT"` // encrypted data
|
||||
Data []byte `xorm:"BLOB"` // encrypted data
|
||||
CreatedUnix timeutil.TimeStamp `xorm:"created NOT NULL"`
|
||||
}
|
||||
|
||||
|
@ -67,17 +66,21 @@ func InsertEncryptedSecret(ctx context.Context, ownerID, repoID int64, name, dat
|
|||
return nil, fmt.Errorf("%w: ownerID and repoID cannot be both zero, global secrets are not supported", util.ErrInvalidArgument)
|
||||
}
|
||||
|
||||
encrypted, err := secret_module.EncryptSecret(setting.SecretKey, data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
secret := &Secret{
|
||||
OwnerID: ownerID,
|
||||
RepoID: repoID,
|
||||
Name: strings.ToUpper(name),
|
||||
Data: encrypted,
|
||||
}
|
||||
return secret, db.Insert(ctx, secret)
|
||||
|
||||
return secret, db.WithTx(ctx, func(ctx context.Context) error {
|
||||
if err := db.Insert(ctx, secret); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
secret.SetSecret(data)
|
||||
_, err := db.GetEngine(ctx).ID(secret.ID).Cols("data").Update(secret)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
func init() {
|
||||
|
@ -113,21 +116,9 @@ func (opts FindSecretsOptions) ToConds() builder.Cond {
|
|||
return cond
|
||||
}
|
||||
|
||||
// UpdateSecret changes org or user reop secret.
|
||||
func UpdateSecret(ctx context.Context, secretID int64, data string) error {
|
||||
encrypted, err := secret_module.EncryptSecret(setting.SecretKey, data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s := &Secret{
|
||||
Data: encrypted,
|
||||
}
|
||||
affected, err := db.GetEngine(ctx).ID(secretID).Cols("data").Update(s)
|
||||
if affected != 1 {
|
||||
return ErrSecretNotFound{}
|
||||
}
|
||||
return err
|
||||
func (s *Secret) SetSecret(data string) {
|
||||
key := keying.DeriveKey(keying.ContextActionSecret)
|
||||
s.Data = key.Encrypt([]byte(data), keying.ColumnAndID("data", s.ID))
|
||||
}
|
||||
|
||||
func GetSecretsOfTask(ctx context.Context, task *actions_model.ActionTask) (map[string]string, error) {
|
||||
|
@ -155,13 +146,14 @@ func GetSecretsOfTask(ctx context.Context, task *actions_model.ActionTask) (map[
|
|||
return nil, err
|
||||
}
|
||||
|
||||
key := keying.DeriveKey(keying.ContextActionSecret)
|
||||
for _, secret := range append(ownerSecrets, repoSecrets...) {
|
||||
v, err := secret_module.DecryptSecret(setting.SecretKey, secret.Data)
|
||||
v, err := key.Decrypt(secret.Data, keying.ColumnAndID("data", secret.ID))
|
||||
if err != nil {
|
||||
log.Error("decrypt secret %v %q: %v", secret.ID, secret.Name, err)
|
||||
log.Error("unable to decrypt secret[id=%d,name=%q]: %v", secret.ID, secret.Name, err)
|
||||
return nil, err
|
||||
}
|
||||
secrets[secret.Name] = v
|
||||
secrets[secret.Name] = string(v)
|
||||
}
|
||||
|
||||
return secrets, nil
|
||||
|
|
103
models/secret/secret_test.go
Normal file
103
models/secret/secret_test.go
Normal file
|
@ -0,0 +1,103 @@
|
|||
// Copyright 2025 The Forgejo Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package secret
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"forgejo.org/models/actions"
|
||||
"forgejo.org/models/repo"
|
||||
"forgejo.org/models/unittest"
|
||||
"forgejo.org/modules/keying"
|
||||
"forgejo.org/modules/util"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestInsertEncryptedSecret(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
t.Run("Global secret", func(t *testing.T) {
|
||||
secret, err := InsertEncryptedSecret(t.Context(), 0, 0, "GLOBAL_SECRET", "some common secret")
|
||||
require.ErrorIs(t, err, util.ErrInvalidArgument)
|
||||
assert.Nil(t, secret)
|
||||
})
|
||||
|
||||
key := keying.DeriveKey(keying.ContextActionSecret)
|
||||
|
||||
t.Run("Insert repository secret", func(t *testing.T) {
|
||||
secret, err := InsertEncryptedSecret(t.Context(), 0, 1, "REPO_SECRET", "some repository secret")
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, secret)
|
||||
assert.Equal(t, "REPO_SECRET", secret.Name)
|
||||
assert.EqualValues(t, 1, secret.RepoID)
|
||||
assert.NotEmpty(t, secret.Data)
|
||||
|
||||
// Assert the secret is stored in the database.
|
||||
unittest.AssertExistsAndLoadBean(t, &Secret{RepoID: 1, Name: "REPO_SECRET", Data: secret.Data})
|
||||
|
||||
t.Run("Keying", func(t *testing.T) {
|
||||
// Cannot decrypt with different ID.
|
||||
plainText, err := key.Decrypt(secret.Data, keying.ColumnAndID("data", secret.ID+1))
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, plainText)
|
||||
|
||||
// Cannot decrypt with different column.
|
||||
plainText, err = key.Decrypt(secret.Data, keying.ColumnAndID("metadata", secret.ID))
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, plainText)
|
||||
|
||||
// Can decrypt with correct column and ID.
|
||||
plainText, err = key.Decrypt(secret.Data, keying.ColumnAndID("data", secret.ID))
|
||||
require.NoError(t, err)
|
||||
assert.EqualValues(t, "some repository secret", plainText)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("Insert owner secret", func(t *testing.T) {
|
||||
secret, err := InsertEncryptedSecret(t.Context(), 2, 0, "OWNER_SECRET", "some owner secret")
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, secret)
|
||||
assert.Equal(t, "OWNER_SECRET", secret.Name)
|
||||
assert.EqualValues(t, 2, secret.OwnerID)
|
||||
assert.NotEmpty(t, secret.Data)
|
||||
|
||||
// Assert the secret is stored in the database.
|
||||
unittest.AssertExistsAndLoadBean(t, &Secret{OwnerID: 2, Name: "OWNER_SECRET", Data: secret.Data})
|
||||
|
||||
t.Run("Keying", func(t *testing.T) {
|
||||
// Cannot decrypt with different ID.
|
||||
plainText, err := key.Decrypt(secret.Data, keying.ColumnAndID("data", secret.ID+1))
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, plainText)
|
||||
|
||||
// Cannot decrypt with different column.
|
||||
plainText, err = key.Decrypt(secret.Data, keying.ColumnAndID("metadata", secret.ID))
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, plainText)
|
||||
|
||||
// Can decrypt with correct column and ID.
|
||||
plainText, err = key.Decrypt(secret.Data, keying.ColumnAndID("data", secret.ID))
|
||||
require.NoError(t, err)
|
||||
assert.EqualValues(t, "some owner secret", plainText)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("Get secrets", func(t *testing.T) {
|
||||
secrets, err := GetSecretsOfTask(t.Context(), &actions.ActionTask{
|
||||
Job: &actions.ActionRunJob{
|
||||
Run: &actions.ActionRun{
|
||||
RepoID: 1,
|
||||
Repo: &repo.Repository{
|
||||
OwnerID: 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "some owner secret", secrets["OWNER_SECRET"])
|
||||
assert.Equal(t, "some repository secret", secrets["REPO_SECRET"])
|
||||
})
|
||||
}
|
|
@ -58,6 +58,8 @@ var (
|
|||
ContextPushMirror Context = "pushmirror"
|
||||
// Used for the `two_factor` table.
|
||||
ContextTOTP Context = "totp"
|
||||
// Used for the `secret` table.
|
||||
ContextActionSecret Context = "action_secret"
|
||||
)
|
||||
|
||||
// Derive *the* key for a given context, this is a deterministic function.
|
||||
|
|
|
@ -15,16 +15,16 @@ func CreateOrUpdateSecret(ctx context.Context, ownerID, repoID int64, name, data
|
|||
return nil, false, err
|
||||
}
|
||||
|
||||
s, err := db.Find[secret_model.Secret](ctx, secret_model.FindSecretsOptions{
|
||||
s, exists, err := db.Get[secret_model.Secret](ctx, secret_model.FindSecretsOptions{
|
||||
OwnerID: ownerID,
|
||||
RepoID: repoID,
|
||||
Name: name,
|
||||
})
|
||||
}.ToConds())
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
if len(s) == 0 {
|
||||
if !exists {
|
||||
s, err := secret_model.InsertEncryptedSecret(ctx, ownerID, repoID, name, data)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
|
@ -32,11 +32,11 @@ func CreateOrUpdateSecret(ctx context.Context, ownerID, repoID int64, name, data
|
|||
return s, true, nil
|
||||
}
|
||||
|
||||
if err := secret_model.UpdateSecret(ctx, s[0].ID, data); err != nil {
|
||||
s.SetSecret(data)
|
||||
if _, err := db.GetEngine(ctx).Cols("data").ID(s.ID).Update(s); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
return s[0], false, nil
|
||||
return s, false, nil
|
||||
}
|
||||
|
||||
func DeleteSecretByID(ctx context.Context, ownerID, repoID, secretID int64) error {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue