feat: add deploy tokens (#37306)

Deploy keys only work over SSH. A deploy token is their counterpart for HTTPS: a repository scoped credential, used as the password of a Git request, with read or read and write access. It covers Git operations and LFS, and can be regenerated in place.

Signed-off-by: silverwind <me@silverwind.io>
Co-authored-by: Claude Mythos <noreply@anthropic.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: bircni <bircni@icloud.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
ToastyTheBot
2026-08-27 03:32:44 +08:00
committed by GitHub
parent 3c4d5a6a5c
commit 646ea0f253
76 changed files with 1594 additions and 831 deletions
+99
View File
@@ -0,0 +1,99 @@
// Copyright 2021 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package deploykey
import (
"context"
"time"
"gitea.dev/models/asymkey"
"gitea.dev/models/db"
"gitea.dev/models/perm"
"gitea.dev/modules/timeutil"
"xorm.io/builder"
)
type KeyType int // SSH public key or HTTP auth token
const (
KeyTypeSSH KeyType = iota + 1
KeyTypeToken
)
type DeployKey struct {
ID int64 `xorm:"pk autoincr"`
KeyID int64 `xorm:"INDEX"`
RepoID int64 `xorm:"INDEX"`
Name string
KeyType KeyType `xorm:"NOT NULL DEFAULT 1"`
Fingerprint string
PublicKey *asymkey.PublicKey `xorm:"-"`
TokenHash string `xorm:"INDEX"` // sha256 of the token, which carries enough entropy to need no salt
Token string `xorm:"-"` // only set when the token is created
Mode perm.AccessMode `xorm:"NOT NULL DEFAULT 1"`
CreatedUnix timeutil.TimeStamp `xorm:"created"`
UpdatedUnix timeutil.TimeStamp `xorm:"updated"`
}
// these methods below are mainly used by templates
func (key *DeployKey) HasRecentActivity() bool {
return key.UpdatedUnix.AddDuration(7*24*time.Hour) > timeutil.TimeStampNow()
}
func (key *DeployKey) HasUsed() bool { return key.UpdatedUnix > key.CreatedUnix }
func (key *DeployKey) IsReadOnly() bool { return key.Mode == perm.AccessModeRead }
func (key *DeployKey) IsKeyTypeToken() bool { return key.KeyType == KeyTypeToken }
func init() {
db.RegisterModel(new(DeployKey))
}
func checkDeployKeyName(ctx context.Context, repoID int64, name string) error {
has, err := db.Exist[DeployKey](ctx, builder.Eq{"repo_id": repoID, "name": name})
if err != nil {
return err
} else if has {
return ErrDeployKeyNameAlreadyUsed{repoID, name}
}
return nil
}
// UpdateDeployKeyLastUsed marks the key as used now.
func UpdateDeployKeyLastUsed(ctx context.Context, id int64) error {
_, err := db.GetEngine(ctx).ID(id).Cols("updated_unix").Update(&DeployKey{UpdatedUnix: timeutil.TimeStampNow()})
return err
}
// ListDeployKeysOptions are options for ListDeployKeys
type ListDeployKeysOptions struct {
db.ListOptions
RepoID int64
KeyID int64
Fingerprint string
}
func (opt ListDeployKeysOptions) ToOrders() string {
return "name"
}
func (opt ListDeployKeysOptions) ToConds() builder.Cond {
cond := builder.NewCond()
cond = cond.And(builder.Eq{"repo_id": opt.RepoID}) // repo ID must be used
if opt.KeyID != 0 {
cond = cond.And(builder.Eq{"key_id": opt.KeyID})
}
if opt.Fingerprint != "" {
cond = cond.And(builder.Eq{"fingerprint": opt.Fingerprint})
}
return cond
}
+75
View File
@@ -0,0 +1,75 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package deploykey
import (
"context"
"gitea.dev/models/asymkey"
"gitea.dev/models/db"
"gitea.dev/models/perm"
"gitea.dev/modules/util"
"xorm.io/builder"
)
// AddDeployKeySSH add new deploy-key to database and authorized_keys file.
func AddDeployKeySSH(ctx context.Context, repoID int64, name, content string, accessMode perm.AccessMode) (*DeployKey, error) {
if accessMode != perm.AccessModeRead && accessMode != perm.AccessModeWrite {
return nil, util.NewInvalidArgumentErrorf("invalid access mode")
}
return db.WithTx2(ctx, func(ctx context.Context) (*DeployKey, error) {
pkey, err := asymkey.FindOrAddDeployPublicKey(ctx, content)
if err != nil {
return nil, err
}
if has, err := db.Exist[DeployKey](ctx, builder.Eq{"repo_id": repoID, "key_id": pkey.ID}); err != nil {
return nil, err
} else if has {
return nil, ErrDeployKeyAlreadyExist{pkey.ID, repoID}
}
if err := checkDeployKeyName(ctx, repoID, name); err != nil {
return nil, err
}
key := &DeployKey{KeyID: pkey.ID, RepoID: repoID, KeyType: KeyTypeSSH, Name: name, Fingerprint: pkey.Fingerprint, Mode: accessMode}
return key, db.Insert(ctx, key)
})
}
func (key *DeployKey) LoadPublicKey(ctx context.Context) (err error) {
if key.PublicKey != nil {
return nil
}
key.PublicKey, err = asymkey.GetPublicKeyByID(ctx, key.KeyID)
return err
}
// GetDeployKeyByID returns deploy-key by given ID.
func GetDeployKeyByID(ctx context.Context, repoID, deployKeyID int64) (*DeployKey, error) {
key, exist, err := db.Get[DeployKey](ctx, builder.Eq{"id": deployKeyID, "repo_id": repoID})
if err != nil {
return nil, err
} else if !exist {
return nil, ErrDeployKeyNotExist{deployKeyID, 0, repoID}
}
return key, nil
}
// GetDeployKeyByRepoPublicKey returns deploy-key by given public key ID and repository ID.
func GetDeployKeyByRepoPublicKey(ctx context.Context, repoID, publicKeyID int64) (*DeployKey, error) {
// the type is part of the condition because every token row carries key id 0
key, exist, err := db.Get[DeployKey](ctx, builder.Eq{"key_id": publicKeyID, "repo_id": repoID, "key_type": KeyTypeSSH})
if err != nil {
return nil, err
} else if !exist {
return nil, ErrDeployKeyNotExist{0, publicKeyID, repoID}
}
return key, nil
}
// IsDeployKeyExistByPublicKeyID return true if there is at least one deploy-key with the key id
func IsDeployKeyExistByPublicKeyID(ctx context.Context, keyID int64) (bool, error) {
return db.Exist[DeployKey](ctx, builder.Eq{"key_id": keyID, "key_type": KeyTypeSSH})
}
+76
View File
@@ -0,0 +1,76 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package deploykey
import (
"context"
"strings"
"gitea.dev/models/db"
"gitea.dev/models/perm"
"gitea.dev/modules/base"
"gitea.dev/modules/util"
"xorm.io/builder"
)
const (
DeployTokenPrefix = "gdt_" // lets a secret scanner recognize a leaked token
deployTokenLength = 43 // 256 bits of entropy over the 62 alphanumerical characters
)
func (key *DeployKey) generateToken() {
key.Token = DeployTokenPrefix + util.CryptoRandomString(deployTokenLength)
key.TokenHash = base.EncodeSha256(key.Token)
key.Fingerprint = key.Token[:len(DeployTokenPrefix)+2] + "********" + key.Token[len(key.Token)-2:]
}
// AddDeployKeyToken adds a token that authenticates git HTTP requests for one repository.
// The plaintext token is only readable on the returned key.
func AddDeployKeyToken(ctx context.Context, repoID int64, name string, accessMode perm.AccessMode) (*DeployKey, error) {
key := &DeployKey{
RepoID: repoID,
KeyType: KeyTypeToken,
Name: name,
Mode: accessMode,
}
key.generateToken()
return db.WithTx2(ctx, func(ctx context.Context) (*DeployKey, error) {
if err := checkDeployKeyName(ctx, repoID, name); err != nil {
return nil, err
}
return key, db.Insert(ctx, key)
})
}
// RegenerateDeployKeyToken replaces the token value of an existing deploy token, keeping its name and access mode.
func RegenerateDeployKeyToken(ctx context.Context, repoID, keyID int64) (*DeployKey, error) {
key, err := GetDeployKeyByID(ctx, repoID, keyID)
if err != nil {
return nil, err
}
if key.KeyType != KeyTypeToken {
return nil, ErrDeployKeyNotExist{keyID, 0, repoID}
}
key.generateToken()
_, err = db.GetEngine(ctx).ID(key.ID).Cols("token_hash", "fingerprint").NoAutoTime().Update(key)
return key, err
}
// VerifyDeployKeyToken returns the deploy-key which the given plaintext token authenticates.
func VerifyDeployKeyToken(ctx context.Context, token string) (*DeployKey, error) {
if !strings.HasPrefix(token, DeployTokenPrefix) { // spares a query for every password of a normal user
return nil, ErrDeployKeyNotExist{}
}
key, exist, err := db.Get[DeployKey](ctx, builder.Eq{"token_hash": base.EncodeSha256(token), "key_type": KeyTypeToken})
if err != nil {
return nil, err
} else if !exist {
return nil, ErrDeployKeyNotExist{}
}
return key, nil
}
+52
View File
@@ -0,0 +1,52 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package deploykey
import (
"testing"
"gitea.dev/models/db"
"gitea.dev/models/perm"
"gitea.dev/models/unittest"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestDeployToken(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
key, err := AddDeployKeyToken(t.Context(), 1, "ci", perm.AccessModeWrite)
require.NoError(t, err)
assert.False(t, key.IsReadOnly())
assert.Len(t, key.Token, len(DeployTokenPrefix)+deployTokenLength)
got, err := VerifyDeployKeyToken(t.Context(), key.Token)
require.NoError(t, err)
assert.Equal(t, key.ID, got.ID)
assert.Empty(t, got.Token, "the token itself is never stored")
_, err = VerifyDeployKeyToken(t.Context(), "not-a-token")
assert.True(t, IsErrDeployKeyNotExist(err))
_, err = AddDeployKeyToken(t.Context(), 1, "ci", perm.AccessModeWrite)
assert.True(t, IsErrDeployKeyNameAlreadyUsed(err))
regenerated, err := RegenerateDeployKeyToken(t.Context(), 1, key.ID)
require.NoError(t, err)
assert.Equal(t, key.Name, regenerated.Name)
assert.Equal(t, key.Mode, regenerated.Mode)
assert.False(t, unittest.AssertExistsAndLoadBean(t, &DeployKey{ID: key.ID}).HasUsed(), "regenerating is not a use")
_, err = VerifyDeployKeyToken(t.Context(), key.Token)
assert.True(t, IsErrDeployKeyNotExist(err), "the old token stops working")
_, err = VerifyDeployKeyToken(t.Context(), regenerated.Token)
require.NoError(t, err)
// an SSH deploy-key has no token to regenerate
sshKey := &DeployKey{RepoID: 1, KeyType: KeyTypeSSH, Name: "ssh"}
require.NoError(t, db.Insert(t.Context(), sshKey))
_, err = RegenerateDeployKeyToken(t.Context(), 1, sshKey.ID)
assert.True(t, IsErrDeployKeyNotExist(err))
}
+71
View File
@@ -0,0 +1,71 @@
// Copyright 2021 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package deploykey
import (
"fmt"
"gitea.dev/modules/util"
)
// ErrDeployKeyNotExist represents a "DeployKeyNotExist" kind of error.
type ErrDeployKeyNotExist struct {
ID int64
KeyID int64
RepoID int64
}
// IsErrDeployKeyNotExist checks if an error is a ErrDeployKeyNotExist.
func IsErrDeployKeyNotExist(err error) bool {
_, ok := err.(ErrDeployKeyNotExist)
return ok
}
func (err ErrDeployKeyNotExist) Error() string {
return fmt.Sprintf("Deploy key does not exist [id: %d, key_id: %d, repo_id: %d]", err.ID, err.KeyID, err.RepoID)
}
func (err ErrDeployKeyNotExist) Unwrap() error {
return util.ErrNotExist
}
// ErrDeployKeyAlreadyExist represents a "DeployKeyAlreadyExist" kind of error.
type ErrDeployKeyAlreadyExist struct {
KeyID int64
RepoID int64
}
// IsErrDeployKeyAlreadyExist checks if an error is a ErrDeployKeyAlreadyExist.
func IsErrDeployKeyAlreadyExist(err error) bool {
_, ok := err.(ErrDeployKeyAlreadyExist)
return ok
}
func (err ErrDeployKeyAlreadyExist) Error() string {
return fmt.Sprintf("public key already exists [key_id: %d, repo_id: %d]", err.KeyID, err.RepoID)
}
func (err ErrDeployKeyAlreadyExist) Unwrap() error {
return util.ErrAlreadyExist
}
// ErrDeployKeyNameAlreadyUsed represents a "DeployKeyNameAlreadyUsed" kind of error.
type ErrDeployKeyNameAlreadyUsed struct {
RepoID int64
Name string
}
// IsErrDeployKeyNameAlreadyUsed checks if an error is a ErrDeployKeyNameAlreadyUsed.
func IsErrDeployKeyNameAlreadyUsed(err error) bool {
_, ok := err.(ErrDeployKeyNameAlreadyUsed)
return ok
}
func (err ErrDeployKeyNameAlreadyUsed) Error() string {
return fmt.Sprintf("public key with name already exists [repo_id: %d, name: %s]", err.RepoID, err.Name)
}
func (err ErrDeployKeyNameAlreadyUsed) Unwrap() error {
return util.ErrNotExist
}
+14
View File
@@ -0,0 +1,14 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package deploykey
import (
"testing"
"gitea.dev/models/unittest"
)
func TestMain(m *testing.M) {
unittest.MainTest(m, &unittest.TestOptions{FixtureFiles: []string{}}) // the tests insert what they assert on
}