From 0c5f8b4b639e48176f1cbf78b732cb20d5abf0a4 Mon Sep 17 00:00:00 2001
From: Runxi Yu <me@runxiyu.org>
Date: Fri, 07 Mar 2025 17:10:00 +0800
Subject: [PATCH] hooks, fedauth: Add basic federated authentication for git push

---
 fedauth.go                 | 75 +++++++++++++++++++++++++++++++++++++++++++++++++++++
 git_hooks_handle.go        | 47 +++++++++++++++++++++++++++++++++++++++++++++++
 git_init.go                |  1 +
 sql/schema.sql             |  7 +++++++
 ssh_handle_receive_pack.go | 44 ++++++++++++++++++++++++++------------------
 templates/group.tmpl       |  1 +

diff --git a/fedauth.go b/fedauth.go
new file mode 100644
index 0000000000000000000000000000000000000000..3f403e77a0823df770b7f346c3ef231d63825cf0
--- /dev/null
+++ b/fedauth.go
@@ -0,0 +1,75 @@
+package main
+
+import (
+	"bufio"
+	"context"
+	"errors"
+	"io"
+	"net/http"
+	"net/url"
+	"strings"
+
+	"github.com/jackc/pgx/v5"
+)
+
+func check_and_update_federated_user_status(ctx context.Context, user_id int, service, remote_username, pubkey string) (bool, error) {
+	switch service {
+	case "sr.ht":
+		username_escaped := url.PathEscape(remote_username)
+
+		resp, err := http.Get("https://meta.sr.ht/~" + username_escaped + ".keys")
+		if err != nil {
+			return false, err
+		}
+
+		defer func() {
+			_ = resp.Body.Close()
+		}()
+		buf := bufio.NewReader(resp.Body)
+
+		matched := false
+		for {
+			line, err := buf.ReadString('\n')
+			if errors.Is(err, io.EOF) {
+				break
+			} else if err != nil {
+				return false, err
+			}
+
+			line_split := strings.Split(line, " ")
+			if len(line_split) < 2 {
+				continue
+			}
+			line = strings.Join(line_split[:2], " ")
+
+			if line == pubkey {
+				matched = true
+				break
+			}
+		}
+		if !matched {
+			return false, nil
+		}
+
+		var tx pgx.Tx
+		if tx, err = database.Begin(ctx); err != nil {
+			return false, err
+		}
+		defer func() {
+			_ = tx.Rollback(ctx)
+		}()
+		if _, err = tx.Exec(ctx, `UPDATE users SET type = 'federated' WHERE id = $1 AND type = 'pubkey_only'`, user_id); err != nil {
+			return false, err
+		}
+		if _, err = tx.Exec(ctx, `INSERT INTO federated_identities (user_id, service, remote_username) VALUES ($1, $2, $3)`, user_id, service, remote_username); err != nil {
+			return false, err
+		}
+		if err = tx.Commit(ctx); err != nil {
+			return false, err
+		}
+
+		return true, nil
+	default:
+		return false, errors.New("unknown federated service")
+	}
+}
diff --git a/git_hooks_handle.go b/git_hooks_handle.go
index 2adaf9a58ade734ef5ac9f77df95d63ed7539c88..7da6c8854677dcc7729b1c85515f67dcc2f0e2eb 100644
--- a/git_hooks_handle.go
+++ b/git_hooks_handle.go
@@ -151,6 +151,53 @@ 					var line, old_oid, rest, new_oid, ref_name string
 					var found bool
 					var old_hash, new_hash plumbing.Hash
 					var old_commit, new_commit *object.Commit
+					var git_push_option_count int
+
+					git_push_option_count, err = strconv.Atoi(git_env["GIT_PUSH_OPTION_COUNT"])
+					if err != nil {
+						wf_error(ssh_stderr, "Failed to parse GIT_PUSH_OPTION_COUNT: %v", err)
+						return 1
+					}
+
+					// TODO: Allow existing users (even if they are already federated or registered) to add a federated user ID... though perhaps this should be in the normal SSH interface instead of the git push interface?
+					// Also it'd be nice to be able to combine users or whatever
+					if pack_to_hook.contrib_requirements == "federated" && pack_to_hook.user_type != "federated" && pack_to_hook.user_type != "registered" {
+						if git_push_option_count == 0 {
+							wf_error(ssh_stderr, "This repo requires contributors to be either federated or registered users. You must supply your federated user ID as a push option. For example, git push -o fedid=sr.ht:runxiyu")
+							return 1
+						}
+						for i := 0; i < git_push_option_count; i++ {
+							push_option, ok := git_env[fmt.Sprintf("GIT_PUSH_OPTION_%d", i)]
+							if !ok {
+								wf_error(ssh_stderr, "Failed to get push option %d", i)
+								return 1
+							}
+							if strings.HasPrefix(push_option, "fedid=") {
+								federated_user_identifier := strings.TrimPrefix(push_option, "fedid=")
+								service, username, found := strings.Cut(federated_user_identifier, ":")
+								if !found {
+									wf_error(ssh_stderr, "Invalid federated user identifier %#v does not contain a colon", federated_user_identifier)
+									return 1
+								}
+
+								ok, err := check_and_update_federated_user_status(ctx, pack_to_hook.user_id, service, username, pack_to_hook.pubkey)
+								if err != nil {
+									wf_error(ssh_stderr, "Failed to verify federated user identifier %#v: %v", federated_user_identifier, err)
+									return 1
+								}
+								if !ok {
+									wf_error(ssh_stderr, "Failed to verify federated user identifier %#v: you don't seem to be on the list", federated_user_identifier)
+									return 1
+								}
+
+								break
+							}
+							if i == git_push_option_count-1 {
+								wf_error(ssh_stderr, "This repo requires contributors to be either federated or registered users. You must supply your federated user ID as a push option. For example, git push -o fedid=sr.ht:runxiyu")
+								return 1
+							}
+						}
+					}
 
 					line, err = stdin.ReadString('\n')
 					if errors.Is(err, io.EOF) {
diff --git a/git_init.go b/git_init.go
index 1e1f8c7c2258dcc3e3c75bf377fe45613b1da932..4313ee847acbf79a77d5557e34b48ac813251ee3 100644
--- a/git_init.go
+++ b/git_init.go
@@ -24,6 +24,7 @@ 		return err
 	}
 
 	git_config.Raw.SetOption("core", git_format_config.NoSubsection, "hooksPath", config.Hooks.Execs)
