diff --git a/models/asymkey/error.go b/models/asymkey/error.go index 5df7beb8cd..6c0db1dc7c 100644 --- a/models/asymkey/error.go +++ b/models/asymkey/error.go @@ -276,6 +276,48 @@ func (err ErrDeployKeyNameAlreadyUsed) Unwrap() error { return util.ErrNotExist } +// ErrHTTPSDeployKeyNotExist is returned when a lookup of an HTTPS deploy key +// fails. Either of ID or RepoID may be zero depending on the call site. +type ErrHTTPSDeployKeyNotExist struct { + ID int64 + RepoID int64 +} + +// IsErrHTTPSDeployKeyNotExist checks if an error is an ErrHTTPSDeployKeyNotExist. +func IsErrHTTPSDeployKeyNotExist(err error) bool { + _, ok := err.(ErrHTTPSDeployKeyNotExist) + return ok +} + +func (err ErrHTTPSDeployKeyNotExist) Error() string { + return fmt.Sprintf("HTTPS deploy key does not exist [id: %d, repo_id: %d]", err.ID, err.RepoID) +} + +func (err ErrHTTPSDeployKeyNotExist) Unwrap() error { + return util.ErrNotExist +} + +// ErrHTTPSDeployKeyNameAlreadyUsed is returned when creating an HTTPS deploy +// key would collide with an existing name under the same repository. +type ErrHTTPSDeployKeyNameAlreadyUsed struct { + RepoID int64 + Name string +} + +// IsErrHTTPSDeployKeyNameAlreadyUsed checks if an error is an ErrHTTPSDeployKeyNameAlreadyUsed. +func IsErrHTTPSDeployKeyNameAlreadyUsed(err error) bool { + _, ok := err.(ErrHTTPSDeployKeyNameAlreadyUsed) + return ok +} + +func (err ErrHTTPSDeployKeyNameAlreadyUsed) Error() string { + return fmt.Sprintf("HTTPS deploy key with name already exists [repo_id: %d, name: %s]", err.RepoID, err.Name) +} + +func (err ErrHTTPSDeployKeyNameAlreadyUsed) Unwrap() error { + return util.ErrAlreadyExist +} + // ErrSSHInvalidTokenSignature represents a "ErrSSHInvalidTokenSignature" kind of error. type ErrSSHInvalidTokenSignature struct { Wrapped error diff --git a/models/asymkey/https_deploy_key.go b/models/asymkey/https_deploy_key.go new file mode 100644 index 0000000000..334e491f85 --- /dev/null +++ b/models/asymkey/https_deploy_key.go @@ -0,0 +1,199 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package asymkey + +import ( + "context" + "crypto/subtle" + "encoding/hex" + "time" + + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/perm" + "code.gitea.io/gitea/modules/timeutil" + "code.gitea.io/gitea/modules/util" + + "xorm.io/builder" +) + +// HTTPSDeployKeyTokenLength is the expected length of a hex-encoded deploy +// token (20 random bytes → 40 hex chars). +const HTTPSDeployKeyTokenLength = 40 + +// HTTPSDeployKey is a per-repository credential that authenticates Git +// operations over HTTPS without being tied to a user account. It mirrors the +// semantics of the SSH DeployKey (RepoID + Mode) but carries a hashed bearer +// token instead of a public-key fingerprint. +type HTTPSDeployKey struct { + ID int64 `xorm:"pk autoincr"` + RepoID int64 `xorm:"INDEX UNIQUE(s) NOT NULL"` + Name string `xorm:"UNIQUE(s) NOT NULL"` + TokenHash string `xorm:"UNIQUE NOT NULL"` + TokenSalt string `xorm:"NOT NULL"` + TokenLastEight string `xorm:"INDEX"` + Mode perm.AccessMode `xorm:"NOT NULL DEFAULT 1"` + + // Token holds the plaintext token only on the row returned from a creation + // call. It is never read from or written to the database. + Token string `xorm:"-"` + + CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"` + UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"` + HasRecentActivity bool `xorm:"-"` + HasUsed bool `xorm:"-"` +} + +// AfterLoad populates derived display fields after XORM reads a row. +func (k *HTTPSDeployKey) AfterLoad() { + k.HasUsed = k.UpdatedUnix > k.CreatedUnix + k.HasRecentActivity = k.UpdatedUnix.AddDuration(7*24*time.Hour) > timeutil.TimeStampNow() +} + +// IsReadOnly reports whether the key grants only read access to its +// repository. +func (k *HTTPSDeployKey) IsReadOnly() bool { + return k.Mode == perm.AccessModeRead +} + +func init() { + db.RegisterModel(new(HTTPSDeployKey)) +} + +// tokenIsValidFormat reports whether s looks like a serialized deploy token +// (40 lowercase hex chars). We reject everything else early so that an +// incidental basic-auth password can never collide with the token lookup. +func tokenIsValidFormat(s string) bool { + if len(s) != HTTPSDeployKeyTokenLength { + return false + } + for i := 0; i < len(s); i++ { + c := s[i] + if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) { + return false + } + } + return true +} + +// AddHTTPSDeployKey creates a new HTTPS deploy key for the given repository +// and returns both the stored row and the plaintext token. The plaintext is +// only returned here; callers must surface it to the user exactly once. +func AddHTTPSDeployKey(ctx context.Context, repoID int64, name string, readOnly bool) (*HTTPSDeployKey, string, error) { + if name == "" { + return nil, "", util.NewInvalidArgumentErrorf("deploy key name must not be empty") + } + + salt := util.CryptoRandomString(10) + tokenBytes := util.CryptoRandomBytes(20) + token := hex.EncodeToString(tokenBytes) + + mode := perm.AccessModeRead + if !readOnly { + mode = perm.AccessModeWrite + } + + now := timeutil.TimeStampNow() + key := &HTTPSDeployKey{ + RepoID: repoID, + Name: name, + TokenHash: auth_model.HashToken(token, salt), + TokenSalt: salt, + TokenLastEight: token[len(token)-8:], + Mode: mode, + CreatedUnix: now, + UpdatedUnix: now, + } + + insertErr := db.WithTx(ctx, func(ctx context.Context) error { + has, err := db.GetEngine(ctx).Where("repo_id = ? AND name = ?", repoID, name).Exist(new(HTTPSDeployKey)) + if err != nil { + return err + } + if has { + return ErrHTTPSDeployKeyNameAlreadyUsed{RepoID: repoID, Name: name} + } + + _, err = db.GetEngine(ctx).NoAutoTime().Insert(key) + if err != nil { + return ErrHTTPSDeployKeyNameAlreadyUsed{RepoID: repoID, Name: name} + } + return nil + }) + if insertErr != nil { + return nil, "", insertErr + } + + key.Token = token + return key, token, nil +} + +// GetHTTPSDeployKeyByID loads a single HTTPS deploy key by its primary key. +func GetHTTPSDeployKeyByID(ctx context.Context, id int64) (*HTTPSDeployKey, error) { + key, exist, err := db.GetByID[HTTPSDeployKey](ctx, id) + if err != nil { + return nil, err + } + if !exist { + return nil, ErrHTTPSDeployKeyNotExist{ID: id} + } + return key, nil +} + +// ListHTTPSDeployKeysOptions filters a list query. +type ListHTTPSDeployKeysOptions struct { + db.ListOptions + RepoID int64 +} + +// ToConds implements db.FindOptions. +func (opt ListHTTPSDeployKeysOptions) ToConds() builder.Cond { + cond := builder.NewCond() + if opt.RepoID != 0 { + cond = cond.And(builder.Eq{"repo_id": opt.RepoID}) + } + return cond +} + +// DeleteHTTPSDeployKey removes the key identified by (repoID, id). The repo +// scope is required so that a caller in one repository cannot drop a token +// belonging to another. +func DeleteHTTPSDeployKey(ctx context.Context, repoID, id int64) error { + cnt, err := db.GetEngine(ctx).Where("repo_id = ?", repoID).ID(id).Delete(new(HTTPSDeployKey)) + if err != nil { + return err + } + if cnt == 0 { + return ErrHTTPSDeployKeyNotExist{ID: id, RepoID: repoID} + } + return nil +} + +// VerifyHTTPSDeployToken returns the key that the given plaintext token +// authenticates, or ErrHTTPSDeployKeyNotExist if no key matches. +func VerifyHTTPSDeployToken(ctx context.Context, token string) (*HTTPSDeployKey, error) { + if !tokenIsValidFormat(token) { + _ = auth_model.HashToken(token, util.CryptoRandomString(10)) // dummy to prevent timing side-channel + return nil, ErrHTTPSDeployKeyNotExist{} + } + + lastEight := token[len(token)-8:] + var candidates []HTTPSDeployKey + if err := db.GetEngine(ctx).Where("token_last_eight = ?", lastEight).Find(&candidates); err != nil { + return nil, err + } + + for i := range candidates { + expected := auth_model.HashToken(token, candidates[i].TokenSalt) + if subtle.ConstantTimeCompare([]byte(candidates[i].TokenHash), []byte(expected)) == 1 { + k := candidates[i] + k.UpdatedUnix = timeutil.TimeStampNow() + if _, err := db.GetEngine(ctx).ID(k.ID).Cols("updated_unix").Update(&k); err != nil { + return nil, err + } + return &k, nil + } + } + return nil, ErrHTTPSDeployKeyNotExist{} +} diff --git a/models/asymkey/https_deploy_key_test.go b/models/asymkey/https_deploy_key_test.go new file mode 100644 index 0000000000..40cc56b5bc --- /dev/null +++ b/models/asymkey/https_deploy_key_test.go @@ -0,0 +1,416 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package asymkey + +import ( + "context" + "crypto/rand" + "encoding/hex" + "strings" + "testing" + "time" + + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/unittest" + "code.gitea.io/gitea/modules/util" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestHTTPSDeployKeyTokenLength(t *testing.T) { + // Verify the token length constant is used for validation. + assert.True(t, tokenIsValidFormat(strings.Repeat("a", HTTPSDeployKeyTokenLength))) + assert.False(t, tokenIsValidFormat(strings.Repeat("a", HTTPSDeployKeyTokenLength-1))) + assert.False(t, tokenIsValidFormat(strings.Repeat("a", HTTPSDeployKeyTokenLength+1))) + assert.False(t, tokenIsValidFormat(strings.Repeat("g", HTTPSDeployKeyTokenLength))) +} + +func TestTokenIsValidFormatEdgeCases(t *testing.T) { + // Valid: 40 lowercase hex chars + assert.True(t, tokenIsValidFormat("aaaaaaaaaa000000000011111111112222222222")) + + // Reject uppercase hex + assert.False(t, tokenIsValidFormat(strings.Repeat("A", HTTPSDeployKeyTokenLength))) + assert.False(t, tokenIsValidFormat("AAAAAAAAAA000000000011111111112222222222")) + + // Reject whitespace + assert.False(t, tokenIsValidFormat(" "+strings.Repeat("a", HTTPSDeployKeyTokenLength-1))) + assert.False(t, tokenIsValidFormat(strings.Repeat("a", HTTPSDeployKeyTokenLength-1)+" ")) + assert.False(t, tokenIsValidFormat(strings.Repeat("a", HTTPSDeployKeyTokenLength/2)+" "+strings.Repeat("a", HTTPSDeployKeyTokenLength/2-1))) + + // Reject empty string + assert.False(t, tokenIsValidFormat("")) + + // Reject special characters + assert.False(t, tokenIsValidFormat(strings.Repeat("!", HTTPSDeployKeyTokenLength))) + assert.False(t, tokenIsValidFormat(strings.Repeat("@", HTTPSDeployKeyTokenLength))) + + // Reject mixed case + assert.False(t, tokenIsValidFormat("AaAaAaAaAa0000000000111111111122222222")) +} + +func TestAddHTTPSDeployKeyEmptyName(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + _, _, err := AddHTTPSDeployKey(t.Context(), 1, "", true) + require.Error(t, err) + assert.Contains(t, err.Error(), "empty") +} + +func TestAddHTTPSDeployKey(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + key, token, err := AddHTTPSDeployKey(t.Context(), 1, "ci-readonly", true) + require.NoError(t, err) + require.NotNil(t, key) + + assert.Equal(t, int64(1), key.RepoID) + assert.Equal(t, "ci-readonly", key.Name) + assert.True(t, key.IsReadOnly()) + assert.Len(t, token, HTTPSDeployKeyTokenLength, "token should match HTTPSDeployKeyTokenLength") + for _, r := range token { + ok := (r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') + assert.Truef(t, ok, "token contains non-hex char %q", r) + } + + // Verify TokenHash is non-empty (token was hashed) + assert.NotEmpty(t, key.TokenHash, "TokenHash must be set after create") + + // Verify TokenLastEight matches the actual token suffix + assert.Equal(t, token[len(token)-8:], key.TokenLastEight, "TokenLastEight must match token suffix") + + // Verify timestamps are set + assert.NotZero(t, key.CreatedUnix, "CreatedUnix must be set after create") + assert.NotZero(t, key.UpdatedUnix, "UpdatedUnix must be set after create") + + got, err := GetHTTPSDeployKeyByID(t.Context(), key.ID) + require.NoError(t, err) + assert.Equal(t, key.ID, got.ID) + assert.Equal(t, key.TokenHash, got.TokenHash) + assert.Empty(t, got.Token, "plaintext token must not be persisted") + + _ = DeleteHTTPSDeployKey(t.Context(), 1, key.ID) +} + +func TestAddHTTPSDeployKey_NameUnique(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + keyA, _, err := AddHTTPSDeployKey(t.Context(), 1, "dup", false) + require.NoError(t, err) + defer DeleteHTTPSDeployKey(t.Context(), 1, keyA.ID) + + _, _, err = AddHTTPSDeployKey(t.Context(), 1, "dup", false) + require.Error(t, err) + assert.True(t, IsErrHTTPSDeployKeyNameAlreadyUsed(err), + "expected ErrHTTPSDeployKeyNameAlreadyUsed, got %T: %v", err, err) + + // Same name on a different repo is fine. + keyB, _, err := AddHTTPSDeployKey(t.Context(), 2, "dup", false) + require.NoError(t, err) + defer DeleteHTTPSDeployKey(t.Context(), 2, keyB.ID) +} + +func TestListHTTPSDeployKeys(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + keyA, _, err := AddHTTPSDeployKey(t.Context(), 1, "t-list-a", true) + require.NoError(t, err) + defer DeleteHTTPSDeployKey(t.Context(), 1, keyA.ID) + + keyB, _, err := AddHTTPSDeployKey(t.Context(), 1, "t-list-b", false) + require.NoError(t, err) + defer DeleteHTTPSDeployKey(t.Context(), 1, keyB.ID) + + keyC, _, err := AddHTTPSDeployKey(t.Context(), 2, "t-list-c", true) + require.NoError(t, err) + defer DeleteHTTPSDeployKey(t.Context(), 2, keyC.ID) + + keys, err := db.Find[HTTPSDeployKey](t.Context(), + ListHTTPSDeployKeysOptions{RepoID: 1}) + require.NoError(t, err) + names := make(map[string]struct{}, len(keys)) + for _, k := range keys { + names[k.Name] = struct{}{} + } + assert.Contains(t, names, "t-list-a") + assert.Contains(t, names, "t-list-b") + + keys, err = db.Find[HTTPSDeployKey](t.Context(), + ListHTTPSDeployKeysOptions{RepoID: 2}) + require.NoError(t, err) + names = make(map[string]struct{}, len(keys)) + for _, k := range keys { + names[k.Name] = struct{}{} + } + assert.Contains(t, names, "t-list-c") +} + +func TestDeleteHTTPSDeployKey(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + key, _, err := AddHTTPSDeployKey(t.Context(), 1, "to-delete", true) + require.NoError(t, err) + + require.NoError(t, DeleteHTTPSDeployKey(t.Context(), 1, key.ID)) + + _, err = GetHTTPSDeployKeyByID(t.Context(), key.ID) + require.Error(t, err) + assert.True(t, IsErrHTTPSDeployKeyNotExist(err)) + + // Deleting a key that belongs to a different repo must fail cleanly. + key, _, err = AddHTTPSDeployKey(t.Context(), 1, "stays", true) + require.NoError(t, err) + err = DeleteHTTPSDeployKey(t.Context(), 2, key.ID) + require.Error(t, err) + assert.True(t, IsErrHTTPSDeployKeyNotExist(err)) +} + +func TestVerifyHTTPSDeployToken(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + key, token, err := AddHTTPSDeployKey(t.Context(), 1, "verify", false) + require.NoError(t, err) + + got, err := VerifyHTTPSDeployToken(t.Context(), token) + require.NoError(t, err) + assert.Equal(t, key.ID, got.ID) + assert.Equal(t, key.RepoID, got.RepoID) + + _, err = VerifyHTTPSDeployToken(t.Context(), "0000000000000000000000000000000000000000") + require.Error(t, err) + assert.True(t, IsErrHTTPSDeployKeyNotExist(err)) + + _, err = VerifyHTTPSDeployToken(t.Context(), "") + require.Error(t, err) + + _, err = VerifyHTTPSDeployToken(t.Context(), "not-hex") + require.Error(t, err) + + _ = DeleteHTTPSDeployKey(t.Context(), 1, key.ID) +} + +func TestHTTPSDeployKeyAfterLoad(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + key, token, err := AddHTTPSDeployKey(t.Context(), 1, "afterload-test", true) + require.NoError(t, err) + defer DeleteHTTPSDeployKey(t.Context(), 1, key.ID) + + // Fresh key: CreatedUnix == UpdatedUnix, so HasUsed should be false + got, err := GetHTTPSDeployKeyByID(t.Context(), key.ID) + require.NoError(t, err) + assert.False(t, got.HasUsed, "fresh key should not have HasUsed") + assert.True(t, got.HasRecentActivity, "fresh key should have recent activity") + + // After verify: UpdatedUnix > CreatedUnix, so HasUsed should be true + // Sleep to ensure second-level granularity differs. + time.Sleep(1 * time.Second) + _, err = VerifyHTTPSDeployToken(t.Context(), token) + require.NoError(t, err) + + got, err = GetHTTPSDeployKeyByID(t.Context(), key.ID) + require.NoError(t, err) + assert.True(t, got.HasUsed, "key should show HasUsed after verify") + assert.True(t, got.HasRecentActivity, "key should still have recent activity") + assert.NotEqual(t, got.CreatedUnix, got.UpdatedUnix, "UpdatedUnix should differ from CreatedUnix after use") +} + +func TestVerifyHTTPSDeployTokenUpdatesTimestamp(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + key, token, err := AddHTTPSDeployKey(t.Context(), 1, "timestamp-test", false) + require.NoError(t, err) + defer DeleteHTTPSDeployKey(t.Context(), 1, key.ID) + + originalUpdated := key.UpdatedUnix + + // Sleep to ensure second-level granularity differs. + time.Sleep(1 * time.Second) + got, err := VerifyHTTPSDeployToken(t.Context(), token) + require.NoError(t, err) + assert.Greater(t, got.UpdatedUnix, originalUpdated, "UpdatedUnix should increase after verification") +} + +func TestAddHTTPSDeployKey_ConcurrentInsert(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + // Attempt concurrent inserts with the same (repoID, name) inside a transaction. + // Only one should succeed; the other must receive ErrHTTPSDeployKeyNameAlreadyUsed. + var results [3]struct { + key *HTTPSDeployKey + err error + } + + done := make(chan struct{}) + for idx := range 3 { + go func(i int) { + defer func() { done <- struct{}{} }() + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + + key, _, err := AddHTTPSDeployKey(ctx, 1, "concurrent-key", true) + results[i] = struct { + key *HTTPSDeployKey + err error + }{key: key, err: err} + }(idx) + } + + for range 3 { + <-done + } + + successCount := 0 + for i := range results { + if results[i].err == nil { + successCount++ + assert.NotNil(t, results[i].key, "result[%d]: key should not be nil on success", i) + _ = DeleteHTTPSDeployKey(t.Context(), 1, results[i].key.ID) + } else { + assert.True(t, IsErrHTTPSDeployKeyNameAlreadyUsed(results[i].err), + "result[%d]: expected ErrHTTPSDeployKeyNameAlreadyUsed, got %T: %v", + i, results[i].err, results[i].err) + } + } + + assert.Equal(t, 1, successCount, "exactly one concurrent insert should succeed") +} + +func TestVerifyHTTPSDeployToken_TimingResistance(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + // Create a valid key so that valid-format tokens trigger the hash lookup path. + key, _, err := AddHTTPSDeployKey(t.Context(), 1, "timing-test", true) + require.NoError(t, err) + defer DeleteHTTPSDeployKey(t.Context(), 1, key.ID) + + // Generate a fake valid-format token that does not match any key. + validFormatToken := func() string { + b := make([]byte, 20) + _, _ = rand.Read(b) + return hex.EncodeToString(b) + } + + // Measure the time to verify a valid-format but wrong token. + validFormat := validFormatToken() + start := time.Now() + for range 5 { + _, _ = VerifyHTTPSDeployToken(t.Context(), validFormat) + } + validFormatDuration := time.Since(start) + + // Measure the time to verify an invalid-format token. + // If the implementation short-circuits without a dummy hash, + // this will be orders of magnitude faster than the valid-format case. + invalidFormat := "short" + start = time.Now() + for range 5 { + _, _ = VerifyHTTPSDeployToken(t.Context(), invalidFormat) + } + invalidFormatDuration := time.Since(start) + + // The invalid-format path should not be dramatically faster. + // Allow a generous margin (invalid should be within 10x of valid) + // to account for CI noise while still catching the microsecond-vs-millisecond gap. + if invalidFormatDuration < validFormatDuration/10 { + t.Errorf("invalid-format verification (%v) is too fast compared to valid-format (%v); "+ + "possible timing oracle", invalidFormatDuration, validFormatDuration) + } +} + +func TestVerifyHTTPSDeployToken_DummyHash(t *testing.T) { + // Verify that the dummy hash in the invalid-format path actually performs + // a pbkdf2 computation by confirming the HashToken call is reachable. + // This is a structural check: the dummy hash uses a random salt, + // so the result should be deterministic for the same inputs. + salt := "test-salt-12345" + hash := auth_model.HashToken("dummy-token", salt) + assert.NotEmpty(t, hash, "HashToken should produce non-empty output") + assert.NotEqual(t, "dummy-token", hash, "HashToken should not return the input") +} + +func TestAddHTTPSDeployKey_WithinTransaction(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + // Create a key outside a transaction first. + key1, _, err := AddHTTPSDeployKey(t.Context(), 1, "in-tx-key", true) + require.NoError(t, err) + defer DeleteHTTPSDeployKey(t.Context(), 1, key1.ID) + + // Attempt to create a duplicate inside a transaction. + // The operation should detect the conflict and return the proper error. + var insertErr error + _ = db.WithTx(t.Context(), func(ctx context.Context) error { + _, _, insertErr = AddHTTPSDeployKey(ctx, 1, "in-tx-key", true) + return insertErr + }) + require.Error(t, insertErr) + assert.True(t, IsErrHTTPSDeployKeyNameAlreadyUsed(insertErr), + "expected ErrHTTPSDeployKeyNameAlreadyUsed, got %T: %v", insertErr, insertErr) +} + +func TestHTTPSDeployKeyModeSelection(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + readOnlyKey, _, err := AddHTTPSDeployKey(t.Context(), 1, "mode-read", true) + require.NoError(t, err) + defer DeleteHTTPSDeployKey(t.Context(), 1, readOnlyKey.ID) + assert.True(t, readOnlyKey.IsReadOnly()) + + writeKey, _, err := AddHTTPSDeployKey(t.Context(), 1, "mode-write", false) + require.NoError(t, err) + defer DeleteHTTPSDeployKey(t.Context(), 1, writeKey.ID) + assert.False(t, writeKey.IsReadOnly()) +} + +func TestHTTPSDeployKeyTokenGeneration(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + // Verify that each key gets a unique token. + _, token1, err := AddHTTPSDeployKey(t.Context(), 1, "token-gen-1", true) + require.NoError(t, err) + _, token2, err := AddHTTPSDeployKey(t.Context(), 1, "token-gen-2", true) + require.NoError(t, err) + + assert.NotEqual(t, token1, token2, "each key should have a unique token") + + // Verify that generated tokens are always valid format. + assert.True(t, tokenIsValidFormat(token1)) + assert.True(t, tokenIsValidFormat(token2)) +} + +func TestVerifyHTTPSDeployToken_LastEightIndex(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + key, token, err := AddHTTPSDeployKey(t.Context(), 1, "last-eight", true) + require.NoError(t, err) + defer DeleteHTTPSDeployKey(t.Context(), 1, key.ID) + + // Verify that the last eight characters of the token match the index column. + assert.Equal(t, token[len(token)-8:], key.TokenLastEight) + + // Verify that the token lookup works even when other keys share the same suffix. + // Create a dummy key with a crafted token that shares the last eight chars. + salt := util.CryptoRandomString(10) + dummyToken := strings.Repeat("a", HTTPSDeployKeyTokenLength-8) + key.TokenLastEight + dummyHash := auth_model.HashToken(dummyToken, salt) + + dummyKey := &HTTPSDeployKey{ + RepoID: 1, + Name: "last-eight-collide", + TokenHash: dummyHash, + TokenSalt: salt, + TokenLastEight: key.TokenLastEight, + Mode: 1, + } + require.NoError(t, db.Insert(t.Context(), dummyKey)) + defer DeleteHTTPSDeployKey(t.Context(), 1, dummyKey.ID) + + // Verify the original token still resolves to the correct key. + got, err := VerifyHTTPSDeployToken(t.Context(), token) + require.NoError(t, err) + assert.Equal(t, key.ID, got.ID) +} diff --git a/models/asymkey/main_test.go b/models/asymkey/main_test.go index be71f848d9..3613f5bf3c 100644 --- a/models/asymkey/main_test.go +++ b/models/asymkey/main_test.go @@ -15,6 +15,7 @@ func TestMain(m *testing.M) { "gpg_key.yml", "public_key.yml", "deploy_key.yml", + "https_deploy_key.yml", "gpg_key_import.yml", "user.yml", "email_address.yml", diff --git a/models/fixtures/https_deploy_key.yml b/models/fixtures/https_deploy_key.yml new file mode 100644 index 0000000000..0d1b2f0098 --- /dev/null +++ b/models/fixtures/https_deploy_key.yml @@ -0,0 +1,3 @@ +[] # empty + +# DO NOT add more test data in the fixtures, test case should prepare their own test data separately and clearly diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index c3a8f08b5d..78b94e25fd 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -409,6 +409,7 @@ func prepareMigrationTasks() []*migration { // Gitea 1.26.0 ends at migration ID number 330 (database version 331) newMigration(331, "Add ActionRunAttempt model and related action fields", v1_27.AddActionRunAttemptModel), + newMigration(332, "Add https_deploy_key table", v1_27.AddHTTPSDeployKeyTable), } return preparedMigrations } diff --git a/models/migrations/v1_27/v332.go b/models/migrations/v1_27/v332.go new file mode 100644 index 0000000000..26794b9cb1 --- /dev/null +++ b/models/migrations/v1_27/v332.go @@ -0,0 +1,26 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_27 + +import ( + "xorm.io/xorm" +) + +// AddHTTPSDeployKeyTable introduces the per-repository HTTPS deploy-key +// credential table. The shape here must stay in lock-step with the +// HTTPSDeployKey struct in models/asymkey. +func AddHTTPSDeployKeyTable(x *xorm.Engine) error { + type HTTPSDeployKey struct { + ID int64 `xorm:"pk autoincr"` + RepoID int64 `xorm:"INDEX UNIQUE(s) NOT NULL"` + Name string `xorm:"UNIQUE(s) NOT NULL"` + TokenHash string `xorm:"UNIQUE NOT NULL"` + TokenSalt string `xorm:"NOT NULL"` + TokenLastEight string `xorm:"INDEX"` + Mode int `xorm:"NOT NULL DEFAULT 1"` + CreatedUnix int64 `xorm:"INDEX created"` + UpdatedUnix int64 `xorm:"INDEX updated"` + } + return x.Sync(new(HTTPSDeployKey)) +} diff --git a/models/migrations/v1_27/v332_test.go b/models/migrations/v1_27/v332_test.go new file mode 100644 index 0000000000..392bcfa690 --- /dev/null +++ b/models/migrations/v1_27/v332_test.go @@ -0,0 +1,52 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_27 + +import ( + "testing" + + "code.gitea.io/gitea/models/migrations/migrationtest" + + "github.com/stretchr/testify/assert" +) + +func Test_AddHTTPSDeployKeyTable(t *testing.T) { + // Start from an empty DB — nothing depends on prior migration state. + x, deferable := migrationtest.PrepareTestEnv(t, 0) + defer deferable() + if x == nil || t.Failed() { + return + } + + if err := AddHTTPSDeployKeyTable(x); err != nil { + assert.NoError(t, err) + return + } + + type HTTPSDeployKey struct { + ID int64 + RepoID int64 + Name string + TokenHash string + TokenSalt string + TokenLastEight string + Mode int + CreatedUnix int64 + UpdatedUnix int64 + } + + _, err := x.Insert(&HTTPSDeployKey{ + RepoID: 1, + Name: "migration-smoke", + TokenHash: "hash", + TokenSalt: "salt", + TokenLastEight: "abcd1234", + Mode: 1, + }) + assert.NoError(t, err) + + count, err := x.Count(&HTTPSDeployKey{}) + assert.NoError(t, err) + assert.Equal(t, int64(1), count) +} diff --git a/modules/structs/repo_key.go b/modules/structs/repo_key.go index a13cde71fb..06945519b6 100644 --- a/modules/structs/repo_key.go +++ b/modules/structs/repo_key.go @@ -47,3 +47,45 @@ type CreateKeyOption struct { // required: false ReadOnly bool `json:"read_only"` } + +// HTTPSDeployKey an HTTPS deploy key/token +type HTTPSDeployKey struct { + // ID is the unique identifier for the deploy key + ID int64 `json:"id"` + // Name is the human-readable name for the key + Name string `json:"name"` + // URL is the API URL for this deploy key + URL string `json:"url"` + // Token is the plaintext token, only returned on creation + Token string `json:"token,omitempty"` + // TokenLastEight is the last 8 characters of the token for identification + TokenLastEight string `json:"token_last_eight"` + // ReadOnly indicates if the key has read-only access + ReadOnly bool `json:"read_only"` + // HasUsed indicates if the key has been used for authentication + HasUsed bool `json:"has_used"` + // HasRecentActivity indicates if the key was used in the last 7 days + HasRecentActivity bool `json:"has_recent_activity"` + // Repository is the repository this deploy key belongs to + Repository *Repository `json:"repository,omitempty"` + // swagger:strfmt date-time + // Created is the time when the deploy key was created + Created time.Time `json:"created_at"` + // swagger:strfmt date-time + // Updated is the time when the deploy key was last updated + Updated time.Time `json:"updated_at"` +} + +// CreateHTTPSDeployKeyOption options when creating an HTTPS deploy key +// swagger:model CreateHTTPSDeployKeyOption +type CreateHTTPSDeployKeyOption struct { + // Name of the key to add + // + // required: true + // unique: true + Name string `json:"name" binding:"Required;MaxSize(50)"` + // Describe if the key has only read access or read/write + // + // required: false + ReadOnly bool `json:"read_only"` +} diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index c7ec133e57..0ac45d2489 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -2379,6 +2379,12 @@ "repo.settings.deploy_key_deletion": "Remove Deploy Key", "repo.settings.deploy_key_deletion_desc": "Removing a deploy key will revoke its access to this repository. Continue?", "repo.settings.deploy_key_deletion_success": "The deploy key has been removed.", + "repo.settings.https_deploy_keys": "HTTPS Deploy Keys", + "repo.settings.https_deploy_key_desc": "HTTPS deploy keys are per-repository tokens that can be used as the password for Git operations over HTTPS. The token is shown only once, right after it is created.", + "repo.settings.add_https_deploy_key": "Add HTTPS Deploy Key", + "repo.settings.no_https_deploy_keys": "There are no HTTPS deploy keys yet.", + "repo.settings.https_deploy_key_created": "The HTTPS deploy key \"%s\" has been created. Copy the token now — it will not be shown again: %s", + "repo.settings.https_deploy_key_created_info": "Your HTTPS deploy key \"%s\" was created successfully. Copy the token below — it will not be shown again:", "repo.settings.branches": "Branches", "repo.settings.protected_branch": "Branch Protection", "repo.settings.protected_branch.save_rule": "Save Rule", diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index a8bfa0965e..2ab345e7c0 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -1279,6 +1279,12 @@ func Routes() *web.Router { m.Combo("/{id}").Get(repo.GetDeployKey). Delete(repo.DeleteDeploykey) }, reqToken(), reqAdmin()) + m.Group("/https_keys", func() { + m.Combo("").Get(repo.ListHTTPSDeployKeys). + Post(bind(api.CreateHTTPSDeployKeyOption{}), repo.CreateHTTPSDeployKey) + m.Combo("/{id}").Get(repo.GetHTTPSDeployKey). + Delete(repo.DeleteHTTPSDeployKey) + }, reqToken(), reqAdmin()) m.Group("/times", func() { m.Combo("").Get(repo.ListTrackedTimesByRepository) m.Combo("/{timetrackingusername}").Get(repo.ListTrackedTimesByUser) diff --git a/routers/api/v1/repo/https_key.go b/routers/api/v1/repo/https_key.go new file mode 100644 index 0000000000..dd204f9b14 --- /dev/null +++ b/routers/api/v1/repo/https_key.go @@ -0,0 +1,242 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "errors" + "net/http" + "net/url" + + asymkey_model "code.gitea.io/gitea/models/asymkey" + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/routers/api/v1/utils" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/convert" +) + +func composeHTTPSDeployKeysAPILink(owner, name string) string { + return setting.AppURL + "api/v1/repos/" + url.PathEscape(owner) + "/" + url.PathEscape(name) + "/https_keys/" +} + +// ListHTTPSDeployKeys list all the HTTPS deploy keys of a repository +func ListHTTPSDeployKeys(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/https_keys repository repoListHTTPSKeys + // --- + // summary: List a repository's HTTPS deploy keys + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // - name: page + // in: query + // description: page number of results to return (1-based) + // type: integer + // - name: limit + // in: query + // description: page size of results + // type: integer + // responses: + // "200": + // "$ref": "#/responses/HTTPSDeployKeyList" + // "404": + // "$ref": "#/responses/notFound" + + opts := asymkey_model.ListHTTPSDeployKeysOptions{ + ListOptions: utils.GetListOptions(ctx), + RepoID: ctx.Repo.Repository.ID, + } + + keys, count, err := db.FindAndCount[asymkey_model.HTTPSDeployKey](ctx, opts) + if err != nil { + ctx.APIErrorInternal(err) + return + } + + apiLink := composeHTTPSDeployKeysAPILink(ctx.Repo.Owner.Name, ctx.Repo.Repository.Name) + apiKeys := make([]*api.HTTPSDeployKey, len(keys)) + for i := range keys { + apiKeys[i] = convert.ToHTTPSDeployKey(apiLink, keys[i]) + } + + ctx.SetTotalCountHeader(count) + ctx.JSON(http.StatusOK, &apiKeys) +} + +// GetHTTPSDeployKey get an HTTPS deploy key by id +func GetHTTPSDeployKey(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/https_keys/{id} repository repoGetHTTPSKey + // --- + // summary: Get a repository's HTTPS deploy key by id + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // - name: id + // in: path + // description: id of the key to get + // type: integer + // format: int64 + // required: true + // responses: + // "200": + // "$ref": "#/responses/HTTPSDeployKey" + // "404": + // "$ref": "#/responses/notFound" + + key, err := asymkey_model.GetHTTPSDeployKeyByID(ctx, ctx.PathParamInt64("id")) + if err != nil { + if asymkey_model.IsErrHTTPSDeployKeyNotExist(err) { + ctx.APIErrorNotFound() + } else { + ctx.APIErrorInternal(err) + } + return + } + + if key.RepoID != ctx.Repo.Repository.ID { + ctx.APIErrorNotFound() + return + } + + apiLink := composeHTTPSDeployKeysAPILink(ctx.Repo.Owner.Name, ctx.Repo.Repository.Name) + ctx.JSON(http.StatusOK, convert.ToHTTPSDeployKey(apiLink, key)) +} + +// CreateHTTPSDeployKey create HTTPS deploy key for a repository +func CreateHTTPSDeployKey(ctx *context.APIContext) { + // swagger:operation POST /repos/{owner}/{repo}/https_keys repository repoCreateHTTPSKey + // --- + // summary: Add an HTTPS deploy key to a repository + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/CreateHTTPSDeployKeyOption" + // responses: + // "201": + // "$ref": "#/responses/HTTPSDeployKey" + // "404": + // "$ref": "#/responses/notFound" + // "422": + // "$ref": "#/responses/validationError" + + form := web.GetForm(ctx).(*api.CreateHTTPSDeployKeyOption) + key, token, err := asymkey_model.AddHTTPSDeployKey(ctx, ctx.Repo.Repository.ID, form.Name, form.ReadOnly) + if err != nil { + switch { + case asymkey_model.IsErrHTTPSDeployKeyNameAlreadyUsed(err): + ctx.APIError(http.StatusUnprocessableEntity, "A deploy key with the same name already exists") + case errors.Is(err, util.ErrInvalidArgument): + ctx.APIError(http.StatusUnprocessableEntity, err) + default: + ctx.APIErrorInternal(err) + } + return + } + + log.Trace("HTTPS deploy key added (API): operator=%s repo=%s key=%s (id=%d)", + ctx.Doer.Name, ctx.Repo.Repository.FullName(), key.Name, key.ID) + + apiLink := composeHTTPSDeployKeysAPILink(ctx.Repo.Owner.Name, ctx.Repo.Repository.Name) + apiKey := convert.ToHTTPSDeployKey(apiLink, key) + apiKey.Token = token + ctx.JSON(http.StatusCreated, apiKey) +} + +// DeleteHTTPSDeployKey delete HTTPS deploy key for a repository +func DeleteHTTPSDeployKey(ctx *context.APIContext) { + // swagger:operation DELETE /repos/{owner}/{repo}/https_keys/{id} repository repoDeleteHTTPSKey + // --- + // summary: Delete an HTTPS deploy key from a repository + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // - name: id + // in: path + // description: id of the key to delete + // type: integer + // format: int64 + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + + key, err := asymkey_model.GetHTTPSDeployKeyByID(ctx, ctx.PathParamInt64("id")) + if err != nil { + if asymkey_model.IsErrHTTPSDeployKeyNotExist(err) { + ctx.APIErrorNotFound() + } else { + ctx.APIErrorInternal(err) + } + return + } + + if key.RepoID != ctx.Repo.Repository.ID { + ctx.APIErrorNotFound() + return + } + + if err := asymkey_model.DeleteHTTPSDeployKey(ctx, ctx.Repo.Repository.ID, key.ID); err != nil { + if asymkey_model.IsErrHTTPSDeployKeyNotExist(err) { + ctx.APIErrorNotFound() + } else { + ctx.APIErrorInternal(err) + } + return + } + + log.Trace("HTTPS deploy key deleted (API): operator=%s repo=%s key=%s (id=%d)", + ctx.Doer.Name, ctx.Repo.Repository.FullName(), key.Name, key.ID) + + ctx.Status(http.StatusNoContent) +} diff --git a/routers/api/v1/swagger/key.go b/routers/api/v1/swagger/key.go index 8390833589..e565a03b89 100644 --- a/routers/api/v1/swagger/key.go +++ b/routers/api/v1/swagger/key.go @@ -48,3 +48,17 @@ type swaggerResponseDeployKeyList struct { // in:body Body []api.DeployKey `json:"body"` } + +// HTTPSDeployKey +// swagger:response HTTPSDeployKey +type swaggerResponseHTTPSDeployKey struct { + // in:body + Body api.HTTPSDeployKey `json:"body"` +} + +// HTTPSDeployKeyList +// swagger:response HTTPSDeployKeyList +type swaggerResponseHTTPSDeployKeyList struct { + // in:body + Body []api.HTTPSDeployKey `json:"body"` +} diff --git a/routers/api/v1/swagger/options.go b/routers/api/v1/swagger/options.go index 1a442d1146..f00716796f 100644 --- a/routers/api/v1/swagger/options.go +++ b/routers/api/v1/swagger/options.go @@ -50,6 +50,9 @@ type swaggerParameterBodies struct { // in:body CreateKeyOption api.CreateKeyOption + // in:body + CreateHTTPSDeployKeyOption api.CreateHTTPSDeployKeyOption + // in:body RenameUserOption api.RenameUserOption diff --git a/routers/web/repo/githttp.go b/routers/web/repo/githttp.go index 928c78a61f..8673998f9c 100644 --- a/routers/web/repo/githttp.go +++ b/routers/web/repo/githttp.go @@ -157,6 +157,48 @@ func httpBase(ctx *context.Context, optGitService ...string) *serviceHandler { return nil } + // HTTPS deploy tokens authenticate as the repo owner but must be + // constrained to the single bound repository and mode. We do this + // before the other basic-auth checks so a deploy token bypasses + // PAT-scope verification, 2FA, and unit-permission lookup — those + // all assume the doer's own permissions should drive the outcome, + // which is not what a deploy token is supposed to do. + if ctx.Data["IsDeployToken"] == true { + boundRepoID, _ := ctx.Data["DeployTokenRepoID"].(int64) + if !repoExist || boundRepoID != repo.ID { + ctx.PlainText(http.StatusNotFound, "Repository not found") + return nil + } + tokenMode, _ := ctx.Data["DeployTokenMode"].(perm.AccessMode) + if accessMode > tokenMode { + ctx.PlainText(http.StatusForbidden, "Deploy token does not grant write access") + return nil + } + if !isPull && repo.IsMirror { + ctx.PlainText(http.StatusForbidden, "mirror repository is read-only") + return nil + } + // Enforce the per-unit enablement check. Without this a deploy + // token could push to or clone a wiki whose TypeWiki unit has + // been disabled, bypassing the same check applied to the + // user-password and PAT paths further down. + if isWiki { + if _, err := repo.GetUnit(ctx, unit.TypeWiki); err != nil { + if repo_model.IsErrUnitTypeNotExist(err) { + ctx.PlainText(http.StatusForbidden, "repository wiki is disabled") + return nil + } + ctx.ServerError("GetUnit(UnitTypeWiki) for "+repo.FullName(), err) + return nil + } + } + var environ []string + if !isPull { + environ = repo_module.DoerPushingEnvironment(ctx.Doer, repo, isWiki) + } + return &serviceHandler{serviceType, repo, isWiki, environ} + } + context.CheckRepoScopedToken(ctx, repo, auth_model.GetScopeLevelFromAccessMode(accessMode)) if ctx.Written() { return nil diff --git a/routers/web/repo/setting/deploy_key.go b/routers/web/repo/setting/deploy_key.go index ab54b8ccf5..3aa890d401 100644 --- a/routers/web/repo/setting/deploy_key.go +++ b/routers/web/repo/setting/deploy_key.go @@ -4,19 +4,21 @@ package setting import ( + "errors" "net/http" asymkey_model "code.gitea.io/gitea/models/asymkey" "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web" asymkey_service "code.gitea.io/gitea/services/asymkey" "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/forms" ) -// DeployKeys render the deploy keys list of a repository page +// DeployKeys render the deploy keys and HTTPS deploy tokens list of a repository page func DeployKeys(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("repo.settings.deploy_keys") + " / " + ctx.Tr("secrets.secrets") ctx.Data["PageIsSettingsKeys"] = true @@ -29,6 +31,14 @@ func DeployKeys(ctx *context.Context) { } ctx.Data["Deploykeys"] = keys + httpsKeys, err := db.Find[asymkey_model.HTTPSDeployKey](ctx, + asymkey_model.ListHTTPSDeployKeysOptions{RepoID: ctx.Repo.Repository.ID}) + if err != nil { + ctx.ServerError("ListHTTPSDeployKeys", err) + return + } + ctx.Data["HTTPSDeploykeys"] = httpsKeys + ctx.HTML(http.StatusOK, tplDeployKeys) } @@ -92,16 +102,81 @@ func DeployKeysPost(ctx *context.Context) { return } - log.Trace("Deploy key added: %d", ctx.Repo.Repository.ID) + log.Trace("Deploy key added: operator=%s repo=%s key=%s (id=%d)", + ctx.Doer.Name, ctx.Repo.Repository.FullName(), key.Name, key.ID) ctx.Flash.Success(ctx.Tr("repo.settings.add_key_success", key.Name)) ctx.Redirect(ctx.Repo.RepoLink + "/settings/keys") } // DeleteDeployKey response for deleting a deploy key func DeleteDeployKey(ctx *context.Context) { - if err := asymkey_service.DeleteDeployKey(ctx, ctx.Repo.Repository, ctx.FormInt64("id")); err != nil { + id := ctx.FormInt64("id") + if err := asymkey_service.DeleteDeployKey(ctx, ctx.Repo.Repository, id); err != nil { ctx.Flash.Error("DeleteDeployKey: " + err.Error()) } else { + log.Trace("Deploy key deleted: operator=%s repo=%s key-id=%d", + ctx.Doer.Name, ctx.Repo.Repository.FullName(), id) + ctx.Flash.Success(ctx.Tr("repo.settings.deploy_key_deletion_success")) + } + + ctx.JSONRedirect(ctx.Repo.RepoLink + "/settings/keys") +} + +// HTTPSDeployKeysPost handles creation of an HTTPS deploy key for the current +// repository. The plaintext token is rendered inline via ctx.Data so it never +// touches cookie-backed flash storage. +func HTTPSDeployKeysPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.HTTPSDeployKeyForm) + ctx.Data["Title"] = ctx.Tr("repo.settings.deploy_keys") + ctx.Data["PageIsSettingsKeys"] = true + + if ctx.HasError() { + ctx.Data["HasError"] = true + ctx.Data["httpsKeyTitle"] = form.Title + DeployKeys(ctx) + ctx.HTML(http.StatusOK, tplDeployKeys) + return + } + + key, token, err := asymkey_model.AddHTTPSDeployKey(ctx, ctx.Repo.Repository.ID, form.Title, !form.IsWritable) + if err != nil { + switch { + case asymkey_model.IsErrHTTPSDeployKeyNameAlreadyUsed(err): + ctx.Data["HasError"] = true + ctx.Data["Err_Title"] = true + case errors.Is(err, util.ErrInvalidArgument): + ctx.Data["HasError"] = true + ctx.Data["Err_Title"] = true + default: + ctx.ServerError("AddHTTPSDeployKey", err) + return + } + ctx.Data["httpsKeyTitle"] = form.Title + DeployKeys(ctx) + ctx.HTML(http.StatusOK, tplDeployKeys) + return + } + + log.Trace("HTTPS deploy key added: operator=%s repo=%s key=%s (id=%d)", + ctx.Doer.Name, ctx.Repo.Repository.FullName(), key.Name, key.ID) + + // Render the page inline with the token in ctx.Data. + // This avoids storing the secret credential in cookie-backed flash. + DeployKeys(ctx) + ctx.Data["HTTPSDeployKeyToken"] = token + ctx.Data["HTTPSDeployKeyName"] = key.Name + ctx.HTML(http.StatusOK, tplDeployKeys) +} + +// DeleteHTTPSDeployKey deletes a single HTTPS deploy key scoped to the +// current repository. +func DeleteHTTPSDeployKey(ctx *context.Context) { + id := ctx.FormInt64("id") + if err := asymkey_model.DeleteHTTPSDeployKey(ctx, ctx.Repo.Repository.ID, id); err != nil { + ctx.Flash.Error("DeleteHTTPSDeployKey: " + err.Error()) + } else { + log.Trace("HTTPS deploy key deleted: operator=%s repo=%s key-id=%d", + ctx.Doer.Name, ctx.Repo.Repository.FullName(), id) ctx.Flash.Success(ctx.Tr("repo.settings.deploy_key_deletion_success")) } diff --git a/routers/web/repo/setting/settings_test.go b/routers/web/repo/setting/settings_test.go index 154d01fda4..90bf980355 100644 --- a/routers/web/repo/setting/settings_test.go +++ b/routers/web/repo/setting/settings_test.go @@ -5,9 +5,11 @@ package setting import ( "net/http" + "strconv" "testing" asymkey_model "code.gitea.io/gitea/models/asymkey" + "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/organization" "code.gitea.io/gitea/models/perm" repo_model "code.gitea.io/gitea/models/repo" @@ -431,3 +433,74 @@ func TestHandleSettingsPostMirrorPreservesExistingUsername(t *testing.T) { require.True(t, ok) assert.Equal(t, "updated-password", password) } + +func TestHTTPSDeployKeyCreateAndDelete(t *testing.T) { + unittest.PrepareTestEnv(t) + + ctx, _ := contexttest.MockContext(t, "user2/repo1/settings/keys/https") + contexttest.LoadUser(t, ctx, 2) + contexttest.LoadRepo(t, ctx, 1) + + web.SetForm(ctx, &forms.HTTPSDeployKeyForm{ + Title: "ci-writable", + IsWritable: true, + }) + HTTPSDeployKeysPost(ctx) + + // Handler must render the template directly (200), not redirect (303). + // This avoids putting the plaintext token in a cookie-backed flash. + assert.Equal(t, http.StatusOK, ctx.Resp.WrittenStatus()) + + keys, err := db.Find[asymkey_model.HTTPSDeployKey](ctx, + asymkey_model.ListHTTPSDeployKeysOptions{RepoID: 1}) + require.NoError(t, err) + require.Len(t, keys, 1) + assert.Equal(t, "ci-writable", keys[0].Name) + assert.False(t, keys[0].IsReadOnly()) + + // The plaintext token must be on ctx.Data, NOT in flash/cookies. + assert.NotEmpty(t, ctx.Data["HTTPSDeployKeyToken"], "token must be in ctx.Data") + assert.NotContains(t, ctx.Flash.SuccessMsg, ctx.Data["HTTPSDeployKeyToken"], + "token must NOT be in flash — it is a secret credential") + + // Now delete it. + delCtx, _ := contexttest.MockContext(t, "user2/repo1/settings/keys/https/delete") + contexttest.LoadUser(t, delCtx, 2) + contexttest.LoadRepo(t, delCtx, 1) + delCtx.Req.Form.Set("id", strconv.FormatInt(keys[0].ID, 10)) + DeleteHTTPSDeployKey(delCtx) + assert.NotEqual(t, http.StatusInternalServerError, delCtx.Resp.WrittenStatus()) + + keys, err = db.Find[asymkey_model.HTTPSDeployKey](ctx, + asymkey_model.ListHTTPSDeployKeysOptions{RepoID: 1}) + require.NoError(t, err) + assert.Empty(t, keys) +} + +func TestHTTPSDeployKeysPostValidationError(t *testing.T) { + unittest.PrepareTestEnv(t) + + ctx, _ := contexttest.MockContext(t, "user2/repo1/settings/keys/https") + contexttest.LoadUser(t, ctx, 2) + contexttest.LoadRepo(t, ctx, 1) + + // Empty title should fail the Required binding validation. + web.SetForm(ctx, &forms.HTTPSDeployKeyForm{ + Title: "", + IsWritable: false, + }) + HTTPSDeployKeysPost(ctx) + + // Must render template inline (200), not redirect — so error state is preserved. + assert.Equal(t, http.StatusOK, ctx.Resp.WrittenStatus()) + + // Error state must be set so the template can show the panel with errors. + hasError, ok := ctx.Data["HasError"].(bool) + assert.True(t, ok && hasError, "HasError must be set on validation failure") + + // No HTTPS deploy key should have been created. + keys, err := db.Find[asymkey_model.HTTPSDeployKey](ctx, + asymkey_model.ListHTTPSDeployKeysOptions{RepoID: 1}) + require.NoError(t, err) + assert.Empty(t, keys) +} diff --git a/routers/web/web.go b/routers/web/web.go index ecd75250d2..9ec49d179c 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -94,12 +94,14 @@ func optionsCorsHandler() func(next http.Handler) http.Handler { type AuthMiddleware struct { AllowOAuth2 types.PreMiddlewareProvider AllowBasic types.PreMiddlewareProvider + AllowDeployToken types.PreMiddlewareProvider MiddlewareHandler func(*context.Context) } func newWebAuthMiddleware() *AuthMiddleware { type keyAllowOAuth2 struct{} type keyAllowBasic struct{} + type keyAllowDeployToken struct{} webAuth := &AuthMiddleware{} middlewareSetContextValue := func(key, val any) types.PreMiddlewareProvider { @@ -114,11 +116,17 @@ func newWebAuthMiddleware() *AuthMiddleware { webAuth.AllowBasic = middlewareSetContextValue(keyAllowBasic{}, true) webAuth.AllowOAuth2 = middlewareSetContextValue(keyAllowOAuth2{}, true) + // AllowDeployToken narrows HTTPS deploy-token authentication to the + // request contexts that need it (git smart-HTTP). Without this gate, a + // deploy token would authenticate as the repo owner on every Basic-auth + // endpoint — REST API, attachments, feeds — and act as a full-owner PAT. + webAuth.AllowDeployToken = middlewareSetContextValue(keyAllowDeployToken{}, true) enableSSPI := setting.IsWindows && auth_model.IsSSPIEnabled(graceful.GetManager().ShutdownContext()) webAuth.MiddlewareHandler = func(ctx *context.Context) { allowBasic := ctx.GetContextValue(keyAllowBasic{}) == true allowOAuth2 := ctx.GetContextValue(keyAllowOAuth2{}) == true + allowDeployToken := ctx.GetContextValue(keyAllowDeployToken{}) == true group := auth_service.NewGroup() @@ -127,6 +135,12 @@ func newWebAuthMiddleware() *AuthMiddleware { if allowOAuth2 { group.Add(&auth_service.OAuth2{}) } + if allowDeployToken { + // Must come before Basic so a valid deploy token short-circuits + // the fall-through into username/password sign-in (which would + // reject the 40-hex token and leave the caller with a 401). + group.Add(&auth_service.HTTPSDeployToken{}) + } if allowBasic { group.Add(&auth_service.Basic{}) } @@ -1208,6 +1222,8 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { m.Combo("").Get(repo_setting.DeployKeys). Post(web.Bind(forms.AddKeyForm{}), repo_setting.DeployKeysPost) m.Post("/delete", repo_setting.DeleteDeployKey) + m.Post("/https", web.Bind(forms.HTTPSDeployKeyForm{}), repo_setting.HTTPSDeployKeysPost) + m.Post("/https/delete", repo_setting.DeleteHTTPSDeployKey) }) m.Group("/lfs", func() { @@ -1738,7 +1754,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { // Some users want to use "web-based git client" to access Gitea's repositories, // so the CORS handler and OPTIONS method are used. // pattern: "/{username}/{reponame}/{git-paths}": git http support - addOwnerRepoGitHTTPRouters(m, repo.HTTPGitEnabledHandler, webAuth.AllowBasic, webAuth.AllowOAuth2, repo.CorsHandler(), optSignInFromAnyOrigin, context.UserAssignmentWeb()) + addOwnerRepoGitHTTPRouters(m, repo.HTTPGitEnabledHandler, webAuth.AllowBasic, webAuth.AllowOAuth2, webAuth.AllowDeployToken, repo.CorsHandler(), optSignInFromAnyOrigin, context.UserAssignmentWeb()) m.Group("/notifications", func() { m.Get("", user.Notifications) diff --git a/services/auth/basic.go b/services/auth/basic.go index e8a4a2e8f7..4842301865 100644 --- a/services/auth/basic.go +++ b/services/auth/basic.go @@ -24,10 +24,11 @@ var ( // BasicMethodName is the constant name of the basic authentication method const ( - BasicMethodName = "basic" - AccessTokenMethodName = "access_token" - OAuth2TokenMethodName = "oauth2_token" - ActionTokenMethodName = "action_token" + BasicMethodName = "basic" + AccessTokenMethodName = "access_token" + OAuth2TokenMethodName = "oauth2_token" + ActionTokenMethodName = "action_token" + HTTPSDeployTokenMethodName = "https_deploy_token" ) // Basic implements the Auth interface and authenticates requests (API requests diff --git a/services/auth/https_deploy_token.go b/services/auth/https_deploy_token.go new file mode 100644 index 0000000000..c4e20cbec0 --- /dev/null +++ b/services/auth/https_deploy_token.go @@ -0,0 +1,85 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "net/http" + + asymkey_model "code.gitea.io/gitea/models/asymkey" + repo_model "code.gitea.io/gitea/models/repo" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/auth/httpauth" + "code.gitea.io/gitea/modules/log" +) + +// Ensure the struct implements the interface. +var _ Method = &HTTPSDeployToken{} + +// HTTPSDeployToken authenticates HTTP Basic-auth credentials whose token half +// matches a row in the https_deploy_key table. It is deliberately *not* +// registered globally: callers add it to an auth group only for request +// contexts where a repo-scoped deploy token makes sense (currently the git +// smart-HTTP router). See routers/web/web.go for the gating flag. +type HTTPSDeployToken struct{} + +// Name returns the name of this auth method. +func (h *HTTPSDeployToken) Name() string { + return HTTPSDeployTokenMethodName +} + +// Verify parses the Basic-auth header, resolves the token to an +// HTTPSDeployKey, and returns the bound repository owner. The deploy-key +// metadata is stashed on the data store so downstream permission logic can +// constrain the request to the bound repo and access mode. +func (h *HTTPSDeployToken) Verify(req *http.Request, _ http.ResponseWriter, store DataStore, _ SessionStore) (*user_model.User, error) { + authToken := extractBasicAuthToken(req) + if authToken == "" { + return nil, nil //nolint:nilnil // the auth method is not applicable + } + + key, err := asymkey_model.VerifyHTTPSDeployToken(req.Context(), authToken) + if err != nil { + if asymkey_model.IsErrHTTPSDeployKeyNotExist(err) { + return nil, nil //nolint:nilnil // not our token — fall through to regular basic auth + } + return nil, err + } + + repo, err := repo_model.GetRepositoryByID(req.Context(), key.RepoID) + if err != nil { + log.Error("HTTPSDeployToken: GetRepositoryByID(%d): %v", key.RepoID, err) + return nil, err + } + if err := repo.LoadOwner(req.Context()); err != nil { + log.Error("HTTPSDeployToken: LoadOwner for repo %d: %v", repo.ID, err) + return nil, err + } + + log.Trace("HTTPSDeployToken: valid HTTPS deploy key for repo[%d]", repo.ID) + store.GetData()["LoginMethod"] = HTTPSDeployTokenMethodName + store.GetData()["IsDeployToken"] = true + store.GetData()["DeployTokenID"] = key.ID + store.GetData()["DeployTokenRepoID"] = key.RepoID + store.GetData()["DeployTokenMode"] = key.Mode + return repo.Owner, nil +} + +// extractBasicAuthToken pulls the credential string out of an HTTP Basic +// Authorization header. It returns the password half when present (the +// conventional token-in-password pattern used by git credential helpers) and +// otherwise the username half. Returns "" if no Basic header is present. +func extractBasicAuthToken(req *http.Request) string { + authHeader := req.Header.Get("Authorization") + if authHeader == "" { + return "" + } + parsed, ok := httpauth.ParseAuthorizationHeader(authHeader) + if !ok || parsed.BasicAuth == nil { + return "" + } + if parsed.BasicAuth.Password != "" && parsed.BasicAuth.Password != "x-oauth-basic" { + return parsed.BasicAuth.Password + } + return parsed.BasicAuth.Username +} diff --git a/services/convert/convert.go b/services/convert/convert.go index dae0587ec4..bf195bddcb 100644 --- a/services/convert/convert.go +++ b/services/convert/convert.go @@ -698,6 +698,21 @@ func ToDeployKey(apiLink string, key *asymkey_model.DeployKey) *api.DeployKey { } } +// ToHTTPSDeployKey convert asymkey_model.HTTPSDeployKey to api.HTTPSDeployKey +func ToHTTPSDeployKey(apiLink string, key *asymkey_model.HTTPSDeployKey) *api.HTTPSDeployKey { + return &api.HTTPSDeployKey{ + ID: key.ID, + Name: key.Name, + URL: fmt.Sprintf("%s%d", apiLink, key.ID), + TokenLastEight: key.TokenLastEight, + ReadOnly: key.Mode == perm.AccessModeRead, + HasUsed: key.HasUsed, + HasRecentActivity: key.HasRecentActivity, + Created: key.CreatedUnix.AsTime(), + Updated: key.UpdatedUnix.AsTime(), + } +} + // ToOrganization convert user_model.User to api.Organization func ToOrganization(ctx context.Context, org *organization.Organization) *api.Organization { return &api.Organization{ diff --git a/services/forms/repo_form.go b/services/forms/repo_form.go index d8e019f860..39d84ef06f 100644 --- a/services/forms/repo_form.go +++ b/services/forms/repo_form.go @@ -734,3 +734,15 @@ func (f *AddTimeManuallyForm) Validate(req *http.Request, errs binding.Errors) b type SaveTopicForm struct { Topics []string `binding:"topics;Required;"` } + +// HTTPSDeployKeyForm form for adding an HTTPS deploy key to a repository. +type HTTPSDeployKeyForm struct { + Title string `binding:"Required;MaxSize(50)"` + IsWritable bool +} + +// Validate validates the fields +func (f *HTTPSDeployKeyForm) Validate(req *http.Request, errs binding.Errors) binding.Errors { + ctx := context.GetValidateContext(req) + return middleware.Validate(errs, ctx.Data, f, ctx.Locale) +} diff --git a/templates/repo/settings/deploy_keys.tmpl b/templates/repo/settings/deploy_keys.tmpl index 89a9df3667..02f33b6616 100644 --- a/templates/repo/settings/deploy_keys.tmpl +++ b/templates/repo/settings/deploy_keys.tmpl @@ -77,4 +77,77 @@ {{template "base/modal_actions_confirm" .}} +
+

