botauth.go 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286
  1. // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
  2. // SPDX-License-Identifier: Apache-2.0
  3. package botauth // import "miniflux.app/v2/internal/botauth"
  4. // Resources:
  5. //
  6. // https://datatracker.ietf.org/doc/html/draft-meunier-http-message-signatures-directory
  7. // https://datatracker.ietf.org/doc/html/draft-meunier-web-bot-auth-architecture
  8. // https://developers.cloudflare.com/bots/reference/bot-verification/web-bot-auth/
  9. // https://github.com/thibmeu/http-message-signatures-directory
  10. import (
  11. "crypto/ed25519"
  12. "crypto/sha256"
  13. "encoding/base64"
  14. "encoding/json"
  15. "fmt"
  16. "net/http"
  17. "net/url"
  18. "strconv"
  19. "strings"
  20. "time"
  21. "miniflux.app/v2/internal/crypto"
  22. )
  23. const (
  24. signatureValidity = 3600 // 1 hour validity
  25. )
  26. var GlobalInstance *botAuth
  27. type jsonWebKey struct {
  28. KeyType string `json:"kty"`
  29. Curve string `json:"crv"`
  30. PublicKey string `json:"x"`
  31. }
  32. type jsonWebKeySet struct {
  33. Keys []jsonWebKey `json:"keys"`
  34. }
  35. type keyPair struct {
  36. privateKey []byte
  37. publicKey []byte
  38. publicJWK *jsonWebKey
  39. thumbprint string
  40. }
  41. func NewKeyPair(privateKey, publicKey []byte) (*keyPair, error) {
  42. if len(privateKey) != ed25519.PrivateKeySize {
  43. return nil, fmt.Errorf("invalid private key size: got %d instead of %d", len(privateKey), ed25519.PrivateKeySize)
  44. }
  45. if len(publicKey) != ed25519.PublicKeySize {
  46. return nil, fmt.Errorf("invalid public key size: got %d instead of %d", len(publicKey), ed25519.PublicKeySize)
  47. }
  48. publicJWK := &jsonWebKey{
  49. KeyType: "OKP",
  50. Curve: "Ed25519",
  51. PublicKey: base64.RawURLEncoding.EncodeToString(publicKey),
  52. }
  53. thumbprint, err := computeJWKThumbprint(publicJWK)
  54. if err != nil {
  55. return nil, fmt.Errorf("failed to calculate JWK thumbprint: %w", err)
  56. }
  57. return &keyPair{
  58. privateKey: privateKey,
  59. publicKey: publicKey,
  60. publicJWK: publicJWK,
  61. thumbprint: thumbprint,
  62. }, nil
  63. }
  64. type KeyPairs []*keyPair
  65. func (kps KeyPairs) jsonWebKeySet() jsonWebKeySet {
  66. var keys []jsonWebKey
  67. for _, kp := range kps {
  68. keys = append(keys, *kp.publicJWK)
  69. }
  70. return jsonWebKeySet{Keys: keys}
  71. }
  72. type botAuth struct {
  73. directoryURL string
  74. keys KeyPairs
  75. }
  76. func NewBothAuth(directoryURL string, keys KeyPairs) (*botAuth, error) {
  77. if !strings.HasPrefix(directoryURL, "https://") {
  78. return nil, fmt.Errorf("directory URL %q must start with https://", directoryURL)
  79. }
  80. if len(keys) == 0 {
  81. return nil, fmt.Errorf("at least one key pair is required")
  82. }
  83. return &botAuth{
  84. directoryURL: directoryURL,
  85. keys: keys,
  86. }, nil
  87. }
  88. func (ba *botAuth) DirectoryURL() string {
  89. absoluteURL, err := url.JoinPath(ba.directoryURL, "/.well-known/http-message-signatures-directory")
  90. if err != nil {
  91. return ba.directoryURL
  92. }
  93. return absoluteURL
  94. }
  95. func (ba *botAuth) ServeKeyDirectory(w http.ResponseWriter, r *http.Request) {
  96. body, err := json.Marshal(ba.keys.jsonWebKeySet())
  97. if err != nil {
  98. http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
  99. return
  100. }
  101. created := time.Now().Unix()
  102. expires := created + signatureValidity
  103. signatures := make([]string, len(ba.keys))
  104. signatureInputs := make([]string, len(ba.keys))
  105. for i, key := range ba.keys {
  106. signatureMetadata := []signatureMetadata{
  107. {name: "alg", value: "ed25519"},
  108. // https://datatracker.ietf.org/doc/html/draft-meunier-http-message-signatures-directory-01#section-5.2-6.6.1
  109. {name: "keyid", value: key.thumbprint},
  110. // https://datatracker.ietf.org/doc/html/draft-meunier-http-message-signatures-directory-01#section-5.2-6.8.1
  111. {name: "tag", value: "http-message-signatures-directory"},
  112. // https://datatracker.ietf.org/doc/html/draft-meunier-http-message-signatures-directory-01#section-5.2-6.2.1
  113. {name: "created", value: created},
  114. // https://datatracker.ietf.org/doc/html/draft-meunier-http-message-signatures-directory-01#section-5.2-6.4.1
  115. {name: "expires", value: expires},
  116. }
  117. signatureComponents := []signatureComponent{
  118. {name: "@authority", value: r.Host},
  119. }
  120. signatureParams := generateSignatureParams(signatureComponents, signatureMetadata)
  121. signature, err := signComponents(key.privateKey, signatureComponents, signatureParams)
  122. if err != nil {
  123. http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
  124. return
  125. }
  126. signatureLabel := `sig` + strconv.Itoa(i+1)
  127. signatureInputs[i] = signatureLabel + `=` + signatureParams
  128. signatures[i] = signatureLabel + `=:` + signature + `:`
  129. }
  130. // https://datatracker.ietf.org/doc/html/draft-meunier-http-message-signatures-directory-01#name-application-http-message-si
  131. w.Header().Set("Content-Type", "application/http-message-signatures-directory+json")
  132. w.Header().Set("Signature-Input", strings.Join(signatureInputs, ", "))
  133. w.Header().Set("Signature", strings.Join(signatures, ", "))
  134. // Verifiers can cache keys directory for 1 day.
  135. w.Header().Set("Cache-Control", "max-age=86400")
  136. w.WriteHeader(http.StatusOK)
  137. w.Write(body)
  138. }
  139. func (ba *botAuth) SignRequest(req *http.Request) error {
  140. if len(ba.keys) == 0 {
  141. return fmt.Errorf("no key pairs available to sign the request")
  142. }
  143. firstKeyPair := ba.keys[0]
  144. created := time.Now().Unix()
  145. expires := created + signatureValidity
  146. // @authority component
  147. // https://www.rfc-editor.org/rfc/rfc9421#section-2.2.3
  148. authority := req.Host
  149. if authority == "" {
  150. authority = req.URL.Host
  151. }
  152. signatureAgent := `"` + ba.directoryURL + `"`
  153. signatureMetadata := []signatureMetadata{
  154. {name: "alg", value: "ed25519"},
  155. // https://datatracker.ietf.org/doc/html/draft-meunier-web-bot-auth-architecture-02#section-4.2-5.6.1
  156. {name: "keyid", value: firstKeyPair.thumbprint},
  157. // https://datatracker.ietf.org/doc/html/draft-meunier-web-bot-auth-architecture-02#section-4.2-5.8.1
  158. {name: "tag", value: "web-bot-auth"},
  159. // https://datatracker.ietf.org/doc/html/draft-meunier-web-bot-auth-architecture-02#section-4.2-5.2.1
  160. {name: "created", value: created},
  161. // https://datatracker.ietf.org/doc/html/draft-meunier-web-bot-auth-architecture-02#section-4.2-5.4.1
  162. {name: "expires", value: expires},
  163. // https://datatracker.ietf.org/doc/html/draft-meunier-web-bot-auth-architecture-02#name-anti-replay
  164. {name: "nonce", value: base64.StdEncoding.EncodeToString(crypto.GenerateRandomBytes(64))},
  165. }
  166. // https://datatracker.ietf.org/doc/html/draft-meunier-web-bot-auth-architecture-02#name-signature-agent
  167. signatureComponents := []signatureComponent{
  168. {name: "@authority", value: authority},
  169. {name: "signature-agent", value: signatureAgent},
  170. }
  171. signatureParams := generateSignatureParams(signatureComponents, signatureMetadata)
  172. signatureInput := `sig1=` + signatureParams
  173. signature, err := signComponents(firstKeyPair.privateKey, signatureComponents, signatureParams)
  174. if err != nil {
  175. return fmt.Errorf("failed to sign request: %w", err)
  176. }
  177. // Add headers to request
  178. req.Header.Set("Signature-Agent", signatureAgent)
  179. req.Header.Set("Signature-Input", signatureInput)
  180. req.Header.Set("Signature", `sig1=:`+signature+`:`)
  181. return nil
  182. }
  183. // https://www.rfc-editor.org/rfc/rfc8037.html#appendix-A.3
  184. func computeJWKThumbprint(jwk *jsonWebKey) (string, error) {
  185. canonical := `{"crv":"` + jwk.Curve + `","kty":"` + jwk.KeyType + `","x":"` + jwk.PublicKey + `"}`
  186. hash := sha256.Sum256([]byte(canonical))
  187. return base64.RawURLEncoding.EncodeToString(hash[:]), nil
  188. }
  189. type signatureMetadata struct {
  190. name string
  191. value any
  192. }
  193. // https://www.rfc-editor.org/rfc/rfc9421#name-signature-parameters
  194. func generateSignatureParams(components []signatureComponent, signatureMetadata []signatureMetadata) string {
  195. var componentNames []string
  196. for _, component := range components {
  197. componentNames = append(componentNames, `"`+component.name+`"`)
  198. }
  199. var metadataParts []string
  200. for _, meta := range signatureMetadata {
  201. switch v := meta.value.(type) {
  202. case string:
  203. metadataParts = append(metadataParts, meta.name+`="`+v+`"`)
  204. case int64:
  205. metadataParts = append(metadataParts, meta.name+`=`+strconv.FormatInt(v, 10))
  206. }
  207. }
  208. return `(` + strings.Join(componentNames, ` `) + `);` + strings.Join(metadataParts, ";")
  209. }
  210. type signatureComponent struct {
  211. name string
  212. value string
  213. }
  214. // https://www.rfc-editor.org/rfc/rfc9421#name-signing-request-components-
  215. func signComponents(privateKey ed25519.PrivateKey, components []signatureComponent, signatureParams string) (string, error) {
  216. var signatureBase strings.Builder
  217. // Build signature base
  218. for _, comp := range components {
  219. signatureBase.WriteString(`"` + comp.name + `": ` + comp.value + "\n")
  220. }
  221. signatureBase.WriteString(`"@signature-params": ` + signatureParams)
  222. // Sign the signature base
  223. signature := ed25519.Sign(privateKey, []byte(signatureBase.String()))
  224. return base64.StdEncoding.EncodeToString(signature), nil
  225. }