Skip to content

fix(auth): encrypt secrets with AES-256-GCM and an Argon2id-derived key - #39540

Draft
bircni wants to merge 8 commits into
go-gitea:mainfrom
bircni:fix/totp-secret-pbkdf2-salt
Draft

bircni wants to merge 8 commits into
go-gitea:mainfrom
bircni:fix/totp-secret-pbkdf2-salt

Conversation

@bircni

@bircni bircni commented Oct 1, 2026 •

Copy link
Copy Markdown
Member

Fixes https://github.com/go-gitea/gitea/security/advisories/GHSA-673g-8f9j-6q25 (supersedes #36966). Every value encrypted with SECRET_KEY used an unstretched hash of it as the AES key, md5 for TOTP secrets and sha256 for webhook auth headers, Actions secrets, LDAP bind passwords and migration credentials. Any one of them allowed fast offline brute-force of a weak SECRET_KEY, so hardening only TOTP secrets would not help.

  • Derive one Argon2id key from SECRET_KEY once per process
  • Encrypt new values with AES-256-GCM instead of unauthenticated AES-CFB, prefixed with v2:
  • Route TOTP secrets through the shared EncryptSecret/DecryptSecret
  • Add a migration that re-encrypts all existing values
  • Keep reading legacy sha256 and md5 TOTP values that the migration could not convert, e.g. already queued migration tasks or a run under a wrong SECRET_KEY

Replace the unsalted MD5 AES key for TOTP secrets with PBKDF2 matching
scratch-token hashing, and re-encrypt legacy rows inside ValidateAndConsumeTOTP.

Assisted-by: Composer
@GiteaBot GiteaBot added the lgtm/need 2 This PR needs two approvals by maintainers to be considered for merging. label Oct 1, 2026
Encode salt as pbkdf2$<salt>$<ciphertext> in the existing column so legacy
base64 secrets need no schema change.

Assisted-by: Composer
@silverwind

Copy link
Copy Markdown
Member

This needs more work, it does not actually fix the advisory. Working on it.

Every value encrypted with SECRET_KEY used an unstretched hash of it as the
AES key, md5 for TOTP secrets and sha256 for webhook auth headers, Actions
secrets, LDAP bind passwords and migration credentials. Any one of them let
a weak SECRET_KEY be brute-forced quickly from a database dump, so stretching
only TOTP secrets did not help.

Derive a single Argon2id key once per process for all of them, mark new
ciphertexts with a "v2:" prefix and re-encrypt existing rows in a migration
instead of on login. Legacy values stay readable as a fallback for rows the
migration could not convert, such as already queued migration tasks.

Assisted-by: Claude Code:claude-opus-5-5
@silverwind silverwind changed the title fix(auth): encrypt TOTP secrets with PBKDF2 and per-user salt fix(auth): derive SECRET_KEY encryption key with Argon2id Oct 2, 2026
@silverwind

Copy link
Copy Markdown
Member

Current fix works, but has a migration, I'm checking if that can be avoided.

TOTP secrets are never queued, so the only remaining reason for a runtime
md5 fallback was a migration run under a wrong SECRET_KEY. Drop it to keep
the legacy format confined to migration 356 and trim the tests accordingly.

Assisted-by: Claude Code:claude-opus-5-5
@silverwind

silverwind commented Oct 2, 2026 •

Copy link
Copy Markdown
Member

Diff is now as small as it can get. There is no solution without the migration because weak encrypted values must be rewritten in the database, or else someone with a database dump could break the weak crypto in it.

The migration targets the next release, whose migrations live in v29.

Assisted-by: Claude Code:claude-opus-5-5
The v2 format already forces every value to be re-encrypted, so switch it
from unauthenticated AES-CFB to AES-256-GCM now. Tampered values and a wrong
SECRET_KEY then fail cleanly instead of decrypting to garbage. Also trim the
migration and its tests.

Assisted-by: Claude Code:claude-opus-5-5
@silverwind silverwind changed the title fix(auth): derive SECRET_KEY encryption key with Argon2id fix(auth): encrypt secrets with AES-256-GCM and an Argon2id-derived key Oct 2, 2026
If migration 356 runs under a wrong SECRET_KEY, it leaves TOTP rows in the
legacy format. Without a runtime fallback those stayed unreadable even after
restoring the right key. Share one md5 fallback between the runtime and the
migration.

Assisted-by: Claude Code:claude-opus-5-5

