New Sign in

fierj

Public

Tiny personal git forge

ab5d29f62e6d420288b8d0e1681607f80c261745
diff --git a/auth.go b/auth.go
index be0c89d..a4bcf2a 100644
--- a/auth.go
+++ b/auth.go
@@ -13,21 +13,37 @@ import (
 	"golang.org/x/crypto/bcrypt"
 )
 
-// UserStore holds username → bcrypt-hash mappings loaded from a JSON file.
-type UserStore map[string]string
+// UserInfo holds a user's credentials and SSH keys.
+type UserInfo struct {
+	Password string   `json:"password"`
+	SSHKeys  []string `json:"ssh_keys,omitempty"`
+}
+
+// UserStore holds username → UserInfo mappings.
+type UserStore map[string]UserInfo
 
 func LoadUsers(path string) (UserStore, error) {
 	data, err := os.ReadFile(path)
 	if err != nil {
 		if os.IsNotExist(err) {
-			return UserStore{}, nil // first run, no users yet
+			return UserStore{}, nil
 		}
 		return nil, err
 	}
+	// Try new UserInfo format first.
 	var store UserStore
-	if err := json.Unmarshal(data, &store); err != nil {
+	if err := json.Unmarshal(data, &store); err == nil {
+		return store, nil
+	}
+	// Fall back to legacy format (map[string]string) and migrate.
+	var legacy map[string]string
+	if err := json.Unmarshal(data, &legacy); err != nil {
 		return nil, err
 	}
+	store = make(UserStore, len(legacy))
+	for user, hash := range legacy {
+		store[user] = UserInfo{Password: hash}
+	}
 	return store, nil
 }
 
@@ -35,11 +51,11 @@ func (u UserStore) Verify(username, password string) bool {
 	if u == nil || len(u) == 0 {
 		return false
 	}
-	hash, ok := u[username]
+	info, ok := u[username]
 	if !ok {
 		return false
 	}
-	return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
+	return bcrypt.CompareHashAndPassword([]byte(info.Password), []byte(password)) == nil
 }
 
 func (u UserStore) Save(path string) error {
@@ -67,7 +83,7 @@ func (u UserStore) Add(username, password string) error {
 	if err != nil {
 		return err
 	}
-	u[username] = string(hash)
+	u[username] = UserInfo{Password: string(hash)}
 	return nil
 }
 
@@ -84,14 +100,56 @@ func (u UserStore) ChangePassword(username, newPassword string) error {
 	if newPassword == "" {
 		return fmt.Errorf("password is required")
 	}
+	info, ok := u[username]
+	if !ok {
+		return fmt.Errorf("user not found")
+	}
 	hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
 	if err != nil {
 		return err
 	}
-	u[username] = string(hash)
+	info.Password = string(hash)
+	u[username] = info
 	return nil
 }
 
+// SetSSHKeys replaces the SSH keys for a user.
+func (u UserStore) SetSSHKeys(username string, keys []string) error {
+	if u == nil {
+		return fmt.Errorf("user store not configured")
+	}
+	info, ok := u[username]
+	if !ok {
+		return fmt.Errorf("user not found")
+	}
+	info.SSHKeys = keys
+	u[username] = info
+	return nil
+}
+
+// HasAnyKeys returns true if any user has SSH keys configured.
+func (u UserStore) HasAnyKeys() bool {
+	for _, info := range u {
+		if len(info.SSHKeys) > 0 {
+			return true
+		}
+	}
+	return false
+}
+
+// FindUserByKey returns the username whose SSH keys contain the given key.
+func (u UserStore) FindUserByKey(pubkeyFields []string) string {
+	for username, info := range u {
+		for _, k := range info.SSHKeys {
+			kf := strings.Fields(strings.TrimSpace(k))
+			if len(kf) >= 2 && pubkeyFields[0] == kf[0] && pubkeyFields[1] == kf[1] {
+				return username
+			}
+		}
+	}
+	return ""
+}
+
 // ----- Cookie signing -----
 
 func signCookie(username string, secret []byte) string {
diff --git a/auth_handlers.go b/auth_handlers.go
index 98c2add..af19a8d 100644
--- a/auth_handlers.go
+++ b/auth_handlers.go
@@ -1,6 +1,7 @@
 package main
 
 import (
+	"fmt"
 	"html/template"
 	"net/http"
 	"strings"
@@ -61,6 +62,10 @@ func SetupPost(users UserStore, usersPath string, secret []byte, tmpl *template.
 			tmpl.ExecuteTemplate(w, "auth_setup.html", map[string]any{"Error": err.Error(), "User": User(r)})
 			return
 		}
+		// Save SSH key if provided.
+		if key := strings.TrimSpace(r.FormValue("ssh_key")); key != "" {
+			users.SetSSHKeys(username, []string{key})
+		}
 		if err := users.Save(usersPath); err != nil {
 			tmpl.ExecuteTemplate(w, "auth_setup.html", map[string]any{"Error": "Failed to save: " + err.Error(), "User": User(r)})
 			return
@@ -82,9 +87,11 @@ func UsersGet(users UserStore, usersPath string, tmpl *template.Template) http.H
 		for u := range users {
 			userList = append(userList, u)
 		}
+		sshKeys := users[User(r)].SSHKeys
 		tmpl.ExecuteTemplate(w, "auth_users.html", map[string]any{
-			"Users": userList,
-			"User":  User(r),
+			"Users":   userList,
+			"User":    User(r),
+			"SSHKeys": sshKeys,
 		})
 	}
 }
@@ -121,6 +128,26 @@ func UsersPost(users UserStore, usersPath string, tmpl *template.Template) http.
 				return
 			}
 			users.Save(usersPath)
+		case "add-ssh-key":
+			currentUser := User(r)
+			if key := strings.TrimSpace(r.FormValue("ssh_key")); key != "" {
+				info := users[currentUser]
+				info.SSHKeys = append(info.SSHKeys, key)
+				users[currentUser] = info
+				users.Save(usersPath)
+			}
+		case "remove-ssh-key":
+			currentUser := User(r)
+			idx := r.FormValue("index")
+			if info, ok := users[currentUser]; ok {
+				var i int
+				fmt.Sscanf(idx, "%d", &i)
+				if i >= 0 && i < len(info.SSHKeys) {
+					info.SSHKeys = append(info.SSHKeys[:i], info.SSHKeys[i+1:]...)
+					users[currentUser] = info
+					users.Save(usersPath)
+				}
+			}
 		}
 		http.Redirect(w, r, "/users", http.StatusSeeOther)
 	}
