This commit is contained in:
2025-09-22 20:49:05 +02:00
parent 7736b8affc
commit fd4bc48bd1
13 changed files with 852 additions and 1 deletions

View File

@@ -1,2 +1 @@
# go-pgp-server # go-pgp-server

6
assets/css/bootstrap.min.css vendored Normal file

File diff suppressed because one or more lines are too long

1
assets/htmx.min.js vendored Normal file

File diff suppressed because one or more lines are too long

6
assets/js/bootstrap.bundle.min.js vendored Normal file

File diff suppressed because one or more lines are too long

18
data/index.json Normal file
View File

@@ -0,0 +1,18 @@
[
{
"id": "max_at_send.nrw--D88C7FA9A544ECF8BCCEC6EB8F0B3E5851F2C8CC",
"name": "max",
"email": "max@send.nrw",
"fingerprint": "D88C7FA9A544ECF8BCCEC6EB8F0B3E5851F2C8CC",
"filename": "public.asc",
"created_at": "2025-09-22T20:48:08.8860389+02:00"
},
{
"id": "jan.bergner_at_gmail.com--77588003EACB3CFF76B3C1B1A1E557B03E42CC77",
"name": "Jan Bergner",
"email": "jan.bergner@gmail.com",
"fingerprint": "77588003EACB3CFF76B3C1B1A1E557B03E42CC77",
"filename": "0x3E42CC77-pub.asc",
"created_at": "2025-09-22T20:02:50.1116136+02:00"
}
]

View File

@@ -0,0 +1,98 @@
-----BEGIN PGP PUBLIC KEY BLOCK-----
Version: Keybase OpenPGP v1.0.0
Comment: https://keybase.io/crypto
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
S6vU
=hcF1
-----END PGP PUBLIC KEY BLOCK-----

55
data/keys/public.asc Normal file
View File

