as documented on https://datatracker.ietf.org/doc/html/draft-koch-openpgp-webkey-service#name-web-key-directory
---
I rewrote my initial patch in go so that it lives under "api".
To be publicly accessible adjustements must be made to sr.ht-nginx/meta.sr.ht.conf, like (untested):
location /.well-known/openpgpkey {
proxy_pass http://127.0.0.1:5100;
}
It does not conflict anymore with https://lists.sr.ht/~sircmpwn/sr.ht-dev/patches/40325 (removal of pgpy dependency).
This is my first substantial contribution to sr.ht and I appreciate any feedback!
---
api/openpgpkey/opengpgkey_test.go | 53 ++++++++++++++
api/openpgpkey/openpgpkey.go | 116 ++++++++++++++++++++++++++++++
api/openpgpkey/testdata/pubkey | 80 +++++++++++++++++++++
api/server.go | 5 ++
go.mod | 2 +
5 files changed, 256 insertions(+)
create mode 100644 api/openpgpkey/opengpgkey_test.go
create mode 100644 api/openpgpkey/openpgpkey.go
create mode 100644 api/openpgpkey/testdata/pubkey
diff --git a/api/openpgpkey/opengpgkey_test.go b/api/openpgpkey/opengpgkey_test.go
new file mode 100644
index 0000000..7a4cb19
--- /dev/null
+++ b/api/openpgpkey/opengpgkey_test.go
@@ -0,0 +1,53 @@
+package openpgpkey
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/go-chi/chi"
+ "github.com/stretchr/testify/assert"
+ "github.com/vaughan0/go-ini"
+)
+
+func TestNewWellKnown(t *testing.T) {
+ router := chi.NewRouter()
+ err := MountWellKnownRoutes(ini.File{
+ "sr.ht": ini.Section{
+ "global-domain": "sr.ht",
+ },
+ "mail": ini.Section{
+ "pgp-pubkey": "testdata/pubkey",
+ },
+ }, router)
+ assert.NoError(t, err)
+
+ resp := httptest.NewRecorder()
+ router.ServeHTTP(resp, httptest.NewRequest("GET", "/.well-known/openpgpkey/hu/4y36rkzdjnzmk3oxaekyi5biowgr5kcz", nil)) // admin in zbase32
+ assert.Equal(t, http.StatusOK, resp.Result().StatusCode)
+ assert.Equal(t, "application/octet-stream", resp.Header().Get("Content-Type"))
+}
+
+func TestEncodeLocalPart(t *testing.T) {
+ assert.Equal(t, "4y36rkzdjnzmk3oxaekyi5biowgr5kcz", encodeLocalPart("admin"))
+ assert.Equal(t, "4y36rkzdjnzmk3oxaekyi5biowgr5kcz", encodeLocalPart("Admin"))
+ assert.Equal(t, "e5a4bxki1ktx1jncwco5nkcofedmkxod", encodeLocalPart("git"))
+}
+
+func TestGetGlobalDomain(t *testing.T) {
+ globalDomain, err := getGlobalDomain(ini.File{
+ "sr.ht": ini.Section{
+ "global-domain": "sr.ht",
+ },
+ }, "meta.sr.ht")
+ assert.NoError(t, err)
+ assert.Equal(t, "sr.ht", globalDomain)
+
+ globalDomain, err = getGlobalDomain(ini.File{
+ "meta.sr.ht": ini.Section{
+ "origin": "http://meta.sr.ht.local",
+ },
+ }, "meta.sr.ht")
+ assert.NoError(t, err)
+ assert.Equal(t, "sr.ht.local", globalDomain)
+}
diff --git a/api/openpgpkey/openpgpkey.go b/api/openpgpkey/openpgpkey.go
new file mode 100644
index 0000000..f702979
--- /dev/null
+++ b/api/openpgpkey/openpgpkey.go
@@ -0,0 +1,116 @@
+package openpgpkey
+
+import (
+ "bytes"
+ "crypto/sha1"
+ "encoding/base32"
+ "errors"
+ "fmt"
+ "net/http"
+ "net/url"
+ "os"
+ "strings"
+
+ "github.com/ProtonMail/go-crypto/openpgp"
+ "github.com/go-chi/chi"
+ "github.com/vaughan0/go-ini"
+)
+
+// MountWellKnownRoutes mounts the routes to serve the public key (binary encoded) under the well-known URLs:
+// - /.well-known/openpgpkey/hu/<zbase32-hash>
+// - /.well-known/openpgpkey/hu/policy
+//
+// See https://datatracker.ietf.org/doc/html/draft-koch-openpgp-webkey-service
+func MountWellKnownRoutes(conf ini.File, router chi.Router) error {
+ pubKeyPath, ok := conf.Get("mail", "pgp-pubkey")
+ if !ok {
+ return errors.New("expected [mail]pgp-pubkey in config")
+ }
+
+ pubKeyFile, err := os.Open(pubKeyPath)
+ if err != nil {
+ return fmt.Errorf("failed to open [mail]pgp-pubkey: %v", err)
+ }
+ defer pubKeyFile.Close()
+
+ keyring, err := openpgp.ReadArmoredKeyRing(pubKeyFile)
+ if err != nil {
+ return err
+ }
+
+ globalDomain, err := getGlobalDomain(conf, "meta.sr.ht")
+ if err != nil {
+ return err
+ }
+
+ for _, entity := range keyring {
+ var buf bytes.Buffer
+ err = entity.PrimaryKey.Serialize(&buf)
+ if err != nil {
+ return fmt.Errorf("could not serialize primary key: %v", err)
+ }
+ pubKey := buf.Bytes()
+ servePubkey := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/octet-stream")
+ w.Write(pubKey)
+ })
+ for _, identidy := range entity.Identities {
+ local, domain, found := stringsCut(identidy.UserId.Email, "@")
+ if !found || domain != globalDomain {
+ continue
+ }
+ router.Get("/.well-known/openpgpkey/hu/"+encodeLocalPart(local), servePubkey)
+ }
+ }
+ router.Get("/.well-known/openpgpkey/hu/policy", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/plain")
+ w.Write([]byte("# Policy flags for the OpenPGP Web Key Directory"))
+ }))
+ return nil
+}
+
+var zbase32Encoding = base32.NewEncoding("ybndrfg8ejkmcpqxot1uwisza345h769").WithPadding(base32.NoPadding)
+
+func encodeLocalPart(s string) string {
+ hash := sha1.New()
+ hash.Write([]byte(strings.ToLower(s)))
+ shaOne := hash.Sum(nil)
+ return zbase32Encoding.EncodeToString(shaOne)
+}
+
+// Gets the global domain from the config. If it's not defined, assume that
+// the given site is a sub-domain of the global domain, i.e. it is of the
+// form `blah.globaldomain.com`.
+func getGlobalDomain(conf ini.File, site string) (string, error) {
+ globalDomain, ok := conf.Get("sr.ht", "global-domain")
+ if ok {
+ return globalDomain, nil
+ }
+ serviceOrigin, ok := conf.Get(site, "origin")
+ if !ok {
+ return "", fmt.Errorf("expected [sr.ht]global-domain or [%s]origin in config", site)
+ }
+ serviceURL, err := url.Parse(serviceOrigin)
+ if err != nil {
+ return "", fmt.Errorf("failed to parse URL [%s]origin in config: %v", site, err)
+ }
+ _, globalDomain, _ = stringsCut(serviceURL.Hostname(), ".")
+ return globalDomain, nil
+}
+
+// strings.Cut is available starting with go1.18
+// Copied from https://cs.opensource.google/go/go/+/refs/tags/go1.20.4:src/strings/strings.go;l=1262
+// Copyright 2009 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license.
+//
+// stringsCut slices s around the first instance of sep,
+// returning the text before and after sep.
+// The found result reports whether sep appears in s.
+// If sep does not appear in s, cut returns s, "", false.
+func stringsCut(s, sep string) (before, after string, found bool) {
+ if i := strings.Index(s, sep); i >= 0 {
+ return s[:i], s[i+len(sep):], true
+ }
+ return s, "", false
+}
diff --git a/api/openpgpkey/testdata/pubkey b/api/openpgpkey/testdata/pubkey
new file mode 100644
index 0000000..27c1a2c
--- /dev/null
+++ b/api/openpgpkey/testdata/pubkey
@@ -0,0 +1,80 @@
+-----BEGIN PGP PUBLIC KEY BLOCK-----
+
+mQENBFgK0NQBCAC8sMZtJXV9mqmtT09feFTSfahQQiJJxWUbr8sDm+231e6TozmZ
+JljBxhhk6wYht7SEDAxBijod1GbpGtVKc4YFZYb3oFep8zrwOPEq3g4y7V9rrm1J
+/SUGmqhlafXQAwEY0tUKZoudo4es5iWmW1XP+JwK39i7CqVI97UEM4NJuq4d0L56
+VR67A/aZqBfr8OJ0Yxd1mkd2JnBRWcIBt/6zh5aDQhpg3iRa5QInqr3KNsJGXO9K
+C3O0W2PBNsuQcgScVfdifa1OT3sDufEhYEn+0CCSBoKfkyEwyAatwz6Pl+AyhSva
+i+JZlQSmGmx6VAhBANKSnYm2W9e/I44lknHrABEBAAG0E3NyLmh0IDxhZG1pbkBz
+ci5odD6JATcEEwEIACEFAlgK0NQCGwMFCwkIBwIGFQgJCgsCBBYCAwECHgECF4AA
+CgkQZZcE0aOKk66GXAf/TmS/5lU+JECzNeTvRvWBYu5ahml0biUjkibFmEj6UeuD
+vLn5G3UPBxRIjTVEXPVIVgujQz5kkfm+Z3xLstbB5fo1nFXwikB77+LkWMTpXCky
+IqOS8CBxsoAgIZamp/d/K5YzEl2qH0ja5lXI/zjkmyk5jhj6XaGDZJ/Gymk866Uk
+IwUyR7z+wt8VQypvnt8m/6LyTZfO3Gby74XAaT0XQoIfXlHrrmFDKS6mIxzKK2X3
+iMVKfrknhG6TxFNGk7v9xZH+wY82aKPiO26qINHGLew57CWsDLBeusrl916BUjkj
+tmFfE4ZXHQd+QipdzFM6b/VIUKb7zb/GBzmxWRE/G7RIc3IuaHQgb3V0Z29pbmcg
+KEdlbmVyYWwgcHVycG9zZSBvdXRnb2luZyBzci5odCBhZGRyZXNzKSA8b3V0Z29p
+bmdAc3IuaHQ+iQFOBBMBCAA4FiEERHtp5LNL6QvIKaDpZZcE0aOKk64FAlwreIwC
+GwMFCwkIBwIGFQoJCAsCBBYCAwECHgECF4AACgkQZZcE0aOKk64s7Af/RHO4hxQi
+rx4+W1Kqe3f6An9KcrFQ1hDUzWDm5KKZE7RztD1/9BoFXK4UOyPB1F+Ng0gCknLI
+65ISSsiC9CbS0adOf5nAO5cUbwSv2A1D/TgznVf1dZvdBQ1YuCyV53UXFryZJ4d7
+NgPQPOrzaQE7rQutbk/wlNSKn7t3rkJjVaSPAofdjG0HCb2Uc/oFSCNG6aY3SqBv
+yr0ys57CrdhKqg2DVQcsZk8elSEQu4DIFZm5xsM7wxN57qA3Xshm347t4tWH+IpT
+i9sqjIBCa+TEy5frhkgj/ykIl9AKrIWs27KEvCogXy6aiwbQS8K9+Mp4d8TKmv12
+nO79CM5j7vFLa7RGc3IuaHQgdG9kbyAoQWRkcmVzcyBmb3IgYXV0b21hdGVkIGVt
+YWlscyBmcm9tIHRvZG8uc3IuaHQpIDx0b2RvQHNyLmh0PokBTgQTAQgAOBYhBER7
+aeSzS+kLyCmg6WWXBNGjipOuBQJcK3ihAhsDBQsJCAcCBhUKCQgLAgQWAgMBAh4B
+AheAAAoJEGWXBNGjipOuexMH/jBckRZTSVT9CciFzAslsuqRRehJ4Tae5EW4X5Ou
+JGnmFVDb+nn1T14I+I+GhA3URYuCe1pEFiJfg368pXqGDbh/9JSmzWd9PZtGGlI6
+JApQlPYxapfkv3UrXtF0T7Rj0yTTgf6vkjSaHdp05bCvatTO7UAD01s9/TCHloeP
+Mxkx9e4BKfhO2y/aVeNFW9EASKG46q/2c5UyUEjoVCYCV14L3vMcbXNO0hQOeY6o
+WQD245tfNRPUMNumCfdE+FF8PWYGkhypPf9Os4FfsHQsj12bi3CNEa7wrVuMwRU3
+qTWqkc+0c/HMU471DIh6YH7dxFgLTqDtZ7SqoFufmwJ+qTS0THNyLmh0IGJ1aWxk
+cyAoQWRkcmVzcyBmb3IgYXV0b21hdGVkIGVtYWlscyBmcm9tIGJ1aWxkcy5zci5o
+dCkgPGJ1aWxkc0Bzci5odD6JAU4EEwEIADgWIQREe2nks0vpC8gpoOlllwTRo4qT
+rgUCXCt4sgIbAwULCQgHAgYVCgkICwIEFgIDAQIeAQIXgAAKCRBllwTRo4qTrjzo
+B/oCCFAGAhrQJADDH7ALYd6K7kQgtHjOE++u2ZAjbbu5CBgIcao5UKe0t3u8xVDK
+E0gy/v2Y/vyu1VacaXWhvV52dm+YA6UYMDzlyTngQ6zXP2MmmOXYm9o7hks83Acj
+8zyVxtuoSiTEcXBxyGbcFa4TN8ptuiEJEKnRnpi67xMrniaXfBTAE4BDgD3qpfRd
+zh3Ya+/pqd/f6yWCok8rQeHennqbyrdSrjzlwrpwHj63aTp0CsbLjsv7JToo8AIo
+GoyGgXaMj1991DJFE2kxmd3aSFVEPGH2bgLk21wadjSMBfVgWQy/d0hBpU0h77+e
+iGkNWsmQXE0TrNKs96YSPktUtENzci5odCBnaXQgKEFkZHJlc3MgZm9yIGF1dG9t
+YXRlZCBlbWFpbHMgZnJvbSBnaXQuc3IuaHQpIDxnaXRAc3IuaHQ+iQFOBBMBCAA4
+FiEERHtp5LNL6QvIKaDpZZcE0aOKk64FAlwreMMCGwMFCwkIBwIGFQoJCAsCBBYC
+AwECHgECF4AACgkQZZcE0aOKk67CfggAlkf1xyVLFBNC/UyaYq6iYotcVQ9oW82O
+dLZ00NckXllixvmcd9s+l5xmKluGPOivY2/tM2ongxfe3orPrr4/JIQXAeZIcq7z
+uz8OJDI/efPFC+x/BMb2eb0bpDGamcTMp6AiJGZd4fF2IcBGcWUpFSbaXzy8fE/d
+iVEIk970HGSxB8NchA7TY1AKzGty5qy4lslfGc7eA9G8ZfoNQ96MGJFIPvY4jT4L
+71tH+baqRN3/8By0mmxLCiW8rQDVGgcPicZENWWioCIYLlj7TixqTLLtK8nCLQ8a
+y8uc/9Yk+whJDah+t8wBAuIjCysa/lZPcxoWekDyZ6pLGyI2T/QRSbRGc3IuaHQg
+bWV0YSAoQWRkcmVzcyBmb3IgYXV0b21hdGVkIGVtYWlscyBmcm9tIG1ldGEuc3Iu
+aHQpIDxtZXRhQHNyLmh0PokBTgQTAQgAOBYhBER7aeSzS+kLyCmg6WWXBNGjipOu
+BQJcK3jVAhsDBQsJCAcCBhUKCQgLAgQWAgMBAh4BAheAAAoJEGWXBNGjipOuS9AH
+/RVimfI35uuI4zGu5v4T/FNO7uSvLiKtG64iYoQCBJSOw0wx/rU/wWfEZ5w8wPZB
+TdcFVB/qEVCkE8vISmvK70fLiBtOKq8tmZvgFd9f7V9LWUDyoh4xOShFM+nPWpJW
+aweKK/tfclXAU4NIfgtFEn+XuSqrtt3VmQWeYiR8+ToRb3lcO8CxLW/AFZrVzRZu
+X9nQMyCr6mK6Xxg8c17CXSHJRSfFEvRzES7q/spSJT26xlaY8X1V82GFRpBPdeIV
+EEd/6vbPqNJ6kyZioYB/PyEkTxMLh7sRM8l2xKYcqLgW7vXKW2L82vmFdzdPCQMg
+xxWhdUhYWD9RIdoT/n8CEHW0UnNyLmh0IGRpc3BhdGNoIChBZGRyZXNzIGZvciBh
+dXRvbWF0ZWQgZW1haWxzIGZyb20gZGlzcGF0Y2guc3IuaHQpIDxkaXNwYXRjaEBz
+ci5odD6JAU4EEwEIADgWIQREe2nks0vpC8gpoOlllwTRo4qTrgUCXCt46gIbAwUL
+CQgHAgYVCgkICwIEFgIDAQIeAQIXgAAKCRBllwTRo4qTruoEB/9UJapNcs9gci37
+XJCEHB/G0+9HL7lcGjbBSfMhA2BCuirfS3k347Ug+hLzld23wqFSP2wbr+muKHxL
+1rQ9CIJ5lVwWOmZQLovl/8W/irQRh4SRnJtxWpCpXKoYhDH0bPoUbYjR9rezWWb0
+rKw6KpWiEhYAOZWZ/vnv9uT70P7RP0+D5biI5snuS/O2sJLVqt0YDpOPcyF3nFg5
+KHre7DzzBNoIZjrMOld8KbQ9YjOG14kokhXFQRbkrPhisx2uQdaCJN1fn4nbn8EU
+DMXk28ZtutEAJjoGnaWksYi1VtYm/4Z8dWMhRByAx0fNKbl3FhzEUuL8ef2nxfcA
+2KMtUu2IuQENBFgK0NQBCADKUjxaYJ7vssplWvNUnH5hkdPlpqKdQ5lnZHMk9kaq
+RxEieDvle8l7H0hC1JH621EJhQ+l2F5njBUZVmFCNBoK1ryAvc8Y2E2+crYxMJ+r
+75etNLxnAvepkCYf8psl+Qja34brfYmaTokWflFEgZF4PbjIhnbBRHnLKCWHzv4l
+ttysU7JoFPe1OaX4ZTDu09I4PiSJb8ekFQCCcdUW9f520IrF1bwRIP7QhXWTYkR5
+kVJaGZ3PW+Q2AeyFw9HWDEIAYse2LGg6vpPevnow10ZvuTvbd0+qWzMnrfcqW2xV
+6T9gZvS8m8XVIfveX+DyL4SdA5sDuJ2w3s1yLyWW094DABEBAAGJAR8EGAEIAAkF
+AlgK0NQCGwwACgkQZZcE0aOKk64MqwgAs6O9uhyxaaf80QTKlBMaYYjcai9kMlrS
+RGeoEUePq6Wq4VypVM9tgShXZ67UR5eJtjQJzFFijCCj4JdsBGU/vesfsWpYObeW
+/Z/WAGJ6byK44F5gL6KJYf8/8p23CzgZvc6hdGBz8cfdSxsZQZjMI5XLjyeD0uQ/
+Q3IzmQd3m5r8SORtQiQ1L4sgk7PEOPZDN+e+XOYy6OlN5PfPFGGBkYMC171vvuEI
+h06M+nUqlxcDbLvsewjmH2yjFuyMBJ9zu1+1BouKDkFK7kzXmFFqWCSdRTVh59G0
+nOlQSC/TRrL8fOmfK9mXdZcFkJXU3aadQ9g4nSxBBOanSNJvnW0TJw==
+=zmAw
+-----END PGP PUBLIC KEY BLOCK-----
diff --git a/api/server.go b/api/server.go
index 5d7b482..1738498 100644
--- a/api/server.go
+++ b/api/server.go
@@ -22,6 +22,7 @@ import (
"git.sr.ht/~sircmpwn/meta.sr.ht/api/graph/model"
"git.sr.ht/~sircmpwn/meta.sr.ht/api/invoice"
"git.sr.ht/~sircmpwn/meta.sr.ht/api/loaders"
+ "git.sr.ht/~sircmpwn/meta.sr.ht/api/openpgpkey"
"git.sr.ht/~sircmpwn/meta.sr.ht/api/webhooks"
)
@@ -134,5 +135,9 @@ func main() {
panic(err)
}
})
+ err := openpgpkey.MountWellKnownRoutes(appConfig, srv.Router())
+ if err != nil {
+ panic(fmt.Errorf("openpgpkey.MountWellKnownRoutes failed: %v", err))
+ }
srv.Run()
}
diff --git a/go.mod b/go.mod
index 7751d72..4b3452a 100644
--- a/go.mod
+++ b/go.mod
@@ -18,7 +18,9 @@ require (
github.com/mitchellh/mapstructure v1.3.2 // indirect
github.com/nbutton23/zxcvbn-go v0.0.0-20210217022336-fa2cb2858354
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect
+ github.com/stretchr/testify v1.7.1
github.com/urfave/cli/v2 v2.20.2 // indirect
+ github.com/vaughan0/go-ini v0.0.0-20130923145212-a98ad7ee00ec
github.com/vektah/dataloaden v0.2.1-0.20190515034641-a19b9a6e7c9e
github.com/vektah/gqlparser v1.3.1
github.com/vektah/gqlparser/v2 v2.5.1
--
2.40.1
On 6/1/23 12:33, Drew DeVault wrote:
> Honestly not sure about this patch, it's unclear if it belongs in the
> frontend or the backend. Might be more clear post GQL finalization and
> the frontend overhauls. Will sit on it for a bit and think it over.
I'd say backend is fine, but not sure if it should be hub instead of
meta, as the key would be requested from sr.ht/.well-known/... unless we
want to set up a dedicated openpgpkey.sr.ht DNS record, which could then
point to either one.
But the real reason I am responding is that there is problem in the
current implementation. You are only writing the primary key in the HTTP
response, which is not enough, e.g. compare
curl http://localhost:5100/.well-known/openpgpkey/hu/...?l=srht -Ss |
gpg --list-packets
# off=0 ctb=c6 tag=6 hlen=2 plen=51 new-ctb
:public key packet:
version 4, algo 22, created 1643793955, expires 0
pkey[0]: [80 bits] ed25519 (1.3.6.1.4.1.11591.15.1)
pkey[1]: [263 bits]
keyid: 4268671F07D1A61E
to the output of `cat <PUBKEY>.asc | gpg --list-packets`, which will
show a lot more packets. Specifically, the RFC explicitly says:
> The HTTP GET method MUST return the binary representation of the
> OpenPGP key for the given mail address. The key needs to carry a User
> ID packet ([RFC4880]) with that mail address.
This would have to be fixed before merging, but I'll leave it to Drew if
and where we really want this.
Cheers,
Conrad