@silverwind silverwind left a comment •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Legacy fallback could be dropped again if we consider "running migration under wrong SECRET_KEY" to be too much of an edge case, but I kept it in.

@GiteaBot GiteaBot added lgtm/need 1 This PR needs approval from one additional maintainer to be merged. and removed lgtm/need 2 This PR needs two approvals by maintainers to be considered for merging. labels Oct 2, 2026
@wxiaoguang

wxiaoguang commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

Since it is a large migration, can we introduce the preparation for the key rotation?

Move all encrypted contents into a single table and manage them together. Then, to rotate, only need to update that table.

  • table: two_factor.secret = "ref:abcdefg"
  • table: encrypted_content: ref_id="abcdefg", encrypted_value="v2:{base64 encrypted content}"

Pros: easy to rotate the master key
Cons: how to delete the encrypted_content when a two_factor record is deleted.

(IIRC there was a discussion in discord, cc @TheFox0x7 )


I am neutral for the decisions, so this comment is just a question or suggestion, no blocker.

@silverwind
silverwind self-requested a review October 2, 2026 18:22
@GiteaBot GiteaBot added lgtm/need 2 This PR needs two approvals by maintainers to be considered for merging. and removed lgtm/need 1 This PR needs approval from one additional maintainer to be merged. labels Oct 2, 2026
@silverwind

Copy link
Copy Markdown
Member

Sounds doable. I think I will also drop this MD5 fallback after all to keep the codebase clean from legacy mechanisms that could trigger security scanners needlessly.

@wxiaoguang

Copy link
Copy Markdown
Contributor

Sounds doable. I think I will also drop this MD5 fallback after all to keep the codebase clean from legacy mechanisms that could trigger security scanners needlessly.

I haven't thought about the problem carefully, so not sure whether we should do that (single encrypt content table) or not. It really depends on how Gitea will manage the encrypted content in the future. If we'd like to do more, we need a careful and complete design first.

Or, just use the current good enough approach, leave more discussions to the future.

@wxiaoguang

wxiaoguang commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

A quick idea: without introducing a single encrypted content table, maybe we can move ReencryptSecrets to a new standalone package. Make it can use old secrets to decrypt the contents, and use a new secret to encrypt the contents. For this PR's migration, old secret is the one from config file, new secret is the derived one (also, versioned)

Then, we can introduce a cli sub-command to call ReencryptSecrets.

This seems a minimal change, won't make anything more complicated, and should be good enough for the future.

@TheFox0x7

Copy link
Copy Markdown
Contributor

I redid my research into that (I'll recheck with previous looks when I'll get a chance) and I think the one common table would not be a good approach. For it to work properly you need FKs to lock deletion if someone holds a reference and while it's nice and simple to migrate there's not much use in it apart from that - unless you'd be planning to enable multiple references... so a user could define a secret once and reuse that somewhere else from a dropdown or such.

Plain columns are probably simpler to use and maintain - no joins, no table, no possibly broken references if something goes wrong. It's what grafana and gitlab use so I'd assume it's a sane pick for this. Downside being the spread between tables but it's not like this changes much.

@techknowlogick

Copy link
Copy Markdown
Member

Does this also handle this case: #16832 ?

@wxiaoguang

Copy link
Copy Markdown
Contributor

Plain columns are probably simpler to use and maintain - no joins, no table, no possibly broken references if something goes wrong. It's what grafana and gitlab use so I'd assume it's a sane pick for this. Downside being the spread between tables but it's not like this changes much.

Agree, I proposed maybe we can move ReencryptSecrets to a new standalone package, then it can be reused later.

@wxiaoguang

wxiaoguang commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

Any one of them allowed fast offline brute-force of a weak SECRET_KEY .... Add a migration that re-encrypts all existing values

By the way, I think this PR doesn't really help the situation.

The current situation is: most instances are using the default SECRET_KEY. So, even if you use a derived key to re-encrypt the values, the derived key is still the same for most instances (no need to brute-force)

@wxiaoguang
wxiaoguang marked this pull request as draft October 3, 2026 03:19
@bircni bircni closed this Oct 3, 2026
@bircni
bircni deleted the fix/totp-secret-pbkdf2-salt branch October 3, 2026 23:17
@bircni
bircni restored the fix/totp-secret-pbkdf2-salt branch October 3, 2026 23:17
@bircni bircni reopened this Oct 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

lgtm/need 2 This PR needs two approvals by maintainers to be considered for merging. topic/authentication type/bug

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants