Skip to content
Merged
Show file tree
Hide file tree
Changes from 26 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
2a741b7
feat(secrets): generate private ssh key on server
fiftin Feb 21, 2026
186b1b8
feat(secrets): gen SSH key on server
fiftin Feb 21, 2026
3463fbb
fix(secrets): show public key for updated secrets too
fiftin Feb 21, 2026
1fc0245
feat(secrets): don't allow user override plain field
fiftin Feb 21, 2026
aaecddd
Merge branch 'develop' into feat/gen_ssh_key
fiftin May 2, 2026
1d638a4
Merge branch 'develop' into feat/gen_ssh_key
fiftin May 30, 2026
e1672e7
fix: merge conflict
fiftin Aug 19, 2026
a17659f
fix(ui): linter
fiftin Aug 20, 2026
f12be5c
fix(secrets): sql request args
fiftin Aug 20, 2026
e77f70e
fix(ui): disable input if not required
fiftin Aug 25, 2026
191cc0a
fix(be): remove unused field from API model
fiftin Aug 25, 2026
f6a23e7
Merge branch 'develop' into feat/gen_ssh_key
fiftin Sep 7, 2026
fd84159
Merge branch 'develop' into feat/gen_ssh_key
fiftin Sep 7, 2026
9582838
fix(secrets): auto gen ssh key with public key
fiftin Sep 7, 2026
e124444
fix(ui): extra space
fiftin Sep 7, 2026
0e8aa59
feat(secrets): public key correct format
fiftin Sep 7, 2026
8a92094
fix(ci): avoid release tool installs in image builds
Copilot Sep 7, 2026
7a37f6f
fix(keys): preserve generated ssh key flows
Copilot Sep 7, 2026
2d0b348
Fix formatting of SSH key assignment
fiftin Sep 10, 2026
eb6f356
fix: persist generated ssh key on update
Copilot Sep 10, 2026
5942665
Merge branch 'develop' into feat/gen_ssh_key
fiftin Sep 18, 2026
967a0cb
test: add test for secrets
fiftin Sep 19, 2026
3f0c99f
feat: update docs
fiftin Sep 19, 2026
5a75139
Add cleanup for util.Config in AccessKey tests
fiftin Sep 20, 2026
7c3d19e
Add cleanup for access key service test
fiftin Sep 20, 2026
5882a67
fix: detect generated SSH keys from create response
Copilot Sep 20, 2026
f97145d
Apply remaining changes
Copilot Sep 20, 2026
b8edb4d
refactor(ssh gen): make methods more readable
fiftin Sep 20, 2026
6c93273
fix: merge conflict
fiftin Sep 20, 2026
82d136d
Modify test to handle configuration cleanup
fiftin Sep 20, 2026
add71f5
feat(key gen): improve validation
fiftin Sep 21, 2026
14f4856
feat(ui): show/hide public key
fiftin Sep 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ tasks:
desc: Install all build dependencies
cmds:
- task: deps:tools
- task: deps:image

deps:image:
desc: Install dependencies required to build application artifacts
cmds:
- task: deps:be
- task: deps:fe

Expand Down
1 change: 1 addition & 0 deletions api/projects/keys.go
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ func (c *KeyController) AddKey(w http.ResponseWriter, r *http.Request) {
helpers.WriteError(w, err)
return
}
key.Plain = newKey.Plain
Comment thread
fiftin marked this conversation as resolved.