@@ -0,0 +1,55 @@
-----BEGIN PGP PUBLIC KEY BLOCK-----
Comment: https://gopenpgp.org
Version: GopenPGP 2.9.0
xsFNBGjRma8BEACuJeyPs7T+VP3LDbJe/FyvojsRTQlGWpBthEKhDpU5VYyKg38P
Q/V+402jTqJkqBLJa+S1q7hN/PQ9It/+88MZFmh5o6rSA1lOfXV3lG/RjwNMOkl6
cgFE90wQmQHIGG+gvZcXShLItTtl8j9Y0/ag+6WIeBS5sVvs8EiYxJVWXMy/BYoR
SMVU1nrvvGM1RISqeXCX7RTxdsRgBmFjpxFtclIEqIwxLgUOgxr+HvB8TcjvB3gP
oCPLhp9k4riurISrKGxMy7USB+5+EFHSBP5ICP0CNODsWL+Yd5npeVfs8oA0d5HQ
diS9qh84pcWAbzoZaqZCj8+9B07Bt8hNqtx6PCbUGymm4Ef2rtV84jhkKDV5aqJx
M2hnrS+FzhWvaoBpDTes3GHWo3BO0InKbrqwFCp5Ds4Ekq7Tiw5vx3LmHXjgA/up
ad+km1JQ1/aMMKAqDUVIUgpdGWu8mKeB+7nbfWM9QMLABllrSH0H0f0j7Fba5N7W
LmaA5cmc46l0jsXtgYMnTJOrMPbRklk6pO9T64P85E60fX+mz8D09Zjrhu3u2tY3
FwEfyN6ZhHmKiIZXuFf++pdSu+nVa+BXpnqMWej802QOVcQgEDZEHQtWasm9EtEo
IHyLfDsC81RZYpqhcrtbFAl8/MJzLJu+U29FcjsCydca0jsyaB3NrXF3EwARAQAB
zRJtYXggPG1heEBzZW5kLm5ydz7Cwb0EEwEIAHEFgmjRma8DCwkHCRCPCz5YUfLI
zDUUAAAAAAAcABBzYWx0QG5vdGF0aW9ucy5vcGVucGdwanMub3JnhXnoDRWvzhAp
QPRcXRETYgIVCAMWAAICGQECmwMCHgEWIQTYjH+ppUTs+LzOxuuPCz5YUfLIzAAA
JUoP/0GRyu4kdLqAxCHrWaOpBzHFpZrbjrVWuhpNElEsDH3Jthhwd9vsOEQBhoTG
Fj3R/l+BuWWI6VeaPboEtMCqSaoXnYWKyzpJvfkS0EEc+yiN4HXPcai4NBLBnSJI
9JnSVaTg6+xEJhQ1kCbKviGzxcOKMn55ygBz5cECblaOHdZFzA4XB0bK6tiVwMi/
M2rhq5Nk+X4LW8El3L1QKrt3Mo1iiiqmZ4XJTevZh9w3cUiKKaIwXxIO7T9KkgLe
BFv+1jg7IemnNMfnHzeSX8JPX3odfRXsRWLfYteuO7u4awTadZjF2okH3FkhDoXF
K2Orl1q69kbHUYRYRHzoPFURZR9wbCbdZAAn9qT6Ihaiyg8taAz58Z25ldgWDIP8
ql8+vlCnxF102f1ZY8XTxLQfNsTYPfFZ4Kspq0g31GuY03IIpdS9RcyjrHXJ7NDx
JXFfjQDcKRBDbzVknpEPOLYG86BMFlgxPntOoR55KHttNzzjdGN2nW/TOcwkArwF
1xvUImIuSgeV+p6LAUDRdMXVBLVZ+1HDOcz+L/9lamZfgMq1wfma0XcxeaHJNsL2
f6BUGr/52MdOEuEQ53VRNX3g3fEZ5qHtDu8Au6avQisJdwTQ7jJdxAvXNfRWe5Cx
3n0uap//c/IkDHhRONdiY/Ph1aVCOJk6y/tGh0VKki4Bl/KZzsFNBGjRma8BEADX
iVUBaa3BxGrv6tvXRfDSl6q3JQojDH6pPKmJVT3dnyPb4XIcJ3xh0lsroeme4p01
oMNn//TNqnH1mQMf53bZtMsJ3DGfaZ6l+zRKACCnNZVuiAHzUUHua8W3bfMTkm32
iRvpI0/6Ch19znUPANdjxrANtuDqVtZB6NLr9nvQmtZDcLfSPw0xhrE/JYaLhpWM
ULMl99rJgpGivBLMwjtUaN42pd3yt/HiY0xh9WqHQUGCKSFxmbiuriDaR/65XNul
OzX0LypAqP8etvfSD90dMqOFIJ9jsjGAt9qGIvasnvS8ONvcVu/hvx3C8JUxQNfx
mi0nTR5EAUO3NXTp2wOi0tWLzRNW15NgWlEMuG6zAJPhnFPUnd3dEivbSqKEyRtO
b9kdTruIsGMFxn+QN/IGIabTIwekCpaPg0RJQqbzk22tBRS/yJOGtX7DKbC0cU4K
cVGyW2vOj/yHFQ84pEWZ/myk8+zdCWamxkFnmCBl7TdTGg5/VmQxsw2j44R2U6Ax
VkHl9SRJolGCMJYJlatvKybsuAyH0kCn6dbyxtpc/A4GFkIsMToVnsDr6hA1mPEe
lJaPRQ0mUUc0RA2BDXvmv5g62bNzNnNYlUGKvFAZotj88C9zYHKhmn4nkM9JfQIG
f6G8xlIKhMw+XZizZbi1r2MazbUZJ7TSvoFN25G/wwARAQABwsGsBBgBCABgBYJo
0ZmvCRCPCz5YUfLIzDUUAAAAAAAcABBzYWx0QG5vdGF0aW9ucy5vcGVucGdwanMu
b3JnUEPsuZ872vRsropTvyLVZQKbDBYhBNiMf6mlROz4vM7G648LPlhR8sjMAAD0
Kw/6ApWnJyDH6885a9XuP5BvwDXhLAhYtid+AgKqJI0BgC/LoseUIxed8F/W/qXs
BO0xSge3w4XDCf/eMQrJonQvGR3CJWeD7kdxnDizMRn4xYNqnpZVJpKF+Esvym+M
PxQoqG7uOTdAkpDwvb1d8nmFPf3+9LattSw6vTSTTE8yHL4RTG8xDBTp9d71LyCQ
vw0C0kHCkDVAH1AOLrUkN4qCERk9i1LCWqFY8dz+i7/yKCYcZgFH5VX5nOOEoht2
tt24XbQ8GOU7s6zhekY71UqCEr7Z5HuOmtjBtle4odT8fnogWsH3H1fIB6qNzKgz
dlOjn09ZyFV/xJv9lqMgTURFuRO28U8BOuC8LERgX3zWjoN8b/A5ni1Pq68JHrWY
xe0cErwnXINCMcAzp7vboAztZxnKfQ9WDJzVHFkgVAHqVNUw4qkrka0Cwp+m1y/H
UsTVJnNNwqwzrWfgMgDxuqXc5dYRW3CPjh7691Y1Gn+J2e7m8cRAdz2hFddI0+cl
1BblQytXfSseQ3fr/ahXjo6qDV3Y9MCjPCu+2J7zQdiDLgZzShZIOSUBISbw1dOK
FoZCrDqG3kUis06VHKlQcQsDJvdfhAgbFkymksQKE7EPDmyIuV1EqWxI/oC5ROOT
gaBFe8PcFGd8vUtDlyvUk3vDlQMcHEKAPIrzULgZud0Wl5Y=
=0v61
-----END PGP PUBLIC KEY BLOCK-----

