Files
forge/models/asymkey/ssh_key_authorized_keys.go
zeekayandHanzo Dev 27cde39e36 Name every remaining identifier, path and default for this product
Storage buckets, indexer names, the bleve analyzer, the lock prefix, SSH host
keys, the markdown front-matter key, the container data paths, the RPM repo id
and the API security scheme all read as this product now, and app.example.ini
matches the code again. The upstream maintainer list and the changelog
generator config went with the release history they belonged to, and the
citation comments state the fact rather than link the issue.

Three inert rules turn out to have been inert because they named a module path
this tree does not use: gci grouped no first-party imports under prefix(), and
depguard let models/migrations import models and modules/structs. They now name
github.com/hanzoai/git. The config example also documented .gitea/workflows
while the code has read .hanzo/workflows for some time.

The authorized_keys marker follows the delegate-hook precedent: the new name is
written and the old one is still recognised, because a marker we do not know is
copied through as a hand-added key and leaves a revoked key authorized. The
stored user_setting key keeps its value; only a migration may change that.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-10 19:14:35 -07:00

223 lines
6.9 KiB
Go

// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// SPDX-License-Identifier: MIT
package asymkey
import (
"bufio"
"context"
"fmt"
"io"
"os"
"path/filepath"
"regexp"
"strings"
"sync"
"github.com/hanzoai/git/models/db"
"github.com/hanzoai/git/modules/log"
"github.com/hanzoai/git/modules/setting"
"github.com/hanzoai/git/modules/util"
"golang.org/x/crypto/ssh"
)
// AuthorizedStringCommentPrefix marks the lines in authorized_keys this instance
// manages, so RegeneratePublicKeys replaces them and leaves keys the user added
// by hand alone.
const AuthorizedStringCommentPrefix = `# forge public key`
// legacyAuthorizedStringCommentPrefixes are the markers earlier releases wrote.
// Recognising them is what makes a revoked key stop working: a marker we do not
// know is copied through as a hand-added key, and the key beneath it stays
// authorized for as long as the file lives.
var legacyAuthorizedStringCommentPrefixes = []string{`# gitea public key`}
// IsManagedAuthorizedLine reports whether a line in authorized_keys is a marker
// this instance wrote. The key itself is on the following line.
func IsManagedAuthorizedLine(line string) bool {
if strings.HasPrefix(line, AuthorizedStringCommentPrefix) {
return true
}
for _, prefix := range legacyAuthorizedStringCommentPrefixes {
if strings.HasPrefix(line, prefix) {
return true
}
}
return false
}
var sshOpLocker sync.Mutex
func WithSSHOpLocker(f func() error) error {
sshOpLocker.Lock()
defer sshOpLocker.Unlock()
return f()
}
// AuthorizedStringForKey creates the authorized keys string appropriate for the provided key
func AuthorizedStringForKey(key *PublicKey) (string, error) {
sb := &strings.Builder{}
_, err := writeAuthorizedStringForKey(key, sb)
return sb.String(), err
}
// WriteAuthorizedStringForValidKey writes the authorized key for the provided key. If the key is invalid, it does nothing.
func WriteAuthorizedStringForValidKey(key *PublicKey, w io.Writer) error {
validKey, err := writeAuthorizedStringForKey(key, w)
if !validKey {
log.Debug("WriteAuthorizedStringForValidKey: key %s is not valid: %v", key, err)
return nil
}
return err
}
var globalVars = sync.OnceValue(func() (ret struct {
principalRegexp *regexp.Regexp
},
) {
// principalRegexp expresses whether a principal is considered valid.
// This reverse engineers how sshd parses the authorized keys file,
// see e.g. https://github.com/openssh/openssh-portable/blob/32deb00b38b4ee2b3302f261ea1e68c04e020a08/auth2-pubkeyfile.c#L221-L256
// Any newline or # comment will be stripped when parsing, so don't allow
// those. Also, if any space or tab is present in the principal, the part
// proceeding this would be parsed as an option, so just avoid any whitespace
// altogether.
ret.principalRegexp = regexp.MustCompile(`^[^\s#]+$`)
return ret
})
func writeAuthorizedStringForKey(key *PublicKey, w io.Writer) (keyValid bool, err error) {
const tpl = AuthorizedStringCommentPrefix + "\n" + `command=%s,no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty,no-user-rc,restrict %s` + "\n"
var sshKey string
if key.Type == KeyTypePrincipal {
// TODO: actually using PublicKey to store "principal" is an abuse
if !globalVars().principalRegexp.MatchString(key.Content) {
return false, fmt.Errorf("invalid principal key: %s", key.Content)
}
sshKey = fmt.Sprintf("%s # user-%d", key.Content, key.OwnerID)
} else {
pubKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(key.Content))
if err != nil {
return false, err
}
sshKeyMarshalled := strings.TrimSpace(string(ssh.MarshalAuthorizedKey(pubKey)))
sshKey = fmt.Sprintf("%s user-%d", sshKeyMarshalled, key.OwnerID)
}
// now the key is valid, the code below could only return template/IO related errors
sbCmd := &strings.Builder{}
err = setting.SSH.AuthorizedKeysCommandTemplateTemplate.Execute(sbCmd, map[string]any{
"AppPath": util.ShellEscape(setting.AppPath),
"AppWorkPath": util.ShellEscape(setting.AppWorkPath),
"CustomConf": util.ShellEscape(setting.CustomConf),
"CustomPath": util.ShellEscape(setting.CustomPath),
"Key": key,
})
if err != nil {
return true, err
}
sshCommandEscaped := util.ShellEscape(sbCmd.String())
_, err = fmt.Fprintf(w, tpl, sshCommandEscaped, sshKey)
return true, err
}
// appendAuthorizedKeysToFile appends new SSH keys' content to authorized_keys file.
func appendAuthorizedKeysToFile(keys ...*PublicKey) error {
// Don't need to rewrite this file if builtin SSH server is enabled.
if setting.SSH.StartBuiltinServer || !setting.SSH.CreateAuthorizedKeysFile {
return nil
}
sshOpLocker.Lock()
defer sshOpLocker.Unlock()
if setting.SSH.RootPath != "" {
// First of ensure that the RootPath is present, and if not make it with 0700 permissions
// This of course doesn't guarantee that this is the right directory for authorized_keys
// but at least if it's supposed to be this directory and it doesn't exist and we're the
// right user it will at least be created properly.
err := os.MkdirAll(setting.SSH.RootPath, 0o700)
if err != nil {
log.Error("Unable to MkdirAll(%s): %v", setting.SSH.RootPath, err)
return err
}
}
fPath := filepath.Join(setting.SSH.RootPath, "authorized_keys")
f, err := os.OpenFile(fPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
if err != nil {
return err
}
defer f.Close()
// Note: chmod command does not support in Windows.
if !setting.IsWindows {
fi, err := f.Stat()
if err != nil {
return err
}
// .ssh directory should have mode 700, and authorized_keys file should have mode 600.
if fi.Mode().Perm() > 0o600 {
log.Error("authorized_keys file has unusual permission flags: %s - setting to -rw-------", fi.Mode().Perm().String())
if err = f.Chmod(0o600); err != nil {
return err
}
}
}
for _, key := range keys {
if key.Type == KeyTypePrincipal {
continue
}
if err = WriteAuthorizedStringForValidKey(key, f); err != nil {
return err
}
}
return nil
}
// RegeneratePublicKeys regenerates the authorized_keys file
func RegeneratePublicKeys(ctx context.Context, t io.Writer) error {
if err := db.GetEngine(ctx).Where("type != ?", KeyTypePrincipal).Iterate(new(PublicKey), func(idx int, bean any) (err error) {
return WriteAuthorizedStringForValidKey(bean.(*PublicKey), t)
}); err != nil {
return err
}
fPath := filepath.Join(setting.SSH.RootPath, "authorized_keys")
isExist, err := util.IsExist(fPath)
if err != nil {
log.Error("Unable to check if %s exists. Error: %v", fPath, err)
return err
}
if isExist {
f, err := os.Open(fPath)
if err != nil {
return err
}
defer f.Close()
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := scanner.Text()
if IsManagedAuthorizedLine(line) {
scanner.Scan()
continue
}
_, err = io.WriteString(t, line+"\n")
if err != nil {
return err
}
}
if err = scanner.Err(); err != nil {
return fmt.Errorf("RegeneratePublicKeys scan: %w", err)
}
}
return nil
}