helpers.WriteJSON(w, http.StatusCreated, key)
}
Expand Down
3 changes: 2 additions & 1 deletion db/AccessKey.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,12 @@ type AccessKey struct {
// You should use methods SerializeSecret to fill this field.
Secret *string `db:"secret" json:"-" backup:"-"`
Plain *string `db:"plain" json:"plain,omitempty"`
IgnorePlain bool
IgnorePlain bool `db:"-" json:"-" backup:"-" `

String string `db:"-" json:"string"`
LoginPassword LoginPassword `db:"-" json:"login_password"`
SshKey SshKey `db:"-" json:"ssh"`
GenerateSSHKey bool `db:"-" json:"generate_ssh_key,omitempty"`
Comment thread
fiftin marked this conversation as resolved.
OverrideSecret bool `db:"-" json:"override_secret,omitempty"`

StorageID *int `db:"storage_id" json:"-" backup:"-"`
Expand Down
1 change: 0 additions & 1 deletion db/sql/access_key.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,6 @@ func (d *SqlDb) UpdateAccessKey(key db.AccessKey) error {
}

if key.OverrideSecret {

query += ", type=?, secret=?, source_storage_id=?, source_storage_key=?, source_storage_type=?"
args = append(args, key.Type)
args = append(args, key.Secret)
Expand Down
2 changes: 1 addition & 1 deletion deployment/docker/runner/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ RUN if [ -n "$APP_BUILD_TYPE" ]; then \

RUN --mount=type=cache,target=/go/pkg \
--mount=type=cache,target=/root/.cache/go-build \
task deps APP_BUILD_TYPE=${APP_BUILD_TYPE} && \
task deps:image APP_BUILD_TYPE=${APP_BUILD_TYPE} && \
task build GOOS=${TARGETOS} GOARCH=${TARGETARCH} APP_BUILD_TYPE=${APP_BUILD_TYPE}


Expand Down
2 changes: 1 addition & 1 deletion deployment/docker/server/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ RUN if [ -n "$APP_BUILD_TYPE" ]; then \

RUN --mount=type=cache,target=/go/pkg \
--mount=type=cache,target=/root/.cache/go-build \
task deps APP_BUILD_TYPE=${APP_BUILD_TYPE} && \
task deps:image APP_BUILD_TYPE=${APP_BUILD_TYPE} && \
task build GOOS=${TARGETOS} GOARCH=${TARGETARCH} APP_BUILD_TYPE=${APP_BUILD_TYPE}

# renovate: datasource=github-releases depName=opentofu/opentofu
Expand Down
59 changes: 57 additions & 2 deletions services/server/AccessKey_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package server
import (
"bytes"
"encoding/base64"
"encoding/json"
"os"
"path/filepath"
"testing"
Expand Down Expand Up @@ -119,6 +120,50 @@ func TestCreateSkipsSerializationForReadOnlyStorage(t *testing.T) {
assert.Equal(t, "test", created.Name)
}

func TestUpdateGeneratesSSHKeyWithoutOverrideSecret(t *testing.T) {
projectID := 1
key := db.AccessKey{
ID: 1,
Name: "generated",
Type: db.AccessKeySSH,
ProjectID: &projectID,
GenerateSSHKey: true,
OverrideSecret: false,
}

var updated db.AccessKey
repo := &mockAccessKeyRepo{
keys: []db.AccessKey{{
ID: 1,
ProjectID: &projectID,
Type: db.AccessKeySSH,
Name: "generated",
}},
UpdateAccessKeyFn: func(k db.AccessKey) error {
updated = k
return nil
},
}

previousConfig := util.Config
util.Config = &util.ConfigType{}
Comment thread
Copilot marked this conversation as resolved.
Outdated
t.Cleanup(func() { util.Config = previousConfig })
encryptionService := NewAccessKeyEncryptionService(nil, nil, nil, nil)
svc := NewAccessKeyService(repo, encryptionService, nil)

err := svc.Update(key)
require.NoError(t, err)
require.NotEmpty(t, updated.SshKey.PrivateKey)
require.NotNil(t, updated.Plain)
require.NotNil(t, updated.Secret)
secret, err := base64.StdEncoding.DecodeString(*updated.Secret)
require.NoError(t, err)
var stored db.SshKey
require.NoError(t, json.Unmarshal(secret, &stored))
assert.Contains(t, *updated.Plain, "public_key")
assert.Equal(t, updated.SshKey.PrivateKey, stored.PrivateKey)
}

func TestRekeyAccessKeysSkipsExternalStorageKeys(t *testing.T) {
projectID := 1

Expand Down Expand Up @@ -290,7 +335,9 @@ func TestRekeyAccessKeysReStampsToActiveID(t *testing.T) {
}

type mockAccessKeyRepo struct {
keys []db.AccessKey
keys []db.AccessKey
UpdateAccessKeyFn func(db.AccessKey) error
CreateAccessKeyFn func(db.AccessKey) (db.AccessKey, error)
}

func (m *mockAccessKeyRepo) GetAccessKey(_ int, keyID int) (db.AccessKey, error) {
Expand All @@ -307,8 +354,16 @@ func (m *mockAccessKeyRepo) GetAccessKeyRefs(int, int) (db.ObjectReferrers, erro
func (m *mockAccessKeyRepo) GetAccessKeys(int, db.GetAccessKeyOptions, db.RetrieveQueryParams) ([]db.AccessKey, error) {
return nil, nil
}
func (m *mockAccessKeyRepo) UpdateAccessKey(db.AccessKey) error { return nil }
func (m *mockAccessKeyRepo) UpdateAccessKey(key db.AccessKey) error {
if m.UpdateAccessKeyFn != nil {
return m.UpdateAccessKeyFn(key)
}
return nil
}
func (m *mockAccessKeyRepo) CreateAccessKey(k db.AccessKey) (db.AccessKey, error) {
if m.CreateAccessKeyFn != nil {
return m.CreateAccessKeyFn(k)
}
return k, nil
}
func (m *mockAccessKeyRepo) DeleteAccessKey(int, int) error { return nil }
Expand Down
56 changes: 56 additions & 0 deletions services/server/access_key_svc.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
package server

import (
"bufio"
"bytes"
"encoding/json"
"errors"

"github.com/semaphoreui/semaphore/db"
"github.com/semaphoreui/semaphore/pkg/common_errors"
"github.com/semaphoreui/semaphore/util"
)

type AccessKeyService interface {
Expand Down Expand Up @@ -69,7 +73,50 @@ func (s *AccessKeyServiceImpl) GetAll(projectID int, options db.GetAccessKeyOpti
return s.accessKeyRepo.GetAccessKeys(projectID, options, params)
}

func maybeGenerateSSHPrivateKey(key *db.AccessKey) error {
if !key.GenerateSSHKey || key.Type != db.AccessKeySSH {
key.Plain = nil
return nil
}
Comment thread
fiftin marked this conversation as resolved.
Outdated

var b bytes.Buffer
privateKeyFile := bufio.NewWriter(&b)

publicKey, err := util.GeneratePrivateKey(privateKeyFile)
Comment thread
fiftin marked this conversation as resolved.
Outdated
if err != nil {
return err
}

err = privateKeyFile.Flush()
if err != nil {
return err
}

key.SshKey.PrivateKey = b.String()
Comment thread
Copilot marked this conversation as resolved.
Outdated
key.SshKey.Passphrase = ""

type sshPublicKey struct {
PublicKey string `json:"public_key"`
}

plainBytes, err := json.Marshal(sshPublicKey{
PublicKey: publicKey,
})
if err != nil {
return err
}

plain := string(plainBytes)
key.Plain = &plain
Comment thread
fiftin marked this conversation as resolved.
Outdated
key.IgnorePlain = false
Comment thread
fiftin marked this conversation as resolved.
Comment thread
fiftin marked this conversation as resolved.
Outdated
return nil
}

func (s *AccessKeyServiceImpl) Create(key db.AccessKey) (newKey db.AccessKey, err error) {
err = maybeGenerateSSHPrivateKey(&key)
if err != nil {
return
}

// SerializeSecret encrypts/persists the secret for writable backends. For read-only
// external storage the secret is not stored in Semaphore, so SerializeSecret fails
Expand All @@ -84,6 +131,15 @@ func (s *AccessKeyServiceImpl) Create(key db.AccessKey) (newKey db.AccessKey, er
}

func (s *AccessKeyServiceImpl) Update(key db.AccessKey) (err error) {
err = maybeGenerateSSHPrivateKey(&key)
Comment thread
fiftin marked this conversation as resolved.
Outdated
if err != nil {
return
}

if key.GenerateSSHKey && key.Type == db.AccessKeySSH {
key.OverrideSecret = true
}

if !key.OverrideSecret {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
err = s.accessKeyRepo.UpdateAccessKey(key)
return
Expand Down
93 changes: 93 additions & 0 deletions services/server/access_key_svc_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package server

import (
"encoding/base64"
"encoding/json"
"testing"

"github.com/semaphoreui/semaphore/db"
"github.com/semaphoreui/semaphore/util"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestAccessKeyService_Update_GeneratedSSHKeyIsPersisted(t *testing.T) {
previousConfig := util.Config
util.Config = &util.ConfigType{}
Comment thread
Copilot marked this conversation as resolved.
t.Cleanup(func() { util.Config = previousConfig })

projectID := 1
repo := &mockAccessKeyRepo{
keys: []db.AccessKey{{ID: 10, ProjectID: &projectID, Name: "old", Type: db.AccessKeySSH}},
}

var persisted *db.AccessKey
repo.UpdateAccessKeyFn = func(k db.AccessKey) error {
persisted = &k
return nil
}

svc := NewAccessKeyService(repo, NewAccessKeyEncryptionService(repo, nil, nil, nil), nil)

err := svc.Update(db.AccessKey{
ID: 10,
ProjectID: &projectID,
Name: "generated",
Type: db.AccessKeySSH,
GenerateSSHKey: true,
OverrideSecret: false,
})

require.NoError(t, err)
require.NotNil(t, persisted)

// The generated key must reach the store as a secret override,
// otherwise UpdateAccessKey silently skips the secret column.
assert.True(t, persisted.OverrideSecret)
assert.False(t, persisted.IgnorePlain)
require.NotNil(t, persisted.Secret)

// Without an encryption key configured the secret is base64-encoded.
raw, err := base64.StdEncoding.DecodeString(*persisted.Secret)
require.NoError(t, err)

var sshKey db.SshKey
require.NoError(t, json.Unmarshal(raw, &sshKey))
assert.Contains(t, sshKey.PrivateKey, "PRIVATE KEY")
assert.Empty(t, sshKey.Passphrase)

require.NotNil(t, persisted.Plain)
var plain struct {
PublicKey string `json:"public_key"`
}
require.NoError(t, json.Unmarshal([]byte(*persisted.Plain), &plain))
assert.NotEmpty(t, plain.PublicKey)
}

func TestAccessKeyService_Update_NoGenerateNoOverrideKeepsSecret(t *testing.T) {
util.Config = &util.ConfigType{}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

projectID := 1
repo := &mockAccessKeyRepo{}

var persisted *db.AccessKey
repo.UpdateAccessKeyFn = func(k db.AccessKey) error {
persisted = &k
return nil
}

svc := NewAccessKeyService(repo, NewAccessKeyEncryptionService(repo, nil, nil, nil), nil)

err := svc.Update(db.AccessKey{
ID: 10,
ProjectID: &projectID,
Name: "renamed",
Type: db.AccessKeySSH,
})

require.NoError(t, err)
require.NotNil(t, persisted)
assert.False(t, persisted.OverrideSecret)
assert.Nil(t, persisted.Secret)
assert.Nil(t, persisted.Plain)
}
23 changes: 6 additions & 17 deletions util/encryption.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
package util

import (
"bufio"
"bytes"
"crypto/aes"
"crypto/cipher"
"crypto/rand"
Expand All @@ -13,6 +11,8 @@ import (
"errors"
"fmt"
"io"

"golang.org/x/crypto/ssh"
)

// EncryptAESGCM encrypts a plaintext using AES-256-GCM with the given base64-encoded key. If the key is empty, it returns the plaintext as base64.
Expand Down Expand Up @@ -90,23 +90,12 @@ func GeneratePrivateKey(privateKeyFile io.Writer) (publicKey string, err error)
return
}

publicKeyBytes := x509.MarshalPKCS1PublicKey(&privateKey.PublicKey)
publicKeyPem := &pem.Block{
Type: "PUBLIC KEY",
Bytes: publicKeyBytes,
}

var b bytes.Buffer
publicKeyFile := bufio.NewWriter(&b)

if err = pem.Encode(publicKeyFile, publicKeyPem); err != nil {
return
}

if err = publicKeyFile.Flush(); err != nil {
// 4. Encode the public key in OpenSSH authorized_keys format
sshPublicKey, err := ssh.NewPublicKey(&privateKey.PublicKey)
if err != nil {
return
}

publicKey = b.String()
publicKey = string(ssh.MarshalAuthorizedKey(sshPublicKey))
return
}
Loading
Loading