+	git_config.Raw.SetOption("receive", git_format_config.NoSubsection, "advertisePushOptions", "true")
 
 	if err = repo.SetConfig(git_config); err != nil {
 		return err
diff --git a/sql/schema.sql b/sql/schema.sql
index d637aa3fe4d96831e5e7f1eb9f540ce4b838c43e..1a038aecac2c7d79c327e4c04501c45ea2993a45 100644
--- a/sql/schema.sql
+++ b/sql/schema.sql
@@ -89,3 +89,10 @@ 	group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
 	user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
 	PRIMARY KEY(user_id, group_id)
 );
+
+CREATE TABLE federated_identities (
+	user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+	service TEXT NOT NULL,
+	remote_username TEXT NOT NULL,
+	PRIMARY KEY(user_id, service)
+);
diff --git a/ssh_handle_receive_pack.go b/ssh_handle_receive_pack.go
index 45610bb5c3378e22000c10d76e2a3293540b5134..b77b717f085028f9797f1ea7d0351396d98a3021 100644
--- a/ssh_handle_receive_pack.go
+++ b/ssh_handle_receive_pack.go
@@ -15,15 +15,17 @@ 	"go.lindenii.runxiyu.org/lindenii-common/cmap"
 )
 
 type pack_to_hook_t struct {
-	session       glider_ssh.Session
-	repo          *git.Repository
-	pubkey        string
-	direct_access bool
-	repo_path     string
-	user_id       int
-	repo_id       int
-	group_path    []string
-	repo_name     string
+	session              glider_ssh.Session
+	repo                 *git.Repository
+	pubkey               string
+	direct_access        bool
+	repo_path            string
+	user_id              int
+	user_type            string
+	repo_id              int
+	group_path           []string
+	repo_name            string
+	contrib_requirements string
 }
 
 var pack_to_hook_by_cookie = cmap.Map[string, pack_to_hook_t]{}
@@ -65,6 +67,8 @@ 			if user_type != "registered" {
 				return errors.New("You need to be a registered user to push to this repo.")
 			}
 		case "ssh_pubkey":
+			fallthrough
+		case "federated":
 			if pubkey == "" {
 				return errors.New("You need to have an SSH public key to push to this repo.")
 			}
@@ -74,7 +78,9 @@ 				if err != nil {
 					return err
 				}
 				fmt.Fprintln(session.Stderr(), "You are now registered as user ID", user_id)
+				user_type = "pubkey_only"
 			}
+
 		case "public":
 		default:
 			panic("unknown contrib_requirements value " + contrib_requirements)
@@ -87,15 +93,17 @@ 		fmt.Fprintln(session.Stderr(), "Error while generating cookie:", err)
 	}
 
 	pack_to_hook_by_cookie.Store(cookie, pack_to_hook_t{
-		session:       session,
-		pubkey:        pubkey,
-		direct_access: direct_access,
-		repo_path:     repo_path,
-		user_id:       user_id,
-		repo_id:       repo_id,
-		group_path:    group_path,
-		repo_name:     repo_name,
-		repo:          repo,
+		session:              session,
+		pubkey:               pubkey,
+		direct_access:        direct_access,
+		repo_path:            repo_path,
+		user_id:              user_id,
+		repo_id:              repo_id,
+		group_path:           group_path,
+		repo_name:            repo_name,
+		repo:                 repo,
+		contrib_requirements: contrib_requirements,
+		user_type:            user_type,
 	})
 	defer pack_to_hook_by_cookie.Delete(cookie)
 	// The Delete won't execute until proc.Wait returns unless something
diff --git a/templates/group.tmpl b/templates/group.tmpl
index 8343026fdcdd440d4f2d0bbb6e99ecb3822c6341..0042e0f067a7aec845f3bedb6c0cb9f51ee115ce 100644
--- a/templates/group.tmpl
+++ b/templates/group.tmpl
@@ -50,6 +50,7 @@ 								<td class="tdinput">
 									<select id="repo-contrib-input" name="repo_contrib">
 										<option value="public">Public</option>
 										<option value="ssh_pubkey">SSH public key</option>
+										<option value="federated">Federated service</option>
 										<option value="registered_user">Registered user</option>
 										<option value="closed">Closed</option>
 									</select>

-- 
2.48.1