feat: use keying for task secrets (#9923)

- Follow up of forgejo/forgejo!5041, forgejo/forgejo!6074, forgejo/forgejo!8692
- The `task` table contains three secrets: clone address (with credentials), auth password and auth token. These secrets are stored for migrating repositories (also the only usage of this table, although it allows for more usages).
- Use `keying` to safely store these secrets and bound them to the table, column, row id and JSON field name.
- The migration isn't spectacular but does closely follow what we learned in the previous two migrations: use a transaction and delete records when you can't decrypt them. We also learned about `db.Iterate` not being happy when updating records but it has since been fixed.

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/9923
Reviewed-by: Mathieu Fenniak <mfenniak@noreply.codeberg.org>
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:
Gusted 2025-11-03 13:42:32 +01:00 committed by Gusted
parent d6f7e154a1
commit 0c11e9a43a
9 changed files with 392 additions and 32 deletions

View file

@ -60,6 +60,8 @@ var (
ContextTOTP Context = "totp"
// Used for the `secret` table.
ContextActionSecret Context = "action_secret"
// Used for the `task` table where type == TaskTypeMigrateRepo.
ContextMigrateTask Context = "migrate_repo_task"
)
// Derive *the* key for a given context, this is a deterministic function.
@ -131,3 +133,16 @@ func (k *Key) Decrypt(ciphertext, additionalData []byte) ([]byte, error) {
func ColumnAndID(column string, id int64) []byte {
return binary.BigEndian.AppendUint64(append([]byte(column), ':'), uint64(id))
}
// ColumnAndJSONSelectorAndID generates a context that can be used as additional context
// for encrypting and decrypting data. It requires the column name, JSON
// selector and the row ID (this requires to be known beforehand). Be careful
// when using this, as the table name isn't part of this context. This means
// it's not bound to a particular table. The table should be part of the context
// that the key was derived for, in which case it binds through that. Use this
// over `ColumnAndID` if you're encrypting data that's stored inside JSON.
// jsonSelector must be a unambigous selector to the JSON field that stores the
// encrypted data.
func ColumnAndJSONSelectorAndID(column, jsonSelector string, id int64) []byte {
return binary.BigEndian.AppendUint64(append(append([]byte(column), ':'), append([]byte(jsonSelector), ':')...), uint64(id))
}

View file

@ -109,3 +109,23 @@ func TestKeyingColumnAndID(t *testing.T) {
assert.Equal(t, []byte{0x74, 0x61, 0x62, 0x6c, 0x65, 0x32, 0x3a, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01}, keying.ColumnAndID("table2", 1))
assert.Equal(t, []byte{0x74, 0x61, 0x62, 0x6c, 0x65, 0x32, 0x3a, 0x7f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff}, keying.ColumnAndID("table2", math.MaxInt64))
}
func TestColumnAndJSONSelectorAndID(t *testing.T) {
assert.Equal(t, []byte{0x74, 0x61, 0x62, 0x6c, 0x65, 0x3a, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x31, 0x3a, 0x80, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0}, keying.ColumnAndJSONSelectorAndID("table", "field1", math.MinInt64))
assert.Equal(t, []byte{0x74, 0x61, 0x62, 0x6c, 0x65, 0x3a, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x31, 0x3a, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff}, keying.ColumnAndJSONSelectorAndID("table", "field1", -1))
assert.Equal(t, []byte{0x74, 0x61, 0x62, 0x6c, 0x65, 0x3a, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x31, 0x3a, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0}, keying.ColumnAndJSONSelectorAndID("table", "field1", 0))
assert.Equal(t, []byte{0x74, 0x61, 0x62, 0x6c, 0x65, 0x3a, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x31, 0x3a, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x1}, keying.ColumnAndJSONSelectorAndID("table", "field1", 1))
assert.Equal(t, []byte{0x74, 0x61, 0x62, 0x6c, 0x65, 0x3a, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x31, 0x3a, 0x7f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff}, keying.ColumnAndJSONSelectorAndID("table", "field1", math.MaxInt64))
assert.Equal(t, []byte{0x74, 0x61, 0x62, 0x6c, 0x65, 0x3a, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x32, 0x3a, 0x80, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0}, keying.ColumnAndJSONSelectorAndID("table", "field2", math.MinInt64))
assert.Equal(t, []byte{0x74, 0x61, 0x62, 0x6c, 0x65, 0x3a, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x32, 0x3a, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff}, keying.ColumnAndJSONSelectorAndID("table", "field2", -1))
assert.Equal(t, []byte{0x74, 0x61, 0x62, 0x6c, 0x65, 0x3a, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x32, 0x3a, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0}, keying.ColumnAndJSONSelectorAndID("table", "field2", 0))
assert.Equal(t, []byte{0x74, 0x61, 0x62, 0x6c, 0x65, 0x3a, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x32, 0x3a, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x1}, keying.ColumnAndJSONSelectorAndID("table", "field2", 1))
assert.Equal(t, []byte{0x74, 0x61, 0x62, 0x6c, 0x65, 0x3a, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x32, 0x3a, 0x7f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff}, keying.ColumnAndJSONSelectorAndID("table", "field2", math.MaxInt64))
assert.Equal(t, []byte{0x74, 0x61, 0x62, 0x6c, 0x65, 0x32, 0x3a, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x31, 0x3a, 0x80, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0}, keying.ColumnAndJSONSelectorAndID("table2", "field1", math.MinInt64))
assert.Equal(t, []byte{0x74, 0x61, 0x62, 0x6c, 0x65, 0x32, 0x3a, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x31, 0x3a, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff}, keying.ColumnAndJSONSelectorAndID("table2", "field1", -1))
assert.Equal(t, []byte{0x74, 0x61, 0x62, 0x6c, 0x65, 0x32, 0x3a, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x31, 0x3a, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0}, keying.ColumnAndJSONSelectorAndID("table2", "field1", 0))
assert.Equal(t, []byte{0x74, 0x61, 0x62, 0x6c, 0x65, 0x32, 0x3a, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x31, 0x3a, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x1}, keying.ColumnAndJSONSelectorAndID("table2", "field1", 1))
assert.Equal(t, []byte{0x74, 0x61, 0x62, 0x6c, 0x65, 0x32, 0x3a, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x31, 0x3a, 0x7f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff}, keying.ColumnAndJSONSelectorAndID("table2", "field1", math.MaxInt64))
}