diff --git a/main.go b/main.go
index f297d2b..87deb84 100644
--- a/main.go
+++ b/main.go
@@ -100,7 +100,7 @@ func main() {
 
 	// Start SSH server if configured
 	if cfg.SSHAddr != "" {
-		sshSrv, err := NewSSHServer(cfg.Dir, cfg.SSHHostKey)
+		sshSrv, err := NewSSHServer(cfg.Dir, cfg.SSHHostKey, users)
 		if err != nil {
 			slog.Error("failed to create SSH server", "error", err)
 			os.Exit(1)
diff --git a/ssh.go b/ssh.go
index bbf6ceb..1f91587 100644
--- a/ssh.go
+++ b/ssh.go
@@ -3,6 +3,7 @@ package main
 import (
 	"crypto/ed25519"
 	"crypto/rand"
+	"encoding/json"
 	"encoding/pem"
 	"fmt"
 	"io"
@@ -20,10 +21,11 @@ import (
 
 type SSHServer struct {
 	reposDir string
+	users    UserStore
 	config   *ssh.ServerConfig
 }
 
-func NewSSHServer(reposDir, hostKeyPath string) (*SSHServer, error) {
+func NewSSHServer(reposDir, hostKeyPath string, users UserStore) (*SSHServer, error) {
 	signer, err := loadOrGenerateHostKey(hostKeyPath)
 	if err != nil {
 		return nil, fmt.Errorf("host key: %w", err)
@@ -39,7 +41,7 @@ func NewSSHServer(reposDir, hostKeyPath string) (*SSHServer, error) {
 	}
 	config.AddHostKey(signer)
 
-	return &SSHServer{reposDir: reposDir, config: config}, nil
+	return &SSHServer{reposDir: reposDir, users: users, config: config}, nil
 }
 
 func (s *SSHServer) ListenAndServe(addr string) error {
@@ -80,11 +82,11 @@ func (s *SSHServer) handleConn(conn net.Conn) {
 		if err != nil {
 			continue
 		}
-		go s.handleSession(channel, requests)
+		go s.handleSession(channel, requests, sshConn.Permissions)
 	}
 }
 
-func (s *SSHServer) handleSession(channel ssh.Channel, requests <-chan *ssh.Request) {
+func (s *SSHServer) handleSession(channel ssh.Channel, requests <-chan *ssh.Request, perms *ssh.Permissions) {
 	defer channel.Close()
 
 	for req := range requests {
@@ -93,7 +95,7 @@ func (s *SSHServer) handleSession(channel ssh.Channel, requests <-chan *ssh.Requ
 		case "exec":
 			ssh.Unmarshal(req.Payload, &execMsg)
 			req.Reply(true, nil)
-			s.runGitCommand(channel, execMsg.Command)
+			s.runGitCommand(channel, execMsg.Command, perms)
 			return
 		case "env":
 			req.Reply(false, nil)
@@ -103,7 +105,7 @@ func (s *SSHServer) handleSession(channel ssh.Channel, requests <-chan *ssh.Requ
 	}
 }
 
-func (s *SSHServer) runGitCommand(channel ssh.Channel, gitCmd string) {
+func (s *SSHServer) runGitCommand(channel ssh.Channel, gitCmd string, perms *ssh.Permissions) {
 	parts := strings.SplitN(strings.TrimSpace(gitCmd), " ", 2)
 	if len(parts) != 2 {
 		io.WriteString(channel.Stderr(), "fatal: invalid git command\n")
@@ -133,7 +135,13 @@ func (s *SSHServer) runGitCommand(channel ssh.Channel, gitCmd string) {
 	}
 
 	switch subCmd {
-	case "git-upload-pack", "git-upload-archive", "git-receive-pack":
+	case "git-upload-pack", "git-upload-archive":
+	case "git-receive-pack":
+		if !s.checkPushAuth(repoPath, perms) {
+			io.WriteString(channel.Stderr(), "fatal: SSH key not authorized to push to this repository\n")
+			channel.SendRequest("exit-status", false, ssh.Marshal(&struct{ ExitStatus uint32 }{1}))
+			return
+		}
 	default:
 		io.WriteString(channel.Stderr(), "fatal: unknown git command: "+subCmd+"\n")
 		return
@@ -177,6 +185,51 @@ func (s *SSHServer) runGitCommand(channel ssh.Channel, gitCmd string) {
 	time.Sleep(50 * time.Millisecond)
 }
 
+// checkPushAuth returns true if the push should be allowed. The key must match
+// either per-repo authorized_keys or a user's registered SSH keys.
+func (s *SSHServer) checkPushAuth(repoPath string, perms *ssh.Permissions) bool {
+	if perms == nil {
+		return false
+	}
+	pubkey := strings.TrimSpace(perms.Extensions["pubkey"])
+	if pubkey == "" {
+		return false
+	}
+	pubFields := strings.Fields(pubkey)
+	if len(pubFields) < 2 {
+		return false
+	}
+
+	// Check per-repo authorized_keys.
+	data, err := os.ReadFile(filepath.Join(repoPath, ".fierj.json"))
+	var repoKeys int
+	if err == nil {
+		var meta RepoMeta
+		if err := json.Unmarshal(data, &meta); err == nil && len(meta.AuthorizedKeys) > 0 {
+			repoKeys = len(meta.AuthorizedKeys)
+			for _, k := range meta.AuthorizedKeys {
+				kFields := strings.Fields(strings.TrimSpace(k))
+				if len(kFields) >= 2 && pubFields[0] == kFields[0] && pubFields[1] == kFields[1] {
+					return true
+				}
+			}
+		}
+	}
+
+	// Check user-level SSH keys.
+	if s.users.FindUserByKey(pubFields) != "" {
+		return true
+	}
+
+	// If no authorization is configured anywhere, allow (backward compat).
+	if repoKeys == 0 && !s.users.HasAnyKeys() {
+		return true
+	}
+
+	slog.Warn("ssh push rejected", "repo", filepath.Base(repoPath))
+	return false
+}
+
 func loadOrGenerateHostKey(path string) (ssh.Signer, error) {
 	if path == "" {
 		path = "fierj_host_key"
diff --git a/templates/auth_setup.html b/templates/auth_setup.html
index 43e8b00..84e9e37 100644
--- a/templates/auth_setup.html
+++ b/templates/auth_setup.html
@@ -26,6 +26,11 @@
       <label for="confirm">Confirm password</label>
       <input type="password" id="confirm" name="confirm" required minlength="6">
     </div>
+    <div class="form-group">
+      <label for="ssh_key">SSH public key (optional — enables git push)</label>
+      <textarea id="ssh_key" name="ssh_key" rows="3" placeholder="ssh-ed25519 AAAAC3..."></textarea>
+      <span class="field-hint">Paste your public key to push via SSH without per-repo setup.</span>
+    </div>
     <button type="submit" class="btn">Create account</button>
   </form>
 </div>
diff --git a/templates/auth_users.html b/templates/auth_users.html
index c3392fd..8fa0196 100644
--- a/templates/auth_users.html
+++ b/templates/auth_users.html
@@ -35,6 +35,34 @@
   </div>
 </div>
 
+<!-- SSH Keys -->
+<div class="card mt-lg">
+  <h2>Your SSH keys</h2>
+  <p class="subtitle">SSH keys allow you to push to any repository without per-repo configuration.</p>
+  {{if .SSHKeys}}
+  <div class="list-box mb-md">
+    {{range $i, $k := .SSHKeys}}
+    <div class="list-row">
+      <code style="font-size:0.8rem;word-break:break-all;">{{$k}}</code>
+      <form method="post" onsubmit="return confirm('Remove this key?')">
+        <input type="hidden" name="action" value="remove-ssh-key">
+        <input type="hidden" name="index" value="{{$i}}">
+        <button type="submit" class="btn-subtle btn-sm">Remove</button>
+      </form>
+    </div>
+    {{end}}
+  </div>
+  {{end}}
+  <form method="post">
+    <input type="hidden" name="action" value="add-ssh-key">
+    <div class="form-group">
+      <label for="ssh_key">Add SSH public key</label>
+      <textarea id="ssh_key" name="ssh_key" rows="2" placeholder="ssh-ed25519 AAAAC3..."></textarea>
+    </div>
+    <button type="submit" class="btn">Add key</button>
+  </form>
+</div>
+
 <!-- User list -->
 <div class="mt-xl">
   <h2 class="section-title">All users ({{len .Users}})</h2>