+ {{ctx.Locale.Tr "repo.settings.https_deploy_keys"}} +
+ +
+

+
+
+ {{if .HTTPSDeployKeyToken}} +
+

{{ctx.Locale.Tr "repo.settings.https_deploy_key_created_info" .HTTPSDeployKeyName}}

+ +
+ {{end}} +
+
+ {{ctx.Locale.Tr "repo.settings.https_deploy_key_desc"}} +
+
+ + +
+
+
+ + + {{ctx.Locale.Tr "repo.settings.is_writable_info"}} +
+
+ + +
+
+ {{if .HTTPSDeploykeys}} +
+ {{range .HTTPSDeploykeys}} +
+
+ {{svg "octicon-key" 32}} +
+
+
{{.Name}}
+
+ {{ctx.Locale.Tr "settings.added_on" (DateUtils.AbsoluteShort .CreatedUnix)}} — {{svg "octicon-info"}} {{if .HasUsed}}{{ctx.Locale.Tr "settings.last_used"}} {{DateUtils.AbsoluteShort .UpdatedUnix}}{{else}}{{ctx.Locale.Tr "settings.no_activity"}}{{end}} - {{ctx.Locale.Tr "settings.can_read_info"}}{{if not .IsReadOnly}} / {{ctx.Locale.Tr "settings.can_write_info"}} {{end}} +
+
+
+ +
+
+ {{end}} +
+ {{else}} + {{ctx.Locale.Tr "repo.settings.no_https_deploy_keys"}} + {{end}} +
+
+ + + {{template "repo/settings/layout_footer" .}} diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index 26d45940f2..1be32b52ed 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -9493,6 +9493,187 @@ } } }, + "/repos/{owner}/{repo}/https_keys": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "List a repository's HTTPS deploy keys", + "operationId": "repoListHTTPSKeys", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "page number of results to return (1-based)", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "page size of results", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "$ref": "#/responses/HTTPSDeployKeyList" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + }, + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "Add an HTTPS deploy key to a repository", + "operationId": "repoCreateHTTPSKey", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo", + "name": "repo", + "in": "path", + "required": true + }, + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/CreateHTTPSDeployKeyOption" + } + } + ], + "responses": { + "201": { + "$ref": "#/responses/HTTPSDeployKey" + }, + "404": { + "$ref": "#/responses/notFound" + }, + "422": { + "$ref": "#/responses/validationError" + } + } + } + }, + "/repos/{owner}/{repo}/https_keys/{id}": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "Get a repository's HTTPS deploy key by id", + "operationId": "repoGetHTTPSKey", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the key to get", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "$ref": "#/responses/HTTPSDeployKey" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + }, + "delete": { + "tags": [ + "repository" + ], + "summary": "Delete an HTTPS deploy key from a repository", + "operationId": "repoDeleteHTTPSKey", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the key to delete", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "$ref": "#/responses/empty" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + } + }, "/repos/{owner}/{repo}/issue_config": { "get": { "produces": [ @@ -23714,6 +23895,27 @@ }, "x-go-package": "code.gitea.io/gitea/modules/structs" }, + "CreateHTTPSDeployKeyOption": { + "description": "CreateHTTPSDeployKeyOption options when creating an HTTPS deploy key", + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "description": "Name of the key to add", + "type": "string", + "uniqueItems": true, + "x-go-name": "Name" + }, + "read_only": { + "description": "Describe if the key has only read access or read/write", + "type": "boolean", + "x-go-name": "ReadOnly" + } + }, + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, "CreateHookOption": { "description": "CreateHookOption options when create a hook", "type": "object", @@ -26459,6 +26661,67 @@ }, "x-go-package": "code.gitea.io/gitea/modules/structs" }, + "HTTPSDeployKey": { + "description": "HTTPSDeployKey an HTTPS deploy key/token", + "type": "object", + "properties": { + "created_at": { + "type": "string", + "format": "date-time", + "x-go-name": "Created" + }, + "has_recent_activity": { + "description": "HasRecentActivity indicates if the key was used in the last 7 days", + "type": "boolean", + "x-go-name": "HasRecentActivity" + }, + "has_used": { + "description": "HasUsed indicates if the key has been used for authentication", + "type": "boolean", + "x-go-name": "HasUsed" + }, + "id": { + "description": "ID is the unique identifier for the deploy key", + "type": "integer", + "format": "int64", + "x-go-name": "ID" + }, + "name": { + "description": "Name is the human-readable name for the key", + "type": "string", + "x-go-name": "Name" + }, + "read_only": { + "description": "ReadOnly indicates if the key has read-only access", + "type": "boolean", + "x-go-name": "ReadOnly" + }, + "repository": { + "$ref": "#/definitions/Repository" + }, + "token": { + "description": "Token is the plaintext token, only returned on creation", + "type": "string", + "x-go-name": "Token" + }, + "token_last_eight": { + "description": "TokenLastEight is the last 8 characters of the token for identification", + "type": "string", + "x-go-name": "TokenLastEight" + }, + "updated_at": { + "type": "string", + "format": "date-time", + "x-go-name": "Updated" + }, + "url": { + "description": "URL is the API URL for this deploy key", + "type": "string", + "x-go-name": "URL" + } + }, + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, "Hook": { "description": "Hook a hook is a web hook when one repository changed", "type": "object", @@ -30660,6 +30923,21 @@ } } }, + "HTTPSDeployKey": { + "description": "HTTPSDeployKey", + "schema": { + "$ref": "#/definitions/HTTPSDeployKey" + } + }, + "HTTPSDeployKeyList": { + "description": "HTTPSDeployKeyList", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/HTTPSDeployKey" + } + } + }, "Hook": { "description": "Hook", "schema": { diff --git a/templates/swagger/v1_openapi3_json.tmpl b/templates/swagger/v1_openapi3_json.tmpl index 33adff75e0..9fa5195e05 100644 --- a/templates/swagger/v1_openapi3_json.tmpl +++ b/templates/swagger/v1_openapi3_json.tmpl @@ -604,6 +604,29 @@ }, "description": "GitignoreTemplateList" }, + "HTTPSDeployKey": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPSDeployKey" + } + } + }, + "description": "HTTPSDeployKey" + }, + "HTTPSDeployKeyList": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/HTTPSDeployKey" + }, + "type": "array" + } + } + }, + "description": "HTTPSDeployKeyList" + }, "Hook": { "content": { "application/json": { @@ -3978,6 +4001,27 @@ "type": "object", "x-go-package": "code.gitea.io/gitea/modules/structs" }, + "CreateHTTPSDeployKeyOption": { + "description": "CreateHTTPSDeployKeyOption options when creating an HTTPS deploy key", + "properties": { + "name": { + "description": "Name of the key to add", + "type": "string", + "uniqueItems": true, + "x-go-name": "Name" + }, + "read_only": { + "description": "Describe if the key has only read access or read/write", + "type": "boolean", + "x-go-name": "ReadOnly" + } + }, + "required": [ + "name" + ], + "type": "object", + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, "CreateHookOption": { "description": "CreateHookOption options when create a hook", "properties": { @@ -6686,6 +6730,68 @@ "type": "object", "x-go-package": "code.gitea.io/gitea/modules/structs" }, + "HTTPSDeployKey": { + "description": "HTTPSDeployKey an HTTPS deploy key/token", + "properties": { + "created_at": { + "format": "date-time", + "type": "string", + "x-go-name": "Created" + }, + "has_recent_activity": { + "description": "HasRecentActivity indicates if the key was used in the last 7 days", + "type": "boolean", + "x-go-name": "HasRecentActivity" + }, + "has_used": { + "description": "HasUsed indicates if the key has been used for authentication", + "type": "boolean", + "x-go-name": "HasUsed" + }, + "id": { + "description": "ID is the unique identifier for the deploy key", + "format": "int64", + "type": "integer", + "x-go-name": "ID" + }, + "name": { + "description": "Name is the human-readable name for the key", + "type": "string", + "x-go-name": "Name" + }, + "read_only": { + "description": "ReadOnly indicates if the key has read-only access", + "type": "boolean", + "x-go-name": "ReadOnly" + }, + "repository": { + "$ref": "#/components/schemas/Repository" + }, + "token": { + "description": "Token is the plaintext token, only returned on creation", + "type": "string", + "x-go-name": "Token" + }, + "token_last_eight": { + "description": "TokenLastEight is the last 8 characters of the token for identification", + "type": "string", + "x-go-name": "TokenLastEight" + }, + "updated_at": { + "format": "date-time", + "type": "string", + "x-go-name": "Updated" + }, + "url": { + "description": "URL is the API URL for this deploy key", + "format": "uri", + "type": "string", + "x-go-name": "URL" + } + }, + "type": "object", + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, "Hook": { "description": "Hook a hook is a web hook when one repository changed", "properties": { @@ -20679,6 +20785,202 @@ ] } }, + "/repos/{owner}/{repo}/https_keys": { + "get": { + "operationId": "repoListHTTPSKeys", + "parameters": [ + { + "description": "owner of the repo", + "in": "path", + "name": "owner", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "name of the repo", + "in": "path", + "name": "repo", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "page number of results to return (1-based)", + "in": "query", + "name": "page", + "schema": { + "type": "integer" + } + }, + { + "description": "page size of results", + "in": "query", + "name": "limit", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/HTTPSDeployKeyList" + }, + "404": { + "$ref": "#/components/responses/notFound" + } + }, + "summary": "List a repository's HTTPS deploy keys", + "tags": [ + "repository" + ] + }, + "post": { + "operationId": "repoCreateHTTPSKey", + "parameters": [ + { + "description": "owner of the repo", + "in": "path", + "name": "owner", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "name of the repo", + "in": "path", + "name": "repo", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateHTTPSDeployKeyOption" + } + } + }, + "x-originalParamName": "body" + }, + "responses": { + "201": { + "$ref": "#/components/responses/HTTPSDeployKey" + }, + "404": { + "$ref": "#/components/responses/notFound" + }, + "422": { + "$ref": "#/components/responses/validationError" + } + }, + "summary": "Add an HTTPS deploy key to a repository", + "tags": [ + "repository" + ] + } + }, + "/repos/{owner}/{repo}/https_keys/{id}": { + "delete": { + "operationId": "repoDeleteHTTPSKey", + "parameters": [ + { + "description": "owner of the repo", + "in": "path", + "name": "owner", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "name of the repo", + "in": "path", + "name": "repo", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "id of the key to delete", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + } + ], + "responses": { + "204": { + "$ref": "#/components/responses/empty" + }, + "403": { + "$ref": "#/components/responses/forbidden" + }, + "404": { + "$ref": "#/components/responses/notFound" + } + }, + "summary": "Delete an HTTPS deploy key from a repository", + "tags": [ + "repository" + ] + }, + "get": { + "operationId": "repoGetHTTPSKey", + "parameters": [ + { + "description": "owner of the repo", + "in": "path", + "name": "owner", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "name of the repo", + "in": "path", + "name": "repo", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "id of the key to get", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/HTTPSDeployKey" + }, + "404": { + "$ref": "#/components/responses/notFound" + } + }, + "summary": "Get a repository's HTTPS deploy key by id", + "tags": [ + "repository" + ] + } + }, "/repos/{owner}/{repo}/issue_config": { "get": { "operationId": "repoGetIssueConfig", diff --git a/tests/integration/api_https_keys_test.go b/tests/integration/api_https_keys_test.go new file mode 100644 index 0000000000..31997f6797 --- /dev/null +++ b/tests/integration/api_https_keys_test.go @@ -0,0 +1,201 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "testing" + + asymkey_model "code.gitea.io/gitea/models/asymkey" + auth_model "code.gitea.io/gitea/models/auth" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" +) + +func TestViewHTTPSDeployKeysNoLogin(t *testing.T) { + defer tests.PrepareTestEnv(t)() + req := NewRequest(t, "GET", "/api/v1/repos/user2/repo1/https_keys") + MakeRequest(t, req, http.StatusUnauthorized) +} + +func TestCreateHTTPSDeployKeyNoLogin(t *testing.T) { + defer tests.PrepareTestEnv(t)() + req := NewRequestWithJSON(t, "POST", "/api/v1/repos/user2/repo1/https_keys", api.CreateHTTPSDeployKeyOption{ + Name: "test-key", + ReadOnly: true, + }) + MakeRequest(t, req, http.StatusUnauthorized) +} + +func TestGetHTTPSDeployKeyNoLogin(t *testing.T) { + defer tests.PrepareTestEnv(t)() + req := NewRequest(t, "GET", "/api/v1/repos/user2/repo1/https_keys/1") + MakeRequest(t, req, http.StatusUnauthorized) +} + +func TestDeleteHTTPSDeployKeyNoLogin(t *testing.T) { + defer tests.PrepareTestEnv(t)() + req := NewRequest(t, "DELETE", "/api/v1/repos/user2/repo1/https_keys/1") + MakeRequest(t, req, http.StatusUnauthorized) +} + +func TestCreateHTTPSDeployKey(t *testing.T) { + defer tests.PrepareTestEnv(t)() + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{Name: "repo1"}) + repoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + + session := loginUser(t, repoOwner.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + keysURL := fmt.Sprintf("/api/v1/repos/%s/%s/https_keys", repoOwner.Name, repo.Name) + + createBody := api.CreateHTTPSDeployKeyOption{ + Name: "ci-read-only", + ReadOnly: true, + } + req := NewRequestWithJSON(t, "POST", keysURL, createBody). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusCreated) + + createdKey := DecodeJSON(t, resp, &api.HTTPSDeployKey{}) + assert.True(t, createdKey.ReadOnly) + assert.Equal(t, "ci-read-only", createdKey.Name) + assert.NotEmpty(t, createdKey.Token, "create response must include the plaintext token") + assert.NotEmpty(t, createdKey.TokenLastEight) + assert.NotEmpty(t, createdKey.URL) + assert.Equal(t, createdKey.Token[len(createdKey.Token)-8:], createdKey.TokenLastEight) + + unittest.AssertExistsAndLoadBean(t, &asymkey_model.HTTPSDeployKey{ + ID: createdKey.ID, + Name: "ci-read-only", + }) +} + +func TestListHTTPSDeployKeys(t *testing.T) { + defer tests.PrepareTestEnv(t)() + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{Name: "repo1"}) + repoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + + session := loginUser(t, repoOwner.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + keysURL := fmt.Sprintf("/api/v1/repos/%s/%s/https_keys", repoOwner.Name, repo.Name) + + // Create a key + req := NewRequestWithJSON(t, "POST", keysURL, api.CreateHTTPSDeployKeyOption{ + Name: "list-test-key", + ReadOnly: false, + }).AddTokenAuth(token) + MakeRequest(t, req, http.StatusCreated) + + // List should now contain the key + req = NewRequest(t, "GET", keysURL). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + keys := DecodeJSON(t, resp, []api.HTTPSDeployKey{}) + found := false + for _, k := range keys { + if k.Name == "list-test-key" { + found = true + assert.False(t, k.ReadOnly) + assert.Empty(t, k.Token, "list response must not include the plaintext token") + break + } + } + assert.True(t, found, "created key should appear in list") +} + +func TestGetHTTPSDeployKey(t *testing.T) { + defer tests.PrepareTestEnv(t)() + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{Name: "repo1"}) + repoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + + session := loginUser(t, repoOwner.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + keysURL := fmt.Sprintf("/api/v1/repos/%s/%s/https_keys", repoOwner.Name, repo.Name) + + // Create a key + createBody := api.CreateHTTPSDeployKeyOption{ + Name: "get-test-key", + ReadOnly: true, + } + req := NewRequestWithJSON(t, "POST", keysURL, createBody). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusCreated) + createdKey := DecodeJSON(t, resp, &api.HTTPSDeployKey{}) + + // Get by ID + getURL := fmt.Sprintf("%s/%d", keysURL, createdKey.ID) + req = NewRequest(t, "GET", getURL). + AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + gotKey := DecodeJSON(t, resp, &api.HTTPSDeployKey{}) + assert.Equal(t, createdKey.ID, gotKey.ID) + assert.Equal(t, "get-test-key", gotKey.Name) + assert.True(t, gotKey.ReadOnly) + assert.Empty(t, gotKey.Token, "get response must not include the plaintext token") + + // Get non-existent key + req = NewRequest(t, "GET", keysURL+"/999999"). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusNotFound) +} + +func TestDeleteHTTPSDeployKey(t *testing.T) { + defer tests.PrepareTestEnv(t)() + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{Name: "repo1"}) + repoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + + session := loginUser(t, repoOwner.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + keysURL := fmt.Sprintf("/api/v1/repos/%s/%s/https_keys", repoOwner.Name, repo.Name) + + // Create a key + createBody := api.CreateHTTPSDeployKeyOption{ + Name: "delete-test-key", + ReadOnly: true, + } + req := NewRequestWithJSON(t, "POST", keysURL, createBody). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusCreated) + createdKey := DecodeJSON(t, resp, &api.HTTPSDeployKey{}) + + // Delete + deleteURL := fmt.Sprintf("%s/%d", keysURL, createdKey.ID) + req = NewRequest(t, "DELETE", deleteURL). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusNoContent) + + // Verify deleted + req = NewRequest(t, "GET", deleteURL). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusNotFound) +} + +func TestCreateHTTPSDeployKeyDuplicateName(t *testing.T) { + defer tests.PrepareTestEnv(t)() + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{Name: "repo1"}) + repoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + + session := loginUser(t, repoOwner.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + keysURL := fmt.Sprintf("/api/v1/repos/%s/%s/https_keys", repoOwner.Name, repo.Name) + + createBody := api.CreateHTTPSDeployKeyOption{ + Name: "duplicate-name-key", + ReadOnly: true, + } + req := NewRequestWithJSON(t, "POST", keysURL, createBody). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusCreated) + + // Try to create with the same name + req = NewRequestWithJSON(t, "POST", keysURL, createBody). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusUnprocessableEntity) +} diff --git a/tests/integration/https_deploy_key_test.go b/tests/integration/https_deploy_key_test.go new file mode 100644 index 0000000000..883794221e --- /dev/null +++ b/tests/integration/https_deploy_key_test.go @@ -0,0 +1,135 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "net/http" + "testing" + + asymkey_model "code.gitea.io/gitea/models/asymkey" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unit" + "code.gitea.io/gitea/models/unittest" + repo_service "code.gitea.io/gitea/services/repository" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// addTestHTTPSDeployKey is a small helper that creates an HTTPS deploy key in +// the test database and returns the plaintext token. It assumes the fixtures +// already hold a repo with the given ID. +func addTestHTTPSDeployKey(t *testing.T, repoID int64, name string, readOnly bool) string { + t.Helper() + _, token, err := asymkey_model.AddHTTPSDeployKey(t.Context(), repoID, name, readOnly) + require.NoError(t, err) + return token +} + +func TestHTTPSDeployKeyClone_Read(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + token := addTestHTTPSDeployKey(t, repo.ID, "https-clone-read", true) + + req := NewRequest(t, "GET", "/"+repo.FullName()+"/info/refs?service=git-upload-pack") + req.Request.SetBasicAuth("x-deploy-token", token) + resp := MakeRequest(t, req, http.StatusOK) + assert.Contains(t, resp.Body.String(), "service=git-upload-pack") +} + +func TestHTTPSDeployKeyPush_ReadOnlyDenied(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + token := addTestHTTPSDeployKey(t, repo.ID, "https-push-denied", true) + + req := NewRequest(t, "GET", "/"+repo.FullName()+"/info/refs?service=git-receive-pack") + req.Request.SetBasicAuth("x-deploy-token", token) + MakeRequest(t, req, http.StatusForbidden) +} + +func TestHTTPSDeployKeyPush_WriteAllowed(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + token := addTestHTTPSDeployKey(t, repo.ID, "https-push-allowed", false) + + // `GET info/refs?service=git-receive-pack` is the same auth path a + // real push would hit first; a 200 means the server would accept the + // push as far as auth is concerned. + req := NewRequest(t, "GET", "/"+repo.FullName()+"/info/refs?service=git-receive-pack") + req.Request.SetBasicAuth("x-deploy-token", token) + resp := MakeRequest(t, req, http.StatusOK) + assert.Contains(t, resp.Body.String(), "service=git-receive-pack") +} + +func TestHTTPSDeployKeyCrossRepo(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + repo2 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2}) + token := addTestHTTPSDeployKey(t, repo1.ID, "https-cross-repo", false) + + req := NewRequest(t, "GET", "/"+repo2.FullName()+"/info/refs?service=git-upload-pack") + req.Request.SetBasicAuth("x-deploy-token", token) + // A cross-repo token should be treated exactly like a missing credential + // for repo2 — the server must not leak whether the token is otherwise + // valid. + MakeRequest(t, req, http.StatusNotFound) +} + +func TestHTTPSDeployKeyInvalidToken(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + + // 40 hex chars that do not correspond to any stored token: the server + // must fall through to Basic auth which then rejects it. + req := NewRequest(t, "GET", "/"+repo.FullName()+"/info/refs?service=git-upload-pack") + req.Request.SetBasicAuth("x-deploy-token", "0123456789abcdef0123456789abcdef01234567") + MakeRequest(t, req, http.StatusUnauthorized) +} + +// TestHTTPSDeployKeyScopedToGitHTTP asserts that a deploy token does NOT +// authenticate on non-git Basic-auth endpoints. Historically the token check +// lived inside auth.Basic.VerifyAuthToken, which made the token behave like a +// full owner-scoped PAT on the REST API, attachments, feeds, etc. +func TestHTTPSDeployKeyScopedToGitHTTP(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + token := addTestHTTPSDeployKey(t, repo.ID, "scope-guard", false) + + // The token works on a git-HTTP path… + gitReq := NewRequest(t, "GET", "/"+repo.FullName()+"/info/refs?service=git-upload-pack") + gitReq.Request.SetBasicAuth("x-deploy-token", token) + MakeRequest(t, gitReq, http.StatusOK) + + // …but the same token on the REST API must NOT authenticate. The API + // layer only knows user/PAT credentials, so the request is rejected as + // an invalid credential (401) rather than silently admitted as the + // repo owner. + apiReq := NewRequest(t, "GET", "/api/v1/repos/"+repo.FullName()) + apiReq.Request.SetBasicAuth("x-deploy-token", token) + MakeRequest(t, apiReq, http.StatusUnauthorized) +} + +// TestHTTPSDeployKeyRespectsDisabledWikiUnit asserts that a deploy token +// cannot reach the wiki of a repository whose wiki unit has been disabled. +// Before the fix the deploy-token branch in httpBase short-circuited before +// the unit-enablement check that runs for password / PAT paths. +func TestHTTPSDeployKeyRespectsDisabledWikiUnit(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + require.NoError(t, repo_service.UpdateRepositoryUnits(t.Context(), repo, nil, []unit.Type{unit.TypeWiki})) + + token := addTestHTTPSDeployKey(t, repo.ID, "wiki-guard", false) + + wikiReq := NewRequest(t, "GET", "/"+repo.FullName()+".wiki/info/refs?service=git-upload-pack") + wikiReq.Request.SetBasicAuth("x-deploy-token", token) + MakeRequest(t, wikiReq, http.StatusForbidden) +}