RC1
This commit is contained in:
6
assets/css/bootstrap.min.css
vendored
Normal file
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
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
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
18
data/index.json
Normal 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"
|
||||
}
|
||||
]
|
||||
98
data/keys/0x3E42CC77-pub.asc
Normal file
98
data/keys/0x3E42CC77-pub.asc
Normal 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
55
data/keys/public.asc
Normal 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/
|
||||
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
|
||||
=0v61
|
||||
-----END PGP PUBLIC KEY BLOCK-----
|
||||
10
go.mod
Normal file
10
go.mod
Normal 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
8
go.sum
Normal 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
527
main.go
Normal 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 <%s></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
32
templates/index.html
Normal 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, E‑Mail, 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%">E‑Mail</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
72
templates/layout.html
Normal 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 ASCII‑armored ö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
19
templates/rows.html
Normal 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}}
|
||||
Reference in New Issue
Block a user