10
go.mod Normal file
View File

@@ -0,0 +1,10 @@
module git.send.nrw/sendnrw/go-pgp-server
go 1.24.4
require (
github.com/ProtonMail/go-crypto v1.3.0 // indirect
github.com/cloudflare/circl v1.6.0 // indirect
golang.org/x/crypto v0.33.0 // indirect
golang.org/x/sys v0.30.0 // indirect
)

8
go.sum Normal file
View File

@@ -0,0 +1,8 @@
github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw=
github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE=
github.com/cloudflare/circl v1.6.0 h1:cr5JKic4HI+LkINy2lg3W2jF8sHCVTBncJr5gIIq7qk=
github.com/cloudflare/circl v1.6.0/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=

527
main.go Normal file
View File

@@ -0,0 +1,527 @@
// main.go
// Web PGP Key Server with Bootstrap UI, HTMX live search,
// WKD (Web Key Directory) + minimal HKP-compatible lookup,
// and automatic fingerprint parsing from uploaded ASCII-armored keys.
//
// Build & Run
//
// go mod init example.com/pgp-keyserver
// go get github.com/ProtonMail/go-crypto/openpgp@v0.0.0-20230828082145-5dc2f9f7b5c1
// go get github.com/ProtonMail/go-crypto/openpgp/armor@v0.0.0-20230828082145-5dc2f9f7b5c1
// go mod tidy
// go run .
//
// Notes
// - This server publishes PUBLIC keys only.
// - WKD uses z-base-32(SHA1(strings.ToLower(addr-spec))) per spec.
// - HKP here is minimal: /pks/lookup?op=get|index&search=<term> (email/fpr/substring).
// - Protect /upload behind auth in production.
package main
import (
"bytes"
"crypto/sha1"
"embed"
"encoding/hex"
"encoding/json"
"fmt"
"html/template"
"io"
"log"
"mime"
"net/http"
"net/url"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"sync"
"time"
"unicode/utf8"
"github.com/ProtonMail/go-crypto/openpgp"
)
//go:embed assets/** templates/*
var contentFS embed.FS
const (
maxUploadSize = 2 << 20 // 2 MiB
dataDir = "data"
keysDir = "data/keys"
indexFile = "data/index.json"
)
type KeyRecord struct {
ID string `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
Fingerprint string `json:"fingerprint"`
Filename string `json:"filename"`
CreatedAt time.Time `json:"created_at"`
}
type store struct {
mu sync.RWMutex
list []KeyRecord
byID map[string]KeyRecord
}
func newStore() *store { return &store{byID: make(map[string]KeyRecord)} }
// persistSnapshot schreibt eine bereits kopierte Liste auf Disk.
func (s *store) persistSnapshot(list []KeyRecord) error {
b, err := json.MarshalIndent(list, "", " ")
if err != nil {
return err
}
if err := os.MkdirAll(dataDir, 0o755); err != nil {
return err
}
tmp := indexFile + ".tmp"
if err := os.WriteFile(tmp, b, 0o644); err != nil {
return err
}
return os.Rename(tmp, indexFile)
}
func (s *store) upsert(rec KeyRecord) error {
s.mu.Lock()
// mutate
idx := -1
for i, it := range s.list {
if it.ID == rec.ID {
idx = i
break
}
}
if idx >= 0 {
s.list[idx] = rec
} else {
s.list = append(s.list, rec)
}
s.byID[rec.ID] = rec
sort.SliceStable(s.list, func(i, j int) bool {
return s.list[i].CreatedAt.After(s.list[j].CreatedAt)
})
// Snapshot erstellen, bevor wir den Lock freigeben
snap := make([]KeyRecord, len(s.list))
copy(snap, s.list)
s.mu.Unlock()
// Jetzt ohne Lock auf Disk schreiben
return s.persistSnapshot(snap)
}
func (s *store) delete(id string) error {
s.mu.Lock()
idx := -1
for i, it := range s.list {
if it.ID == id {
idx = i
break
}
}
if idx < 0 {
s.mu.Unlock()
return os.ErrNotExist
}
rec := s.list[idx]
_ = os.Remove(filepath.Join(keysDir, rec.Filename))
s.list = append(s.list[:idx], s.list[idx+1:]...)
delete(s.byID, id)
snap := make([]KeyRecord, len(s.list))
copy(snap, s.list)
s.mu.Unlock()
return s.persistSnapshot(snap)
}
// Optional: beim Start laden (unverändert, aber ohne Locks von außen aufzurufen)
func (s *store) load() error {
_ = os.MkdirAll(keysDir, 0o755)
b, err := os.ReadFile(indexFile)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
var items []KeyRecord
if err := json.Unmarshal(b, &items); err != nil {
return err
}
s.mu.Lock()
defer s.mu.Unlock()
s.list = items
s.byID = make(map[string]KeyRecord, len(items))
for _, it := range items {
s.byID[it.ID] = it
}
return nil
}
func (s *store) all() []KeyRecord {
s.mu.RLock()
defer s.mu.RUnlock()
cp := make([]KeyRecord, len(s.list))
copy(cp, s.list)
return cp
}
func (s *store) get(id string) (KeyRecord, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
it, ok := s.byID[id]
return it, ok
}
func (s *store) search(q string) []KeyRecord {
s.mu.RLock()
defer s.mu.RUnlock()
q = strings.ToLower(strings.TrimSpace(q))
if q == "" {
return append([]KeyRecord(nil), s.list...)
}
normFPR := normalizeFPR(q)
var out []KeyRecord
for _, it := range s.list {
if containsFold(it.Name, q) || containsFold(it.Email, q) || strings.Contains(strings.ToLower(it.Fingerprint), q) || normalizeFPR(it.Fingerprint) == normFPR {
out = append(out, it)
}
}
return out
}
// --- helpers ---
var safeFilenameRe = regexp.MustCompile(`[^a-zA-Z0-9_.@+-]+`)
func sanitizeFilename(name string) string {
if name == "" {
name = fmt.Sprintf("key-%d", time.Now().Unix())
}
name = safeFilenameRe.ReplaceAllString(name, "-")
if len(name) > 120 {
name = name[:120]
}
if !strings.HasSuffix(name, ".asc") {
name += ".asc"
}
return name
}
func genID(email, fingerprint string) string {
base := strings.ToLower(strings.TrimSpace(email))
fp := strings.ToUpper(strings.TrimSpace(fingerprint))
if fp == "" {
fp = fmt.Sprintf("%d", time.Now().UnixNano())
}
id := base + "--" + fp
id = strings.ReplaceAll(id, "@", "_at_")
id = safeFilenameRe.ReplaceAllString(id, "-")
return id
}
func containsFold(h, n string) bool { return strings.Contains(strings.ToLower(h), strings.ToLower(n)) }
func isASCII(s string) bool { return utf8.ValidString(s) && !strings.ContainsRune(s, '<27>') }
func normalizeFPR(s string) string {
s = strings.ToUpper(strings.TrimSpace(s))
s = strings.TrimPrefix(s, "0X")
s = strings.ReplaceAll(s, " ", "")
return s
}
// --- WKD utilities ---
var zbase32Alphabet = []byte("ybndrfg8ejkmcpqxot1uwisza345h769")
func zbase32Encode(b []byte) string {
var out []byte
var bits, val uint
for _, by := range b {
val = (val << 8) | uint(by)
bits += 8
for bits >= 5 {
idx := (val >> (bits - 5)) & 31
out = append(out, zbase32Alphabet[idx])
bits -= 5
}
}
if bits > 0 {
idx := (val << (5 - bits)) & 31
out = append(out, zbase32Alphabet[idx])
}
return string(out)
}
// wkdHash: z-base-32(SHA1(strings.ToLower(addr-spec))) + domain
func wkdHash(email string) (hash string, domain string) {
email = strings.ToLower(strings.TrimSpace(email))
parts := strings.Split(email, "@")
if len(parts) != 2 {
return "", ""
}
domain = parts[1]
s := sha1.Sum([]byte(email))
return zbase32Encode(s[:]), domain
}
func main() {
_ = os.MkdirAll(keysDir, 0o755)
_ = mime.AddExtensionType(".asc", "application/pgp-keys")
pageTmpl, err := template.ParseFS(
contentFS,
"templates/layout.html",
"templates/index.html",
"templates/rows.html", // <— hinzufügen!
)
if err != nil {
log.Fatal(err)
}
st := newStore()
if err := st.load(); err != nil {
log.Fatal(err)
}
mux := http.NewServeMux()
// Home
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
data := map[string]any{"Items": st.all()}
if err := pageTmpl.ExecuteTemplate(w, "layout", data); err != nil {
http.Error(w, err.Error(), 500)
}
})
// Live search (HTMX)
mux.HandleFunc("/search", func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query().Get("q")
items := st.search(q)
// Nutze das bereits geladene Set:
if err := pageTmpl.ExecuteTemplate(w, "rows", items); err != nil {
http.Error(w, err.Error(), 500)
}
})
// Upload with automatic fingerprint parsing
mux.HandleFunc("/upload", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if err := r.ParseMultipartForm(maxUploadSize); err != nil {
http.Error(w, "invalid form", http.StatusBadRequest)
return
}
name := strings.TrimSpace(r.FormValue("name"))
email := strings.TrimSpace(r.FormValue("email"))
userFPR := strings.TrimSpace(r.FormValue("fingerprint")) // optional override
file, hdr, err := r.FormFile("file")
if err != nil {
http.Error(w, "missing file", http.StatusBadRequest)
return
}
defer file.Close()
var buf bytes.Buffer
lr := io.LimitedReader{R: file, N: maxUploadSize}
if _, err := io.Copy(&buf, &lr); err != nil {
http.Error(w, "read error", http.StatusBadRequest)
return
}
b := buf.Bytes()
if !isASCII(string(b)) || !bytes.Contains(b, []byte("-----BEGIN PGP PUBLIC KEY BLOCK-----")) {
http.Error(w, "file must be ASCII-armored PGP public key (.asc)", http.StatusBadRequest)
return
}
// Parse fingerprint
autoFPR, parseErr := parseFingerprintFromASCII(b)
fpr := userFPR
if fpr == "" && parseErr == nil {
fpr = autoFPR
}
if fpr == "" {
http.Error(w, "could not parse fingerprint; please provide it manually", http.StatusBadRequest)
return
}
fpr = strings.ToUpper(strings.ReplaceAll(fpr, " ", ""))
base := sanitizeFilename(hdr.Filename)
if base == ".asc" || base == "" {
base = sanitizeFilename(email)
}
path := filepath.Join(keysDir, base)
if err := os.WriteFile(path, b, 0o644); err != nil {
http.Error(w, "save error", 500)
return
}
rec := KeyRecord{ID: genID(email, fpr), Name: name, Email: email, Fingerprint: fpr, Filename: base, CreatedAt: time.Now()}
if err := st.upsert(rec); err != nil {
http.Error(w, "index error", 500)
return
}
http.Redirect(w, r, "/", http.StatusSeeOther)
})
// Serve/download by ID
mux.HandleFunc("/keys/", func(w http.ResponseWriter, r *http.Request) {
id := strings.TrimPrefix(r.URL.Path, "/keys/")
rec, ok := st.get(id)
if !ok {
http.NotFound(w, r)
return
}
p := filepath.Join(keysDir, rec.Filename)
data, err := os.ReadFile(p)
if err != nil {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/pgp-keys; charset=utf-8")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", rec.Filename))
w.WriteHeader(http.StatusOK)
_, _ = w.Write(data)
})
// Inline preview
mux.HandleFunc("/view/", func(w http.ResponseWriter, r *http.Request) {
id := strings.TrimPrefix(r.URL.Path, "/view/")
rec, ok := st.get(id)
if !ok {
http.NotFound(w, r)
return
}
p := filepath.Join(keysDir, rec.Filename)
data, err := os.ReadFile(p)
if err != nil {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, "<html><head><meta charset=\"utf-8\"><title>%s</title><link href=\"https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css\" rel=\"stylesheet\"></head><body class=\"p-4\"><div class=\"container\"><h1 class=\"h4 mb-3\">%s &lt;%s&gt;</h1><pre class=\"bg-light p-3 rounded border\">%s</pre><a class=\"btn btn-primary mt-3\" href=\"/keys/%s\">Download .asc</a> <a class=\"btn btn-outline-secondary mt-3\" href=\"/\">Back</a></div></body></html>", template.HTMLEscapeString(rec.Email), template.HTMLEscapeString(rec.Name), template.HTMLEscapeString(rec.Email), template.HTMLEscapeString(string(data)), template.HTMLEscapeString(rec.ID))
})
// --- WKD (Web Key Directory) ---
mux.HandleFunc("/.well-known/openpgpkey/policy", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("# WKD policy"))
})
// direct method: /.well-known/openpgpkey/hu/<hash>
mux.HandleFunc("/.well-known/openpgpkey/hu/", func(w http.ResponseWriter, r *http.Request) {
pathHash := strings.TrimPrefix(r.URL.Path, "/.well-known/openpgpkey/hu/")
var match *KeyRecord
for _, it := range st.all() {
h, _ := wkdHash(it.Email)
if h == pathHash {
match = &it
break
}
}
if match == nil {
http.NotFound(w, r)
return
}
data, err := os.ReadFile(filepath.Join(keysDir, match.Filename))
if err != nil {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/pgp-keys")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(data)
})
// advanced method: /openpgpkey/<domain>/hu/<hash>
mux.HandleFunc("/openpgpkey/", func(w http.ResponseWriter, r *http.Request) {
parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/openpgpkey/"), "/")
if len(parts) != 3 || parts[1] != "hu" {
http.NotFound(w, r)
return
}
domain, hash := parts[0], parts[2]
var match *KeyRecord
for _, it := range st.all() {
h, d := wkdHash(it.Email)
if h == hash && strings.EqualFold(d, domain) {
match = &it
break
}
}
if match == nil {
http.NotFound(w, r)
return
}
data, err := os.ReadFile(filepath.Join(keysDir, match.Filename))
if err != nil {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/pgp-keys")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(data)
})
// --- Minimal HKP-compatible lookup ---
mux.HandleFunc("/pks/lookup", func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
op := strings.ToLower(q.Get("op"))
term := strings.TrimSpace(q.Get("search"))
term, _ = url.QueryUnescape(term)
if term == "" {
http.Error(w, "missing search", http.StatusBadRequest)
return
}
items := st.search(term)
switch op {
case "get":
w.Header().Set("Content-Type", "application/pgp-keys")
for i, it := range items {
data, err := os.ReadFile(filepath.Join(keysDir, it.Filename))
if err == nil {
_, _ = w.Write(data)
if i < len(items)-1 {
_, _ = w.Write([]byte(""))
}
}
}
case "index":
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
for _, it := range items {
fmt.Fprintf(w, "pub:%s:%s:%s", it.Fingerprint, it.Email, it.Name)
}
default:
http.Error(w, "unsupported op", http.StatusBadRequest)
}
})
// Static assets
fs := http.FileServer(http.FS(contentFS))
mux.Handle("/assets/", http.StripPrefix("/", fs))
addr := ":8080"
log.Printf("PGP Key Server listening on %s", addr)
log.Fatal(http.ListenAndServe(addr, withSecurityHeaders(mux)))
}
// parseFingerprintFromASCII parses the first entity in an armored key and returns the hex fingerprint.
func parseFingerprintFromASCII(b []byte) (string, error) {
ents, err := openpgp.ReadArmoredKeyRing(bytes.NewReader(b))
if err != nil {
return "", err
}
if len(ents) == 0 || ents[0].PrimaryKey == nil {
return "", fmt.Errorf("no primary key in block")
}
fp := ents[0].PrimaryKey.Fingerprint
return strings.ToUpper(hex.EncodeToString(fp[:])), nil
}
func withSecurityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("Referrer-Policy", "no-referrer")
next.ServeHTTP(w, r)
})
}

32
templates/index.html Normal file
View File

@@ -0,0 +1,32 @@
{{define "content"}}
<div class="card p-3 p-md-4 mb-4 bg-white">
<div class="row g-2 align-items-center">
<div class="col-md-8">
<input class="form-control form-control-lg search" name="q" id="q" placeholder="Suche nach Name, EMail, Fingerprint…"
hx-get="/search" hx-trigger="keyup changed delay:300ms" hx-target="#results" hx-indicator="#busy">
</div>
<div class="col-md-4 text-md-end">
<span id="busy" class="htmx-indicator text-muted">Suche…</span>
</div>
</div>
</div>
<div class="card p-0 bg-white">
<div class="table-responsive">
<table class="table align-middle mb-0">
<thead class="table-light">
<tr>
<th style="width:30%">Name</th>
<th style="width:30%">EMail</th>
<th style="width:25%">Fingerprint</th>
<th style="width:15%" class="text-end">Aktion</th>
</tr>
</thead>
<tbody id="results">
{{template "rows" .Items}}
</tbody>
</table>
</div>
</div>
{{end}}

72
templates/layout.html Normal file
View File

@@ -0,0 +1,72 @@
{{define "layout"}}
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>PGP Key Server</title>
<link href="/assets/css/bootstrap.min.css" rel="stylesheet">
<script src="/assets/htmx.min.js"></script>
<style>
body { background: #0f172a; } /* slate-900 */
.card { border-radius: 1rem; box-shadow: 0 10px 30px rgba(0,0,0,.25);}
.brand { color: #e2e8f0; } /* slate-200 */
.muted { color: #94a3b8; }
.search { border-radius: .75rem; }
</style>
</head>
<body class="py-4">
<div class="container">
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h1 class="brand h3 mb-0">🔐 PGP Key Server</h1>
<div class="muted">Durchsuche öffentliche OpenPGP-Schlüssel</div>
</div>
<button class="btn btn-success" data-bs-toggle="modal" data-bs-target="#uploadModal">+ Upload</button>
</div>
{{template "content" .}}
<!-- Upload Modal -->
<div class="modal fade" id="uploadModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Öffentlichen Schlüssel hochladen</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form action="/upload" method="post" enctype="multipart/form-data">
<div class="modal-body">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Name</label>
<input class="form-control" name="name" placeholder="Max Mustermann" required>
</div>
<div class="col-md-6">
<label class="form-label">E-Mail</label>
<input type="email" class="form-control" name="email" placeholder="max@example.com" required>
</div>
<div class="col-md-6">
<label class="form-label">Fingerprint (optional)</label>
<input class="form-control" name="fingerprint" placeholder="ABCD 1234 ...">
</div>
<div class="col-12">
<label class="form-label">PGP Public Key (.asc)</label>
<input type="file" class="form-control" name="file" accept=".asc,text/plain" required>
<div class="form-text">Nur ASCIIarmored öffentliche Schlüssel. Maximale Größe 2 MiB.</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Abbrechen</button>
<button class="btn btn-primary">Hochladen</button>
</div>
</form>
</div>
</div>
</div>
</div>
<script src="/assets/js/bootstrap.bundle.min.js"></script>
</body>
</html>
{{end}}

19
templates/rows.html Normal file
View File

@@ -0,0 +1,19 @@
{{define "rows"}}
{{if not .}}
<tr><td colspan="4" class="text-center text-muted py-5">Keine Schlüssel gefunden</td></tr>
{{else}}
{{range .}}
<tr>
<td>{{.Name}}</td>
<td><a href="mailto:{{.Email}}">{{.Email}}</a></td>
<td><code>{{.Fingerprint}}</code></td>
<td class="text-end">
<div class="btn-group">
<a class="btn btn-sm btn-outline-primary" href="/view/{{.ID}}">View</a>
<a class="btn btn-sm btn-primary" href="/keys/{{.ID}}">Download</a>
</div>
</td>
</tr>
{{end}}
{{end}}
{{end}}