fierj
PublicTiny personal git forge
package main
import (
"crypto"
"crypto/ed25519"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
const apContext = "https://www.w3.org/ns/activitystreams"
const apPublic = "https://www.w3.org/ns/activitystreams#Public"
const apContentType = "application/activity+json; profile=\"https://www.w3.org/ns/activitystreams\""
const jrdContentType = "application/jrd+json"
// ----- data types -----
// APActor represents an ActivityPub actor (Service type) for a repo.
type APActor struct {
Context string `json:"@context"`
ID string `json:"id"`
Type string `json:"type"`
PreferredUsername string `json:"preferredUsername"`
Name string `json:"name,omitempty"`
Summary string `json:"summary,omitempty"`
URL string `json:"url,omitempty"`
Inbox string `json:"inbox"`
Outbox string `json:"outbox"`
Followers string `json:"followers"`
PublicKey APKey `json:"publicKey"`
}
// APKey is the public key block embedded in the actor.
type APKey struct {
ID string `json:"id"`
Owner string `json:"owner"`
PublicKeyPem string `json:"publicKeyPem"`
}
// APActivity is a generic ActivityPub activity.
type APActivity struct {
Context string `json:"@context"`
ID string `json:"id,omitempty"`
Type string `json:"type"`
Actor string `json:"actor,omitempty"`
Object any `json:"object,omitempty"`
}
// APCreate wraps a Create activity.
type APCreate struct {
Context string `json:"@context"`
ID string `json:"id"`
Type string `json:"type"`
Actor string `json:"actor"`
Published string `json:"published"`
To []string `json:"to"`
CC []string `json:"cc,omitempty"`
Object *APNote `json:"object"`
}
// APNote is a Note object (used for threads/replies).
type APNote struct {
ID string `json:"id,omitempty"`
Type string `json:"type"`
Published string `json:"published,omitempty"`
AttributedTo string `json:"attributedTo,omitempty"`
Content string `json:"content,omitempty"`
URL string `json:"url,omitempty"`
InReplyTo string `json:"inReplyTo,omitempty"`
To []string `json:"to,omitempty"`
CC []string `json:"cc,omitempty"`
}
// APOrderedCollection is used for outbox and followers.
type APOrderedCollection struct {
Context string `json:"@context"`
ID string `json:"id"`
Type string `json:"type"`
TotalItems int `json:"totalItems"`
First string `json:"first,omitempty"`
OrderedItems []any `json:"orderedItems,omitempty"`
}
// APMeta holds per-repo ActivityPub state, stored in .forge/ap.json.
type APMeta struct {
PublicKey string `json:"public_key"`
PrivateKey string `json:"private_key"`
Followers []string `json:"followers,omitempty"`
}
// ----- key management -----
func apMetaPath(repoPath string) string {
return filepath.Join(repoPath, ".forge", "ap.json")
}
var apMetaLocks sync.Map
func loadOrCreateAPMeta(repoPath string) (*APMeta, error) {
mu := &sync.Mutex{}
actual, _ := apMetaLocks.LoadOrStore(repoPath, mu)
mu = actual.(*sync.Mutex)
mu.Lock()
defer mu.Unlock()
dir := filepath.Dir(apMetaPath(repoPath))
os.MkdirAll(dir, 0755)
data, err := os.ReadFile(apMetaPath(repoPath))
if err == nil {
var meta APMeta
if json.Unmarshal(data, &meta) == nil && meta.PrivateKey != "" {
return &meta, nil
}
}
// Generate an RSA 2048-bit keypair (the de facto AP standard).
rsaKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return nil, fmt.Errorf("generate key: %w", err)
}
pubBytes, err := x509.MarshalPKIXPublicKey(&rsaKey.PublicKey)
if err != nil {
return nil, fmt.Errorf("marshal pub: %w", err)
}
pubPEM := pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubBytes})
privBytes, err := x509.MarshalPKCS8PrivateKey(rsaKey)
if err != nil {
return nil, fmt.Errorf("marshal priv: %w", err)
}
privPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: privBytes})
meta := &APMeta{
PublicKey: string(pubPEM),
PrivateKey: string(privPEM),
Followers: nil,
}
b, _ := json.MarshalIndent(meta, "", " ")
tmp := apMetaPath(repoPath) + ".tmp"
os.WriteFile(tmp, b, 0600)
os.Rename(tmp, apMetaPath(repoPath))
return meta, nil
}
func parseEd25519Private(pemData string) (ed25519.PrivateKey, error) {
priv, err := parsePrivateKey(pemData)
if err != nil {
return nil, err
}
ed, ok := priv.(ed25519.PrivateKey)
if !ok {
return nil, fmt.Errorf("not ed25519")
}
return ed, nil
}
// parsePrivateKey parses a PEM-encoded private key, returning either
// *rsa.PrivateKey or ed25519.PrivateKey.
func parsePrivateKey(pemData string) (crypto.PrivateKey, error) {
block, _ := pem.Decode([]byte(pemData))
if block == nil {
return nil, fmt.Errorf("bad PEM")
}
priv, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
return nil, err
}
switch priv.(type) {
case *rsa.PrivateKey, ed25519.PrivateKey:
return priv, nil
default:
return nil, fmt.Errorf("unsupported key type: %T", priv)
}
}
// parsePublicKey parses a PEM-encoded public key, returning either an
// *rsa.PublicKey or ed25519.PublicKey.
func parsePublicKey(pemData string) (crypto.PublicKey, error) {
block, _ := pem.Decode([]byte(pemData))
if block == nil {
return nil, fmt.Errorf("bad PEM")
}
pub, err := x509.ParsePKIXPublicKey(block.Bytes)
if err != nil {
return nil, err
}
switch pub.(type) {
case *rsa.PublicKey, ed25519.PublicKey:
return pub, nil
default:
return nil, fmt.Errorf("unsupported key type: %T", pub)
}
}
func parseEd25519Public(pemData string) (ed25519.PublicKey, error) {
pub, err := parsePublicKey(pemData)
if err != nil {
return nil, err
}
ed, ok := pub.(ed25519.PublicKey)
if !ok {
return nil, fmt.Errorf("not ed25519")
}
return ed, nil
}
// ----- URL helpers -----
func actorURL(host, repoName string) string {
return "https://" + host + "/" + repoName + "/ap/actor"
}
func inboxURL(host, repoName string) string {
return "https://" + host + "/" + repoName + "/ap/inbox"
}
func outboxURL(host, repoName string) string {
return "https://" + host + "/" + repoName + "/ap/outbox"
}
func followersURL(host, repoName string) string {
return "https://" + host + "/" + repoName + "/ap/followers"
}
// ----- WebFinger -----
type jrd struct {
Subject string `json:"subject"`
Links []jrdLink `json:"links"`
}
type jrdLink struct {
Rel string `json:"rel"`
Type string `json:"type,omitempty"`
Href string `json:"href,omitempty"`
}
func WebFinger(cfg Config) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if cfg.Host == "" {
http.NotFound(w, r)
return
}
resource := r.URL.Query().Get("resource")
if resource == "" || !strings.HasPrefix(resource, "acct:") {
http.NotFound(w, r)
return
}
acct := strings.TrimPrefix(resource, "acct:")
// Format: repo@host
parts := strings.SplitN(acct, "@", 2)
// Accept if the acct host matches our configured host, or if our
// host isn't set (development), accept the request's Host header.
if len(parts) != 2 {
http.NotFound(w, r)
return
}
acctHost := parts[1]
if acctHost != cfg.Host {
// Also accept if the acct uses the request's Host header
// (common behind reverse proxies).
if acctHost != r.Host && !strings.HasPrefix(r.Host, acctHost) {
http.NotFound(w, r)
return
}
}
repoName := parts[0]
// Verify repo exists.
repoPath := filepath.Join(cfg.Dir, repoName+".git")
if _, err := os.Stat(repoPath); os.IsNotExist(err) {
http.NotFound(w, r)
return
}
result := jrd{
Subject: resource,
Links: []jrdLink{{
Rel: "self",
Type: "application/activity+json",
Href: actorURL(cfg.Host, repoName),
}},
}
w.Header().Set("Content-Type", jrdContentType)
json.NewEncoder(w).Encode(result)
}
}
// ----- Actor -----
func APActorHandler(cfg Config) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if cfg.Host == "" {
http.NotFound(w, r)
return
}
repoName := r.PathValue("repo")
repoPath := repoPath(cfg, repoName)
meta, err := loadOrCreateAPMeta(repoPath)
if err != nil {
slog.Error("ap actor: load meta", "repo", repoName, "error", err)
http.Error(w, "internal error", 500)
return
}
actorID := actorURL(cfg.Host, repoName)
git := &Git{Dir: cfg.Dir, Name: repoName}
actor := APActor{
Context: apContext,
ID: actorID,
Type: "Service",
PreferredUsername: repoName,
Name: repoName,
Summary: git.Description(),
URL: "https://" + cfg.Host + "/" + repoName,
Inbox: inboxURL(cfg.Host, repoName),
Outbox: outboxURL(cfg.Host, repoName),
Followers: followersURL(cfg.Host, repoName),
PublicKey: APKey{
ID: actorID + "#main-key",
Owner: actorID,
PublicKeyPem: meta.PublicKey,
},
}
w.Header().Set("Content-Type", apContentType)
json.NewEncoder(w).Encode(actor)
}
}
// ----- Inbox -----
func APInbox(cfg Config) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if cfg.Host == "" {
http.NotFound(w, r)
return
}
repoName := r.PathValue("repo")
repoPath := repoPath(cfg, repoName)
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "bad request", 400)
return
}
defer r.Body.Close()
// Parse the activity first so we can use its actor field as a
// fallback for public key discovery.
var raw map[string]any
if err := json.Unmarshal(body, &raw); err != nil {
http.Error(w, "bad json", 400)
return
}
activityActor, _ := raw["actor"].(string)
// Verify HTTP signature. If verification fails, reject the activity.
sigHeader := r.Header.Get("Signature")
if sigHeader == "" {
http.Error(w, "missing signature", 401)
return
}
// Try RFC 9421 format first (sig1= in Signature + Signature-Input).
if sigInput := r.Header.Get("Signature-Input"); sigInput != "" && strings.Contains(sigHeader, "sig1=") {
if err := verifyRFC9421(sigHeader, sigInput, activityActor, r, body); err != nil {
slog.Warn("ap inbox: rfc9421 verify", "error", err)
http.Error(w, "invalid signature", 401)
return
}
} else {
// Legacy draft-cavage format.
sig, err := parseSignature(sigHeader)
if err != nil {
slog.Warn("ap inbox: bad signature header", "error", err)
http.Error(w, "invalid signature", 401)
return
}
pubKey, err := fetchPublicKeyFallback(sig.KeyID, activityActor)
if err != nil {
slog.Warn("ap inbox: fetch key", "keyId", sig.KeyID, "error", err)
http.Error(w, "cannot verify signature", 401)
return
}
if err := verifySignature(sig, pubKey, r, body); err != nil {
slog.Warn("ap inbox: signature verify", "error", err)
http.Error(w, "invalid signature", 401)
return
}
}
activityType, _ := raw["type"].(string)
actor, _ := raw["actor"].(string)
switch activityType {
case "Create":
obj, ok := raw["object"].(map[string]any)
if !ok {
http.Error(w, "bad object", 400)
return
}
objType, _ := obj["type"].(string)
if objType != "Note" {
http.Error(w, "unsupported object type", 400)
return
}
handleCreateNote(cfg, repoName, actor, obj)
case "Follow":
if actor != "" {
addFollower(repoPath, actor)
slog.Info("ap: new follower", "repo", repoName, "actor", actor)
// Deliver Accept to the follower's inbox (required by AP spec).
go sendAccept(cfg, repoName, actor, raw)
// Return 200 immediately; Mastodon considers the follow
// accepted when it receives the Accept in its inbox.
w.WriteHeader(200)
return
}
case "Undo":
obj, ok := raw["object"].(map[string]any)
if ok {
undoType, _ := obj["type"].(string)
if undoType == "Follow" {
undoActor, _ := obj["actor"].(string)
if undoActor != "" {
removeFollower(repoPath, undoActor)
slog.Info("ap: removed follower", "repo", repoName, "actor", undoActor)
}
}
}
default:
slog.Info("ap inbox: unhandled activity", "type", activityType)
}
w.WriteHeader(200)
}
}
// handleCreateNote processes an incoming Create/Note activity, creating a
// thread or reply in the target repo.
func handleCreateNote(cfg Config, repoName, actor string, obj map[string]any) {
content, _ := obj["content"].(string)
contentPreview := content
if len(contentPreview) > 200 {
contentPreview = contentPreview[:200]
}
slog.Info("ap inbox: create note", "actor", actor, "contentLen", len(content), "inReplyTo", obj["inReplyTo"], "content", contentPreview)
if content == "" {
return
}
// Content from Mastodon is HTML. Keep it for the body (rendered
// client-side), but strip tags for the title.
summary, _ := obj["summary"].(string)
rp := repoPath(cfg, repoName)
// Check if this is a reply to a fierj thread or reply. inReplyTo must be
// a fierj thread URL like https://host/repo/threads/<id>[#reply-<rid>].
inReplyTo, _ := obj["inReplyTo"].(string)
if inReplyTo != "" {
if idx := strings.Index(inReplyTo, "/threads/"); idx >= 0 {
rest := inReplyTo[idx+len("/threads/"):]
// Extract thread ID and optional parent reply ID from fragment.
threadID := rest
parentID := ""
if hash := strings.Index(rest, "#"); hash >= 0 {
threadID = rest[:hash]
frag := rest[hash+1:]
if strings.HasPrefix(frag, "reply-") {
parentID = strings.TrimPrefix(frag, "reply-")
}
}
if slash := strings.Index(threadID, "/"); slash >= 0 {
threadID = threadID[:slash]
}
if err := addNestedReply(rp, threadID, parentID, content, actor, actor); err != nil {
slog.Warn("ap inbox: reply failed", "thread", threadID, "error", err)
return
}
slog.Info("ap inbox: reply added", "repo", repoName, "thread", threadID, "parent", parentID)
return
}
// Not a fierj thread URL — treat as a new thread.
}
// Create a new thread. Use summary (Content Warning) as title when
// present, otherwise strip HTML from the first line of content.
title := summary
if title == "" {
title = stripHTML(content)
if idx := strings.Index(title, "\n"); idx >= 0 {
title = strings.TrimSpace(title[:idx])
}
if len(title) > 120 {
title = title[:120] + "…"
}
}
t, err := createThread(rp, title, content, actor, actor)
if err != nil {
slog.Warn("ap inbox: create thread failed", "error", err)
return
}
slog.Info("ap inbox: thread created", "repo", repoName, "thread", t.ID)
}
// stripHTML removes HTML tags from a string (simple approach for titles).
func stripHTML(s string) string {
var out strings.Builder
inTag := false
for _, r := range s {
if r == '<' {
inTag = true
} else if r == '>' {
inTag = false
} else if !inTag {
out.WriteRune(r)
}
}
return strings.TrimSpace(out.String())
}
// ----- Outbox -----
func APOutbox(cfg Config) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if cfg.Host == "" {
http.NotFound(w, r)
return
}
repoName := r.PathValue("repo")
rp := repoPath(cfg, repoName)
actor := actorURL(cfg.Host, repoName)
// Gather activities: thread creates from threads dir.
var items []any
threads, _, _ := listThreadsFiltered(rp, "")
for _, t := range threads {
threadURL := "https://" + cfg.Host + "/" + repoName + "/threads/" + t.ID
note := APNote{
ID: threadURL,
Type: "Note",
Published: t.Created,
AttributedTo: t.Author,
Content: t.Title + "\n\n" + t.Body,
URL: threadURL,
To: []string{apPublic},
CC: []string{followersURL(cfg.Host, repoName)},
}
items = append(items, map[string]any{
"id": threadURL + "/activity",
"type": "Create",
"actor": actor,
"published": t.Created,
"to": []string{apPublic},
"cc": []string{followersURL(cfg.Host, repoName)},
"object": note,
})
}
coll := APOrderedCollection{
Context: apContext,
ID: outboxURL(cfg.Host, repoName),
Type: "OrderedCollection",
TotalItems: len(items),
OrderedItems: items,
}
w.Header().Set("Content-Type", apContentType)
json.NewEncoder(w).Encode(coll)
}
}
// ----- Followers -----
func APFollowers(cfg Config) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if cfg.Host == "" {
http.NotFound(w, r)
return
}
repoName := r.PathValue("repo")
repoPath := repoPath(cfg, repoName)
meta, err := loadOrCreateAPMeta(repoPath)
if err != nil {
slog.Error("ap followers: load meta", "repo", repoName, "error", err)
http.Error(w, "internal error", 500)
return
}
items := make([]any, len(meta.Followers))
for i, f := range meta.Followers {
items[i] = f
}
coll := APOrderedCollection{
Context: apContext,
ID: followersURL(cfg.Host, repoName),
Type: "OrderedCollection",
TotalItems: len(items),
OrderedItems: items,
}
w.Header().Set("Content-Type", apContentType)
json.NewEncoder(w).Encode(coll)
}
}
var followerLocks sync.Map
func addFollower(repoPath, followerURL string) error {
mu := &sync.Mutex{}
actual, _ := followerLocks.LoadOrStore(repoPath, mu)
mu = actual.(*sync.Mutex)
mu.Lock()
defer mu.Unlock()
meta, err := loadOrCreateAPMeta(repoPath)
if err != nil {
return err
}
for _, f := range meta.Followers {
if f == followerURL {
return nil // already exists
}
}
meta.Followers = append(meta.Followers, followerURL)
return saveAPMeta(repoPath, meta)
}
func removeFollower(repoPath, followerURL string) error {
mu := &sync.Mutex{}
actual, _ := followerLocks.LoadOrStore(repoPath, mu)
mu = actual.(*sync.Mutex)
mu.Lock()
defer mu.Unlock()
meta, err := loadOrCreateAPMeta(repoPath)
if err != nil {
return err
}
filtered := make([]string, 0, len(meta.Followers))
for _, f := range meta.Followers {
if f != followerURL {
filtered = append(filtered, f)
}
}
meta.Followers = filtered
return saveAPMeta(repoPath, meta)
}
func saveAPMeta(repoPath string, meta *APMeta) error {
b, err := json.MarshalIndent(meta, "", " ")
if err != nil {
return err
}
tmp := apMetaPath(repoPath) + ".tmp"
if err := os.WriteFile(tmp, b, 0600); err != nil {
return err
}
return os.Rename(tmp, apMetaPath(repoPath))
}
// ----- HTTP Signatures -----
// httpSignature represents a parsed HTTP Signature header.
type httpSignature struct {
KeyID string
Algorithm string
Headers []string
Signature []byte
}
// parseSignature parses an HTTP Signature header value.
// Format: keyId="...",algorithm="...",headers="...",signature="..."
func parseSignature(header string) (*httpSignature, error) {
s := &httpSignature{}
pairs := strings.Split(header, ",")
for _, pair := range pairs {
kv := strings.SplitN(strings.TrimSpace(pair), "=", 2)
if len(kv) != 2 {
continue
}
key := kv[0]
val := strings.Trim(kv[1], `"`)
switch key {
case "keyId":
s.KeyID = val
case "algorithm":
s.Algorithm = val
case "headers":
s.Headers = strings.FieldsFunc(val, func(r rune) bool { return r == ' ' })
case "signature":
sig, err := base64.StdEncoding.DecodeString(val)
if err != nil {
return nil, fmt.Errorf("bad signature base64: %w", err)
}
s.Signature = sig
}
}
if s.KeyID == "" || len(s.Signature) == 0 {
return nil, fmt.Errorf("incomplete signature")
}
if s.Algorithm == "" {
s.Algorithm = "rsa-sha256" // default
}
return s, nil
}
// fetchPublicKey fetches an actor's public key from the remote server.
// Returns either *rsa.PublicKey or ed25519.PublicKey.
func fetchPublicKey(keyID string) (crypto.PublicKey, error) {
return fetchPublicKeyFallback(keyID, "")
}
// fetchPublicKeyFallback tries the actor URL first (more stable than keyID
// which can use Mastodon's newer /ap/users/<id> format that returns 403/410),
// then falls back to the keyID URL.
func fetchPublicKeyFallback(keyID, actorURL string) (crypto.PublicKey, error) {
if actorURL != "" {
key, err := fetchPublicKeyFromURL(actorURL)
if err == nil {
return key, nil
}
slog.Info("ap: actor fetch failed, trying keyID", "actor", actorURL, "keyId", keyID, "error", err)
}
if keyID != "" {
return fetchPublicKeyFromURL(keyID)
}
return nil, fmt.Errorf("no key source")
}
func fetchPublicKeyFromURL(url string) (crypto.PublicKey, error) {
// Strip fragment for HTTP request; the fragment identifies the key within
// the actor document, not a separate URL.
reqURL := url
if idx := strings.Index(reqURL, "#"); idx >= 0 {
reqURL = reqURL[:idx]
}
req, err := http.NewRequest("GET", reqURL, nil)
if err != nil {
return nil, fmt.Errorf("fetch key: %w", err)
}
req.Header.Set("Accept", apContentType)
req.Header.Set("User-Agent", "fierj/0.1 (+https://git.zserge.com)")
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("fetch key: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("fetch key: status %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var actor struct {
PublicKey struct {
PublicKeyPem string `json:"publicKeyPem"`
} `json:"publicKey"`
}
if err := json.Unmarshal(body, &actor); err != nil {
return nil, fmt.Errorf("parse actor: %w", err)
}
return parsePublicKey(actor.PublicKey.PublicKeyPem)
}
// verifyRFC9421 verifies an RFC 9421 HTTP Message Signature.
// sigHeader is the Signature header (sig1=:BASE64URL:).
// sigInput is the Signature-Input header listing covered components.
func verifyRFC9421(sigHeader, sigInput, actorURL string, r *http.Request, body []byte) error {
// Parse Signature-Input: sig1=("@method" "@target-uri" "content-digest");created=...;keyid="..."
// Find the parenthesized list of covered components.
open := strings.Index(sigInput, "(")
close := strings.Index(sigInput, ")")
if open < 0 || close < 0 || close < open {
return fmt.Errorf("bad Signature-Input format")
}
componentsStr := sigInput[open+1 : close]
components := strings.Fields(strings.ReplaceAll(componentsStr, "\"", ""))
// Extract created and keyid params.
created := ""
keyID := ""
params := sigInput[close+1:]
for _, p := range strings.Split(params, ";") {
p = strings.TrimSpace(p)
if strings.HasPrefix(p, "created=") {
created = strings.Trim(p[len("created="):], `"`)
}
if strings.HasPrefix(p, "keyid=") {
keyID = strings.Trim(p[len("keyid="):], `"`)
}
}
// Build the signing string components.
var lines []string
for _, comp := range components {
switch comp {
case "@method":
lines = append(lines, `"@method": `+r.Method)
case "@target-uri":
targetURI := "https://" + r.Host + r.URL.Path
lines = append(lines, `"@target-uri": `+targetURI)
case "content-digest":
cd := r.Header.Get("Content-Digest")
if cd == "" {
return fmt.Errorf("missing Content-Digest header")
}
lines = append(lines, `"content-digest": `+cd)
case "host":
lines = append(lines, `"host": `+r.Host)
case "date":
lines = append(lines, `"date": `+r.Header.Get("Date"))
default:
// Generic header field.
v := r.Header.Get(comp)
if v == "" {
return fmt.Errorf("missing header %q", comp)
}
lines = append(lines, `"`+comp+`": `+v)
}
}
// Build @signature-params line.
paramsLine := "("
for i, comp := range components {
if i > 0 {
paramsLine += " "
}
paramsLine += `"` + comp + `"`
}
paramsLine += ")"
if created != "" {
paramsLine += ";created=" + created
}
if keyID != "" {
paramsLine += ";keyid=\"" + keyID + "\""
}
lines = append(lines, `"@signature-params": `+paramsLine)
signingString := strings.Join(lines, "\n")
// Extract the signature from sigHeader: sig1=:BASE64URL:
sigStart := strings.Index(sigHeader, "sig1=:")
if sigStart < 0 {
return fmt.Errorf("no sig1 in Signature header")
}
sigStart += len("sig1=:")
sigEnd := strings.Index(sigHeader[sigStart:], ":")
if sigEnd < 0 {
return fmt.Errorf("bad sig1 format")
}
sigB64 := sigHeader[sigStart : sigStart+sigEnd]
sig, err := base64.StdEncoding.DecodeString(sigB64)
if err != nil {
// Fallback: URL-safe encoding.
sig, err = base64.RawURLEncoding.DecodeString(sigB64)
if err != nil {
return fmt.Errorf("decode sig1: %w", err)
}
}
// Fetch the public key.
pubKey, err := fetchPublicKeyFallback(keyID, actorURL)
if err != nil {
return fmt.Errorf("fetch key: %w", err)
}
// Verify.
hash := sha256.Sum256([]byte(signingString))
switch k := pubKey.(type) {
case ed25519.PublicKey:
if !ed25519.Verify(k, hash[:], sig) {
return fmt.Errorf("ed25519 signature mismatch")
}
case *rsa.PublicKey:
if err := rsa.VerifyPKCS1v15(k, crypto.SHA256, hash[:], sig); err != nil {
return fmt.Errorf("rsa signature mismatch: %w", err)
}
default:
return fmt.Errorf("unsupported key type: %T", pubKey)
}
return nil
}
// verifySignature checks the HTTP signature against the request.
// pub may be *rsa.PublicKey or ed25519.PublicKey.
func verifySignature(sig *httpSignature, pub crypto.PublicKey, r *http.Request, body []byte) error {
// Build the signing string from the listed headers.
var parts []string
for _, h := range sig.Headers {
if h == "(request-target)" {
target := strings.ToLower(r.Method) + " " + r.URL.Path
parts = append(parts, "(request-target): "+target)
} else if h == "host" {
parts = append(parts, "host: "+r.Host)
} else if h == "digest" {
parts = append(parts, "digest: "+r.Header.Get("Digest"))
} else {
parts = append(parts, h+": "+r.Header.Get(h))
}
}
signingString := strings.Join(parts, "\n")
// Hash the signing string.
hash := sha256.Sum256([]byte(signingString))
switch k := pub.(type) {
case ed25519.PublicKey:
if !ed25519.Verify(k, hash[:], sig.Signature) {
return fmt.Errorf("ed25519 signature mismatch")
}
case *rsa.PublicKey:
if err := rsa.VerifyPKCS1v15(k, crypto.SHA256, hash[:], sig.Signature); err != nil {
return fmt.Errorf("rsa signature mismatch: %w", err)
}
default:
return fmt.Errorf("unsupported key type: %T", pub)
}
return nil
}
// signRequest adds an HTTP Signature header to an outgoing request.
func signRequest(req *http.Request, body []byte, keyID string, priv crypto.PrivateKey) error {
// Compute digest of body.
digestHash := sha256.Sum256(body)
digest := "SHA-256=" + base64.StdEncoding.EncodeToString(digestHash[:])
req.Header.Set("Digest", digest)
date := time.Now().UTC().Format(http.TimeFormat)
req.Header.Set("Date", date)
// Build signing string: (request-target), host, date, digest
target := strings.ToLower(req.Method) + " " + req.URL.Path
signingString := fmt.Sprintf("(request-target): %s\nhost: %s\ndate: %s\ndigest: %s",
target, req.URL.Host, date, digest)
hash := sha256.Sum256([]byte(signingString))
var sig []byte
var algo string
switch k := priv.(type) {
case ed25519.PrivateKey:
sig = ed25519.Sign(k, hash[:])
algo = "ed25519-sha256"
case *rsa.PrivateKey:
var err error
sig, err = rsa.SignPKCS1v15(rand.Reader, k, crypto.SHA256, hash[:])
if err != nil {
return fmt.Errorf("rsa sign: %w", err)
}
algo = "rsa-sha256"
default:
return fmt.Errorf("unsupported private key type: %T", priv)
}
sigHeader := fmt.Sprintf(
`keyId="%s",algorithm="%s",headers="(request-target) host date digest",signature="%s"`,
keyID, algo, base64.StdEncoding.EncodeToString(sig),
)
req.Header.Set("Signature", sigHeader)
return nil
}
// ----- Delivery to followers -----
var httpClient = &http.Client{Timeout: 10 * time.Second}
func deliverThreadCreate(cfg Config, repoName string, t *Thread) {
if cfg.Host == "" {
return
}
actor := actorURL(cfg.Host, repoName)
threadURL := "https://" + cfg.Host + "/" + repoName + "/threads/" + t.ID
note := APNote{
ID: threadURL,
Type: "Note",
Published: t.Created,
AttributedTo: actor,
Content: t.Title + "\n\n" + t.Body,
URL: threadURL,
To: []string{apPublic},
CC: []string{followersURL(cfg.Host, repoName)},
}
create := map[string]any{
"@context": apContext,
"id": threadURL + "/activity",
"type": "Create",
"actor": actor,
"published": t.Created,
"to": []string{apPublic},
"cc": []string{followersURL(cfg.Host, repoName)},
"object": note,
}
deliverToFollowers(cfg, repoName, create)
}
func deliverPatchCreate(cfg Config, repoName string, p *Patch) {
if cfg.Host == "" {
return
}
actor := actorURL(cfg.Host, repoName)
patchURL := "https://" + cfg.Host + "/" + repoName + "/patches/" + p.ID
note := APNote{
ID: patchURL,
Type: "Note",
Published: p.Created,
AttributedTo: actor,
Content: p.Title + "\n\n" + p.Body,
URL: patchURL,
To: []string{apPublic},
CC: []string{followersURL(cfg.Host, repoName)},
}
create := map[string]any{
"@context": apContext,
"id": patchURL + "/activity",
"type": "Create",
"actor": actor,
"published": p.Created,
"to": []string{apPublic},
"cc": []string{followersURL(cfg.Host, repoName)},
"object": note,
}
deliverToFollowers(cfg, repoName, create)
}
func deliverReplyCreate(cfg Config, repoName string, t *Thread) {
if cfg.Host == "" || len(t.Replies) == 0 {
return
}
actor := actorURL(cfg.Host, repoName)
last := t.Replies[len(t.Replies)-1]
threadURL := "https://" + cfg.Host + "/" + repoName + "/threads/" + t.ID
// Determine the inReplyTo target: parent reply if nested, else thread root.
inReplyTo := threadURL
if last.ParentID != "" {
inReplyTo = threadURL + "#reply-" + last.ParentID
}
note := APNote{
ID: threadURL + "#reply-" + last.ID,
Type: "Note",
Published: last.Created,
AttributedTo: actor,
Content: last.Body,
URL: threadURL + "#reply-" + last.ID,
InReplyTo: inReplyTo,
To: []string{apPublic},
CC: []string{followersURL(cfg.Host, repoName)},
}
create := map[string]any{
"@context": apContext,
"id": threadURL + "#reply-" + last.ID + "/activity",
"type": "Create",
"actor": actor,
"published": last.Created,
"to": []string{apPublic},
"cc": []string{followersURL(cfg.Host, repoName)},
"object": note,
}
deliverToFollowers(cfg, repoName, create)
}
func deliverToFollowers(cfg Config, repoName string, activity map[string]any) {
rp := repoPath(cfg, repoName)
meta, err := loadOrCreateAPMeta(rp)
if err != nil {
slog.Warn("ap deliver: load meta", "repo", repoName, "error", err)
return
}
if len(meta.Followers) == 0 {
slog.Info("ap deliver: no followers", "repo", repoName)
return
}
slog.Info("ap deliver: starting", "repo", repoName, "followers", len(meta.Followers))
priv, err := parsePrivateKey(meta.PrivateKey)
if err != nil {
slog.Warn("ap deliver: parse key", "repo", repoName, "error", err)
return
}
body, err := json.Marshal(activity)
if err != nil {
slog.Warn("ap deliver: marshal", "error", err)
return
}
for _, follower := range meta.Followers {
go func(followerURL string) {
// Resolve inbox from follower's actor.
inbox := followerURL
if !strings.Contains(followerURL, "/inbox") {
resolved, err := resolveInbox(followerURL)
if err != nil {
slog.Warn("ap deliver: resolve inbox", "actor", followerURL, "error", err)
return
}
inbox = resolved
}
req, err := http.NewRequest("POST", inbox, strings.NewReader(string(body)))
if err != nil {
return
}
req.Header.Set("Content-Type", "application/activity+json")
keyID := actorURL(cfg.Host, repoName) + "#main-key"
if err := signRequest(req, body, keyID, priv); err != nil {
slog.Warn("ap deliver: sign", "error", err)
return
}
resp, err := httpClient.Do(req)
if err != nil {
slog.Warn("ap deliver: post", "inbox", inbox, "error", err)
return
}
resp.Body.Close()
slog.Info("ap deliver: delivered", "to", followerURL, "status", resp.StatusCode)
}(follower)
}
}
// sendAccept delivers an Accept activity to a follower's inbox.
// Called asynchronously after a Follow is received.
func sendAccept(cfg Config, repoName, actor string, followActivity map[string]any) {
accept := map[string]any{
"@context": apContext,
"type": "Accept",
"actor": actorURL(cfg.Host, repoName),
"object": followActivity,
}
// Use the same delivery mechanism as thread/patch delivery.
deliverActivityToActor(cfg, repoName, actor, accept)
}
// deliverActivityToActor resolves an actor's inbox and delivers a signed activity.
func deliverActivityToActor(cfg Config, repoName, actor string, activity map[string]any) {
rp := repoPath(cfg, repoName)
meta, err := loadOrCreateAPMeta(rp)
if err != nil {
slog.Warn("ap deliver: load meta", "repo", repoName, "error", err)
return
}
priv, err := parsePrivateKey(meta.PrivateKey)
if err != nil {
slog.Warn("ap deliver: parse key", "repo", repoName, "error", err)
return
}
inbox, err := resolveInbox(actor)
if err != nil {
slog.Warn("ap deliver: resolve inbox", "actor", actor, "error", err)
return
}
body, err := json.Marshal(activity)
if err != nil {
slog.Warn("ap deliver: marshal", "error", err)
return
}
req, err := http.NewRequest("POST", inbox, strings.NewReader(string(body)))
if err != nil {
return
}
req.Header.Set("Content-Type", "application/activity+json")
keyID := actorURL(cfg.Host, repoName) + "#main-key"
if err := signRequest(req, body, keyID, priv); err != nil {
slog.Warn("ap deliver: sign", "error", err)
return
}
resp, err := httpClient.Do(req)
if err != nil {
slog.Warn("ap deliver: post", "inbox", inbox, "error", err)
return
}
resp.Body.Close()
slog.Info("ap deliver: delivered", "to", actor, "status", resp.StatusCode)
}
// resolveInbox fetches an actor and returns its inbox URL.
func resolveInbox(actorURL string) (string, error) {
req, err := http.NewRequest("GET", actorURL, nil)
if err != nil {
return "", err
}
req.Header.Set("Accept", apContentType)
req.Header.Set("User-Agent", "fierj/0.1 (+https://git.zserge.com)")
resp, err := httpClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
var actor struct {
Inbox string `json:"inbox"`
}
if err := json.NewDecoder(resp.Body).Decode(&actor); err != nil {
return "", err
}
if actor.Inbox == "" {
return "", fmt.Errorf("no inbox in actor")
}
return actor.Inbox, nil
}