redbot 2.4.2__tar.gz → 2.5.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. {redbot-2.4.2/redbot.egg-info → redbot-2.5.0}/PKG-INFO +43 -1
  2. {redbot-2.4.2 → redbot-2.5.0}/README.md +40 -0
  3. {redbot-2.4.2 → redbot-2.5.0}/pyproject.toml +2 -37
  4. {redbot-2.4.2 → redbot-2.5.0}/redbot/__init__.py +1 -1
  5. {redbot-2.4.2 → redbot-2.5.0}/redbot/cli.py +25 -1
  6. {redbot-2.4.2 → redbot-2.5.0}/redbot/daemon.py +50 -0
  7. {redbot-2.4.2 → redbot-2.5.0}/redbot/resource/fetch.py +66 -2
  8. redbot-2.5.0/redbot/webbotauth.py +244 -0
  9. {redbot-2.4.2 → redbot-2.5.0}/redbot/webui/handlers/run_test.py +4 -1
  10. {redbot-2.4.2 → redbot-2.5.0}/redbot/webui/ratelimit.py +4 -1
  11. {redbot-2.4.2 → redbot-2.5.0/redbot.egg-info}/PKG-INFO +43 -1
  12. {redbot-2.4.2 → redbot-2.5.0}/redbot.egg-info/SOURCES.txt +1 -0
  13. {redbot-2.4.2 → redbot-2.5.0}/redbot.egg-info/requires.txt +2 -0
  14. {redbot-2.4.2 → redbot-2.5.0}/LICENSE.md +0 -0
  15. {redbot-2.4.2 → redbot-2.5.0}/MANIFEST.in +0 -0
  16. {redbot-2.4.2 → redbot-2.5.0}/redbot/assets/__init__.py +0 -0
  17. {redbot-2.4.2 → redbot-2.5.0}/redbot/assets/icons/__init__.py +0 -0
  18. {redbot-2.4.2 → redbot-2.5.0}/redbot/assets/icons/check-circle.svg +0 -0
  19. {redbot-2.4.2 → redbot-2.5.0}/redbot/assets/icons/exclamation-circle.svg +0 -0
  20. {redbot-2.4.2 → redbot-2.5.0}/redbot/assets/icons/info-circle.svg +0 -0
  21. {redbot-2.4.2 → redbot-2.5.0}/redbot/assets/icons/question-circle.svg +0 -0
  22. {redbot-2.4.2 → redbot-2.5.0}/redbot/assets/icons/times-circle.svg +0 -0
  23. {redbot-2.4.2 → redbot-2.5.0}/redbot/assets/logo/__init__.py +0 -0
  24. {redbot-2.4.2 → redbot-2.5.0}/redbot/assets/logo/apple-touch-icon-144x144.png +0 -0
  25. {redbot-2.4.2 → redbot-2.5.0}/redbot/assets/logo/apple-touch-icon-152x152.png +0 -0
  26. {redbot-2.4.2 → redbot-2.5.0}/redbot/assets/logo/favicon-16x16.png +0 -0
  27. {redbot-2.4.2 → redbot-2.5.0}/redbot/assets/logo/favicon-32x32.png +0 -0
  28. {redbot-2.4.2 → redbot-2.5.0}/redbot/assets/logo/favicon.ico +0 -0
  29. {redbot-2.4.2 → redbot-2.5.0}/redbot/assets/logo/mstile-144x144.png +0 -0
  30. {redbot-2.4.2 → redbot-2.5.0}/redbot/assets/logo/redbot-sq.png +0 -0
  31. {redbot-2.4.2 → redbot-2.5.0}/redbot/assets/prettify.js +0 -0
  32. {redbot-2.4.2 → redbot-2.5.0}/redbot/assets/prettify.js.map +0 -0
  33. {redbot-2.4.2 → redbot-2.5.0}/redbot/assets/red_style.css +0 -0
  34. {redbot-2.4.2 → redbot-2.5.0}/redbot/assets/red_style.css.map +0 -0
  35. {redbot-2.4.2 → redbot-2.5.0}/redbot/assets/script.js +0 -0
  36. {redbot-2.4.2 → redbot-2.5.0}/redbot/assets/script.js.map +0 -0
  37. {redbot-2.4.2 → redbot-2.5.0}/redbot/assets/style.css +0 -0
  38. {redbot-2.4.2 → redbot-2.5.0}/redbot/formatter/__init__.py +0 -0
  39. {redbot-2.4.2 → redbot-2.5.0}/redbot/formatter/har.py +0 -0
  40. {redbot-2.4.2 → redbot-2.5.0}/redbot/formatter/html.py +0 -0
  41. {redbot-2.4.2 → redbot-2.5.0}/redbot/formatter/html_base.py +0 -0
  42. {redbot-2.4.2 → redbot-2.5.0}/redbot/formatter/null_links.py +0 -0
  43. {redbot-2.4.2 → redbot-2.5.0}/redbot/formatter/templates/__init__.py +0 -0
  44. {redbot-2.4.2 → redbot-2.5.0}/redbot/formatter/templates/footer.html +0 -0
  45. {redbot-2.4.2 → redbot-2.5.0}/redbot/formatter/templates/response_finish.html +0 -0
  46. {redbot-2.4.2 → redbot-2.5.0}/redbot/formatter/templates/response_multi_finish.html +0 -0
  47. {redbot-2.4.2 → redbot-2.5.0}/redbot/formatter/templates/response_start.html +0 -0
  48. {redbot-2.4.2 → redbot-2.5.0}/redbot/formatter/text.py +0 -0
  49. {redbot-2.4.2 → redbot-2.5.0}/redbot/i18n.py +0 -0
  50. {redbot-2.4.2 → redbot-2.5.0}/redbot/note.py +0 -0
  51. {redbot-2.4.2 → redbot-2.5.0}/redbot/resource/__init__.py +0 -0
  52. {redbot-2.4.2 → redbot-2.5.0}/redbot/resource/active_check/__init__.py +0 -0
  53. {redbot-2.4.2 → redbot-2.5.0}/redbot/resource/active_check/base.py +0 -0
  54. {redbot-2.4.2 → redbot-2.5.0}/redbot/resource/active_check/conneg.py +0 -0
  55. {redbot-2.4.2 → redbot-2.5.0}/redbot/resource/active_check/etag_validate.py +0 -0
  56. {redbot-2.4.2 → redbot-2.5.0}/redbot/resource/active_check/lm_validate.py +0 -0
  57. {redbot-2.4.2 → redbot-2.5.0}/redbot/resource/active_check/range.py +0 -0
  58. {redbot-2.4.2 → redbot-2.5.0}/redbot/resource/link_parse.py +0 -0
  59. {redbot-2.4.2 → redbot-2.5.0}/redbot/translations/__init__.py +0 -0
  60. {redbot-2.4.2 → redbot-2.5.0}/redbot/translations/es/LC_MESSAGES/messages.mo +0 -0
  61. {redbot-2.4.2 → redbot-2.5.0}/redbot/translations/es/LC_MESSAGES/messages.po +0 -0
  62. {redbot-2.4.2 → redbot-2.5.0}/redbot/translations/extra_strings.py +0 -0
  63. {redbot-2.4.2 → redbot-2.5.0}/redbot/translations/fr/LC_MESSAGES/messages.mo +0 -0
  64. {redbot-2.4.2 → redbot-2.5.0}/redbot/translations/fr/LC_MESSAGES/messages.po +0 -0
  65. {redbot-2.4.2 → redbot-2.5.0}/redbot/translations/ja/LC_MESSAGES/messages.mo +0 -0
  66. {redbot-2.4.2 → redbot-2.5.0}/redbot/translations/ja/LC_MESSAGES/messages.po +0 -0
  67. {redbot-2.4.2 → redbot-2.5.0}/redbot/translations/messages.pot +0 -0
  68. {redbot-2.4.2 → redbot-2.5.0}/redbot/translations/zh/LC_MESSAGES/messages.mo +0 -0
  69. {redbot-2.4.2 → redbot-2.5.0}/redbot/translations/zh/LC_MESSAGES/messages.po +0 -0
  70. {redbot-2.4.2 → redbot-2.5.0}/redbot/type.py +0 -0
  71. {redbot-2.4.2 → redbot-2.5.0}/redbot/utils.py +0 -0
  72. {redbot-2.4.2 → redbot-2.5.0}/redbot/webui/__init__.py +0 -0
  73. {redbot-2.4.2 → redbot-2.5.0}/redbot/webui/captcha.py +0 -0
  74. {redbot-2.4.2 → redbot-2.5.0}/redbot/webui/handlers/__init__.py +0 -0
  75. {redbot-2.4.2 → redbot-2.5.0}/redbot/webui/handlers/base.py +0 -0
  76. {redbot-2.4.2 → redbot-2.5.0}/redbot/webui/handlers/client_error.py +0 -0
  77. {redbot-2.4.2 → redbot-2.5.0}/redbot/webui/handlers/error.py +0 -0
  78. {redbot-2.4.2 → redbot-2.5.0}/redbot/webui/handlers/save.py +0 -0
  79. {redbot-2.4.2 → redbot-2.5.0}/redbot/webui/handlers/show.py +0 -0
  80. {redbot-2.4.2 → redbot-2.5.0}/redbot/webui/links.py +0 -0
  81. {redbot-2.4.2 → redbot-2.5.0}/redbot/webui/saved_tests.py +0 -0
  82. {redbot-2.4.2 → redbot-2.5.0}/redbot.egg-info/dependency_links.txt +0 -0
  83. {redbot-2.4.2 → redbot-2.5.0}/redbot.egg-info/entry_points.txt +0 -0
  84. {redbot-2.4.2 → redbot-2.5.0}/redbot.egg-info/top_level.txt +0 -0
  85. {redbot-2.4.2 → redbot-2.5.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: redbot
3
- Version: 2.4.2
3
+ Version: 2.5.0
4
4
  Summary: REDbot is lint for HTTP.
5
5
  Author-email: Mark Nottingham <mnot@mnot.net>
6
6
  License-Expression: MIT
@@ -16,6 +16,8 @@ Classifier: Operating System :: MacOS :: MacOS X
16
16
  Requires-Python: >=3.9
17
17
  Description-Content-Type: text/markdown
18
18
  License-File: LICENSE.md
19
+ Requires-Dist: cryptography>=42.0.0
20
+ Requires-Dist: http-sf>=1.0
19
21
  Requires-Dist: httplint>=2026.05.01
20
22
  Requires-Dist: importlib_resources
21
23
  Requires-Dist: Jinja2>=3.1.2
@@ -113,6 +115,46 @@ or
113
115
  > podman run --rm -p 8000:8000 ghcr.io/mnot/redbot
114
116
 
115
117
 
118
+ ## Web Bot Auth
119
+
120
+ REDbot can authenticate its outgoing requests using [Web Bot Auth](https://datatracker.ietf.org/doc/draft-meunier-web-bot-auth-architecture/), which signs requests with an Ed25519 key using [HTTP Message Signatures (RFC 9421)](https://www.rfc-editor.org/rfc/rfc9421). This lets origins verify that requests genuinely come from your REDbot instance. The implementation follows the IETF drafts and is not specific to any one verifier.
121
+
122
+ Requests are sent unsigned by default. REDbot only attaches a signature when the origin challenges for one -- that is, when it returns a `401`, `403`, or `429` response carrying an `Accept-Signature` header -- and then transparently retries the request signed. This avoids advertising the bot's identity to servers that don't ask for it.
123
+
124
+ ### Generating a key
125
+
126
+ Create an Ed25519 private key in PEM form:
127
+
128
+ ~~~ bash
129
+ > openssl genpkey -algorithm ed25519 -out web-bot-auth-key.pem
130
+ ~~~
131
+
132
+ Keep this file private and readable by the REDbot process. The matching public key is published automatically (see below); you do not need to extract it yourself.
133
+
134
+ ### Configuring
135
+
136
+ Set these in `config.txt` (for `redbot_daemon`):
137
+
138
+ ~~~ ini
139
+ web_bot_auth_key = /path/to/web-bot-auth-key.pem
140
+ web_bot_auth_directory = https://your-redbot.example
141
+ ~~~
142
+
143
+ `web_bot_auth_directory` is the HTTPS origin where your public key directory is hosted. When you run `redbot_daemon`, it serves that directory at `/.well-known/http-message-signatures-directory` (a JWKS, self-signed per the spec), so a verifier can fetch your public key. Make sure that path is reachable at the origin you configured.
144
+
145
+ `web_bot_auth_directory` is optional: if you omit it, REDbot uses the origin of `ui_uri`. Because `ui_uri` defaults to `https://redbot.org/`, **enabling signing requires `ui_uri` to be set to your instance's real public origin** — otherwise you would publish the wrong bot identity. Set `web_bot_auth_directory` explicitly if your key directory lives on a different origin than the UI.
146
+
147
+ For the command-line tool, pass the equivalent flags (there is no `ui_uri`, so the directory is required):
148
+
149
+ ~~~ bash
150
+ > redbot --web-bot-auth-key web-bot-auth-key.pem --web-bot-auth-directory https://your-redbot.example https://example.com/
151
+ ~~~
152
+
153
+ ### Registering with a verifier
154
+
155
+ To be recognised by a specific verifier, register your directory URL with them according to their process.
156
+
157
+
116
158
  ## Credits
117
159
 
118
160
  Icons by [Font Awesome](https://fontawesome.com/). REDbot includes code from [tippy.js](https://atomiks.github.io/tippyjs/) and [prettify.js](https://github.com/google/code-prettify).
@@ -67,6 +67,46 @@ or
67
67
  > podman run --rm -p 8000:8000 ghcr.io/mnot/redbot
68
68
 
69
69
 
70
+ ## Web Bot Auth
71
+
72
+ REDbot can authenticate its outgoing requests using [Web Bot Auth](https://datatracker.ietf.org/doc/draft-meunier-web-bot-auth-architecture/), which signs requests with an Ed25519 key using [HTTP Message Signatures (RFC 9421)](https://www.rfc-editor.org/rfc/rfc9421). This lets origins verify that requests genuinely come from your REDbot instance. The implementation follows the IETF drafts and is not specific to any one verifier.
73
+
74
+ Requests are sent unsigned by default. REDbot only attaches a signature when the origin challenges for one -- that is, when it returns a `401`, `403`, or `429` response carrying an `Accept-Signature` header -- and then transparently retries the request signed. This avoids advertising the bot's identity to servers that don't ask for it.
75
+
76
+ ### Generating a key
77
+
78
+ Create an Ed25519 private key in PEM form:
79
+
80
+ ~~~ bash
81
+ > openssl genpkey -algorithm ed25519 -out web-bot-auth-key.pem
82
+ ~~~
83
+
84
+ Keep this file private and readable by the REDbot process. The matching public key is published automatically (see below); you do not need to extract it yourself.
85
+
86
+ ### Configuring
87
+
88
+ Set these in `config.txt` (for `redbot_daemon`):
89
+
90
+ ~~~ ini
91
+ web_bot_auth_key = /path/to/web-bot-auth-key.pem
92
+ web_bot_auth_directory = https://your-redbot.example
93
+ ~~~
94
+
95
+ `web_bot_auth_directory` is the HTTPS origin where your public key directory is hosted. When you run `redbot_daemon`, it serves that directory at `/.well-known/http-message-signatures-directory` (a JWKS, self-signed per the spec), so a verifier can fetch your public key. Make sure that path is reachable at the origin you configured.
96
+
97
+ `web_bot_auth_directory` is optional: if you omit it, REDbot uses the origin of `ui_uri`. Because `ui_uri` defaults to `https://redbot.org/`, **enabling signing requires `ui_uri` to be set to your instance's real public origin** — otherwise you would publish the wrong bot identity. Set `web_bot_auth_directory` explicitly if your key directory lives on a different origin than the UI.
98
+
99
+ For the command-line tool, pass the equivalent flags (there is no `ui_uri`, so the directory is required):
100
+
101
+ ~~~ bash
102
+ > redbot --web-bot-auth-key web-bot-auth-key.pem --web-bot-auth-directory https://your-redbot.example https://example.com/
103
+ ~~~
104
+
105
+ ### Registering with a verifier
106
+
107
+ To be recognised by a specific verifier, register your directory URL with them according to their process.
108
+
109
+
70
110
  ## Credits
71
111
 
72
112
  Icons by [Font Awesome](https://fontawesome.com/). REDbot includes code from [tippy.js](https://atomiks.github.io/tippyjs/) and [prettify.js](https://github.com/google/code-prettify).
@@ -20,6 +20,8 @@ classifiers = [
20
20
  ]
21
21
  license-files = ["LICENSE.md"]
22
22
  dependencies = [
23
+ "cryptography >= 42.0.0",
24
+ "http-sf >= 1.0",
23
25
  "httplint >= 2026.05.01",
24
26
  "importlib_resources",
25
27
  "Jinja2 >= 3.1.2",
@@ -74,42 +76,5 @@ namespaces = false
74
76
  [tool.setuptools]
75
77
  include-package-data = true
76
78
 
77
- [tool.mypy]
78
- strict = true
79
- show_error_codes = true
80
-
81
- [tool.pylint.basic]
82
- function-rgx = "[a-z_][a-z0-9_]{1,30}$"
83
- variable-rgx = "[a-z_][a-z0-9_]{1,30}$"
84
- attr-rgx = "[a-z_][a-z0-9_]{1,30}$"
85
- argument-rgx = "[a-z_][a-z0-9_]{1,30}$"
86
- class-attribute-rgx = "([A-Za-z_][A-Za-z0-9_]{1,30}|(__.*__))$"
87
- method-rgx = "[a-z_][a-z0-9_]{1,30}$"
88
- class-rgx = "[a-zA-Z0-9_]+$"
89
-
90
- [tool.pylint.messages_control]
91
- disable = "C0114,C0115,C0116,W0613,R0903"
92
-
93
- [tool.pylint.reports]
94
- reports = false
95
-
96
- [tool.pylint.variables]
97
- dummy-variables-rgx = "response_start|response_body|response_done|error|formatter_done|check_done|_"
98
-
99
- [tool.pylint.design]
100
- max-args=10
101
- max-positional-arguments=10
102
- max-locals=35
103
- max-branches=25
104
- max-statements=100
105
- max-attributes=40
106
- min-public-methods=1
107
-
108
- [tool.pylint.similarities]
109
- min-similarity-lines = 10
110
-
111
79
  [tool.black]
112
80
  line-length = 100
113
-
114
- [tool.isort]
115
- profile = "black"
@@ -2,7 +2,7 @@
2
2
  This is the Resource Expert Droid.
3
3
  """
4
4
 
5
- __version__ = "2.4.2"
5
+ __version__ = "2.5.0"
6
6
  __author__ = "Mark Nottingham <mnot@mnot.net>"
7
7
  __copyright__ = """\
8
8
  Copyright (c) 2008- Mark Nottingham
@@ -12,6 +12,7 @@ import thor
12
12
 
13
13
  from redbot.formatter import available_formatters, find_formatter
14
14
  from redbot.resource import HttpResource
15
+ from redbot.webbotauth import WebBotAuthError, load_signer
15
16
 
16
17
 
17
18
  def main() -> None:
@@ -38,12 +39,35 @@ def main() -> None:
38
39
  default="text",
39
40
  help="output format",
40
41
  )
42
+ parser.add_argument(
43
+ "--web-bot-auth-key",
44
+ action="store",
45
+ dest="web_bot_auth_key",
46
+ help="path to an Ed25519 private key (PEM) for signing requests with Web Bot Auth",
47
+ )
48
+ parser.add_argument(
49
+ "--web-bot-auth-directory",
50
+ action="store",
51
+ dest="web_bot_auth_directory",
52
+ help="HTTPS origin hosting this bot's key directory (the Signature-Agent value)",
53
+ )
41
54
  args = parser.parse_args()
42
55
 
56
+ redbot_config = {"enable_local_access": "True"}
57
+ if args.web_bot_auth_key:
58
+ redbot_config["web_bot_auth_key"] = args.web_bot_auth_key
59
+ if args.web_bot_auth_directory:
60
+ redbot_config["web_bot_auth_directory"] = args.web_bot_auth_directory
43
61
  config_parser = ConfigParser()
44
- config_parser.read_dict({"redbot": {"enable_local_access": "True"}})
62
+ config_parser.read_dict({"redbot": redbot_config})
45
63
  config = config_parser["redbot"]
46
64
 
65
+ try:
66
+ load_signer(config) # validate Web Bot Auth config up front
67
+ except WebBotAuthError as why:
68
+ sys.stderr.write(f"Web Bot Auth configuration error: {why}\n")
69
+ sys.exit(1)
70
+
47
71
  resource = HttpResource(config, descend=args.descend)
48
72
  resource.set_request(args.url)
49
73
 
@@ -34,6 +34,13 @@ from thor.tcp import TcpConnection
34
34
 
35
35
  import redbot
36
36
  from redbot.type import RawHeaderListType
37
+ from redbot.webbotauth import (
38
+ DIRECTORY_CONTENT_TYPE,
39
+ DIRECTORY_MAX_AGE,
40
+ DIRECTORY_PATH,
41
+ WebBotAuthError,
42
+ load_signer,
43
+ )
37
44
  from redbot.webui import RedWebUi
38
45
  from redbot.webui.saved_tests import clean_saved_tests
39
46
 
@@ -78,6 +85,15 @@ class RedBotServer:
78
85
  if SYSTEMD_NOTIFIER is not None:
79
86
  thor.schedule(self.watchdog_freq, self.watchdog_ping)
80
87
 
88
+ # Set up Web Bot Auth signing (validate config up front).
89
+ try:
90
+ self.signer = load_signer(config)
91
+ except WebBotAuthError as why:
92
+ self.console(f"FATAL: Web Bot Auth configuration error: {why}")
93
+ sys.exit(1)
94
+ if self.signer is not None:
95
+ self.console(f"Web Bot Auth enabled (keyid {self.signer.keyid})")
96
+
81
97
  self.static_files = resource_files("redbot.assets")
82
98
  self.extra_files = {}
83
99
  extra_base_dir = self.config.get("extra_base_dir", None)
@@ -342,6 +358,8 @@ class RedRequestHandler:
342
358
  p_uri = urlsplit(self.uri)
343
359
  except UnicodeDecodeError:
344
360
  return self.bad_request(b"That's not a URL.")
361
+ if p_uri.path == DIRECTORY_PATH.encode("ascii"):
362
+ return self.serve_directory()
345
363
  if (
346
364
  p_uri.path.startswith(self.server.static_root + b"/")
347
365
  or p_uri.path in self.server.extra_files
@@ -407,6 +425,38 @@ in standalone server mode. Details follow.
407
425
  self.exchange.response_done([])
408
426
  return None
409
427
 
428
+ def serve_directory(self) -> None:
429
+ "Serve the Web Bot Auth key directory (a self-signed JWKS)."
430
+ signer = self.server.signer
431
+ if signer is None:
432
+ return self.not_found(DIRECTORY_PATH.encode("ascii"))
433
+ body = signer.directory_json()
434
+ headers = [
435
+ (b"Content-Type", DIRECTORY_CONTENT_TYPE.encode("ascii")),
436
+ (b"Cache-Control", b"max-age=%d" % DIRECTORY_MAX_AGE),
437
+ ]
438
+ authority = self.request_authority()
439
+ if authority:
440
+ headers += signer.sign_directory(authority)
441
+ self.exchange.response_start(b"200", b"OK", headers)
442
+ self.exchange.response_body(body)
443
+ self.exchange.response_done([])
444
+ return None
445
+
446
+ def request_authority(self) -> str:
447
+ "The authority (Host) of the incoming request, for signing."
448
+ for name, value in self.req_hdrs:
449
+ if name.lower() == b"host":
450
+ host = value.decode("ascii", "replace").strip().lower()
451
+ # Drop a default port so the signed @authority matches what a
452
+ # verifier normalizes to (RFC 9421 derives @authority this way).
453
+ for suffix in (":443", ":80"):
454
+ if host.endswith(suffix):
455
+ host = host[: -len(suffix)]
456
+ break
457
+ return host
458
+ return ""
459
+
410
460
  def not_found(self, path: bytes) -> None:
411
461
  headers = []
412
462
  headers.append((b"Content-Type", b"text/plain"))
@@ -21,8 +21,28 @@ from redbot import __version__
21
21
  from redbot.i18n import _
22
22
  from redbot.note import RedbotNote
23
23
  from redbot.type import RawHeaderListType, StrHeaderListType
24
+ from redbot.webbotauth import WebBotAuthError, load_signer
24
25
 
25
- UA_STRING = f"RED/{__version__} (https://redbot.org/)".encode("ascii")
26
+ # Fallback UI URL advertised in the User-Agent when ui_uri is not configured
27
+ # (e.g. the CLI). The public instance's ui_uri defaults to the same value.
28
+ DEFAULT_UI_URI = "https://redbot.org/"
29
+
30
+
31
+ def ua_string(config: SectionProxy) -> bytes:
32
+ "The User-Agent for outgoing requests, pointing at this instance's UI URL."
33
+ uri = config.get("ui_uri", "").strip() or DEFAULT_UI_URI
34
+ return f"RED/{__version__} ({uri})".encode("ascii", "replace")
35
+
36
+
37
+ # Status codes on which a response carrying an Accept-Signature header is treated
38
+ # as a Web Bot Auth challenge to re-send the request signed
39
+ # (draft-meunier-web-bot-auth-architecture, Section 4.3).
40
+ WBA_CHALLENGE_STATUSES = {b"401", b"403", b"429"}
41
+
42
+
43
+ def _has_accept_signature(res_headers: RawHeaderListType) -> bool:
44
+ "Whether the response asks the client to sign (RFC 9421 Accept-Signature)."
45
+ return any(name.lower() == b"accept-signature" for name, _v in res_headers)
26
46
 
27
47
 
28
48
  class RedHttpClient(thor.http.HttpClient):
@@ -74,6 +94,7 @@ class RedFetcher(thor.events.EventEmitter):
74
94
  self.fetch_started = False
75
95
  self.fetch_error: Optional[httperr.HttpError] = None
76
96
  self.fetch_done = False
97
+ self._wba_retried = False
77
98
  self.setup_check_ip()
78
99
 
79
100
  def __getstate__(self) -> Dict[str, Any]:
@@ -159,7 +180,15 @@ class RedFetcher(thor.events.EventEmitter):
159
180
  assert self.request.uri, "uri not set in check"
160
181
 
161
182
  if "user-agent" not in [i[0].lower() for i in self.request.headers.text]:
162
- self.request.headers.process([(b"User-Agent", UA_STRING)])
183
+ self.request.headers.process([(b"User-Agent", ua_string(self.config))])
184
+ # Requests are sent unsigned; Web Bot Auth signatures are only added when
185
+ # the origin challenges for them (see _response_start).
186
+ self._send_request()
187
+
188
+ def _send_request(self, extra_headers: Optional[RawHeaderListType] = None) -> None:
189
+ "Start an exchange for the current request, optionally adding extra headers."
190
+ assert self.request.method, "method not set in _send_request"
191
+ assert self.request.uri, "uri not set in _send_request"
163
192
  self.exchange = self.client.exchange()
164
193
  self.exchange.on("response_nonfinal", self._response_nonfinal)
165
194
  self.exchange.once("response_start", self._response_start)
@@ -176,6 +205,8 @@ class RedFetcher(thor.events.EventEmitter):
176
205
  (k.encode("ascii", "replace"), v.encode("ascii", "replace"))
177
206
  for (k, v) in self.request.headers.text
178
207
  ]
208
+ if extra_headers:
209
+ req_hdrs += extra_headers
179
210
  self.request.start_time = time.time()
180
211
  self.exchange.request_start(
181
212
  self.request.method.encode("ascii"),
@@ -189,6 +220,19 @@ class RedFetcher(thor.events.EventEmitter):
189
220
  if not self.fetch_done: # the request could have immediately failed.
190
221
  self.exchange.request_done([])
191
222
 
223
+ def _abort_exchange(self) -> None:
224
+ "Detach from and close the current exchange (e.g. to retry the request)."
225
+ if hasattr(self, "exchange"):
226
+ self.exchange.remove_listeners(
227
+ "response_nonfinal",
228
+ "response_start",
229
+ "response_body",
230
+ "response_done",
231
+ "error",
232
+ )
233
+ if self.exchange.conn:
234
+ self.exchange.conn.close()
235
+
192
236
  def _response_nonfinal(
193
237
  self, status: bytes, phrase: bytes, res_headers: RawHeaderListType
194
238
  ) -> None:
@@ -219,6 +263,26 @@ class RedFetcher(thor.events.EventEmitter):
219
263
  "Process the response start-line and headers."
220
264
  if self.fetch_done:
221
265
  return
266
+ if not self._wba_retried and status in WBA_CHALLENGE_STATUSES and self.request.uri:
267
+ if _has_accept_signature(res_headers):
268
+ try:
269
+ signer = load_signer(self.config)
270
+ except WebBotAuthError as why:
271
+ # A key problem arising mid-run (e.g. rotated to a bad file)
272
+ # should not crash the fetch; just proceed unsigned.
273
+ self.emit("debug", f"Web Bot Auth disabled for this request: {why}")
274
+ signer = None
275
+ if signer is not None:
276
+ self._wba_retried = True
277
+ self.emit(
278
+ "debug",
279
+ f"Web Bot Auth challenge for {self.request.uri}; retrying signed",
280
+ )
281
+ self._abort_exchange()
282
+ self.nonfinal_responses = []
283
+ self.transfer_out = 0
284
+ self._send_request(signer.sign_request(self.request.uri))
285
+ return
222
286
  self.response.start_time = time.time()
223
287
  assert self.exchange.res_version, "exchange.res_version not set in _response_start"
224
288
  self.response.process_response_topline(self.exchange.res_version, status, phrase)
@@ -0,0 +1,244 @@
1
+ """
2
+ Web Bot Auth support for REDbot.
3
+
4
+ Web Bot Auth lets an automated client prove its identity to an origin by
5
+ attaching an HTTP Message Signature (RFC 9421) to its requests, signed with an
6
+ Ed25519 key whose public half is published in a directory at a well-known
7
+ location.
8
+
9
+ This module implements the IETF drafts
10
+ (draft-meunier-web-bot-auth-architecture and
11
+ draft-meunier-http-message-signatures-directory) and is not specific to any
12
+ particular verifier.
13
+
14
+ It provides:
15
+
16
+ * ``WebBotAuthSigner`` - signs outgoing requests and the directory response.
17
+ * ``load_signer`` - builds (and caches) a signer from REDbot configuration.
18
+ """
19
+
20
+ import base64
21
+ import hashlib
22
+ import json
23
+ import os
24
+ import secrets
25
+ import time
26
+ from configparser import SectionProxy
27
+ from typing import Dict, List, Optional, Tuple
28
+ from urllib.parse import urlsplit
29
+
30
+ import http_sf
31
+ from cryptography.hazmat.primitives import serialization
32
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
33
+
34
+ # The tag identifying the purpose of a signature (draft-meunier-web-bot-auth).
35
+ REQUEST_TAG = "web-bot-auth"
36
+ # The tag for a signature over the directory response itself.
37
+ DIRECTORY_TAG = "http-message-signatures-directory"
38
+ # Where the key directory is served, relative to the origin root.
39
+ DIRECTORY_PATH = "/.well-known/http-message-signatures-directory"
40
+ # Media type for the key directory.
41
+ DIRECTORY_CONTENT_TYPE = "application/http-message-signatures-directory+json"
42
+
43
+ # Default lifetime of a request signature, in seconds. Web Bot Auth allows up to
44
+ # 24h; we keep it short to limit the window for replay.
45
+ DEFAULT_VALIDITY = 300
46
+ # Lifetime of the directory response signature. The directory is cacheable for a
47
+ # day (see DIRECTORY_MAX_AGE), so its signature must stay valid at least that
48
+ # long, or a verifier re-checking a cached copy would see it expired.
49
+ DIRECTORY_VALIDITY = 86400
50
+ # Cache lifetime advertised for the directory, in seconds.
51
+ DIRECTORY_MAX_AGE = 86400
52
+ # The label tying together the Signature-Input and Signature dictionary members.
53
+ SIG_LABEL = "sig1"
54
+
55
+
56
+ class WebBotAuthError(Exception):
57
+ "Raised when Web Bot Auth is misconfigured."
58
+
59
+
60
+ def _b64url(data: bytes) -> str:
61
+ "base64url-encode without padding (RFC 7515 / RFC 8037)."
62
+ return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
63
+
64
+
65
+ class WebBotAuthSigner:
66
+ """
67
+ Signs requests and directory responses for Web Bot Auth.
68
+
69
+ ``key_pem`` is an Ed25519 private key in PEM (PKCS#8) form. ``directory`` is
70
+ the HTTPS origin that hosts this bot's key directory; it becomes the
71
+ Signature-Agent header value, and verifiers append the well-known directory
72
+ path to it. ``validity`` is the signature lifetime in seconds.
73
+ """
74
+
75
+ def __init__(
76
+ self,
77
+ key_pem: bytes,
78
+ directory: str,
79
+ validity: int = DEFAULT_VALIDITY,
80
+ key_created: Optional[int] = None,
81
+ ) -> None:
82
+ try:
83
+ key = serialization.load_pem_private_key(key_pem, password=None)
84
+ except (ValueError, TypeError) as why:
85
+ raise WebBotAuthError(f"Couldn't load Web Bot Auth key: {why}") from why
86
+ if not isinstance(key, Ed25519PrivateKey):
87
+ raise WebBotAuthError("Web Bot Auth key must be an Ed25519 private key.")
88
+ self._key = key
89
+ self.directory = directory.strip().strip('"')
90
+ self.validity = validity
91
+ self.key_created = key_created
92
+
93
+ raw_public = key.public_key().public_bytes(
94
+ serialization.Encoding.Raw, serialization.PublicFormat.Raw
95
+ )
96
+ self.public_x = _b64url(raw_public)
97
+ # RFC 8037 Appendix A.3 / RFC 7638: thumbprint over the canonical JWK
98
+ # (members lexicographically ordered, no whitespace).
99
+ thumb_input = json.dumps(
100
+ {"crv": "Ed25519", "kty": "OKP", "x": self.public_x},
101
+ separators=(",", ":"),
102
+ sort_keys=True,
103
+ ).encode("ascii")
104
+ self.keyid = _b64url(hashlib.sha256(thumb_input).digest())
105
+
106
+ @staticmethod
107
+ def authority(uri: str) -> str:
108
+ "The RFC 9421 @authority derived component for a target URI."
109
+ parts = urlsplit(uri)
110
+ host = (parts.hostname or "").lower()
111
+ default = {"http": 80, "https": 443}.get(parts.scheme.lower())
112
+ try:
113
+ port = parts.port
114
+ except ValueError:
115
+ port = None
116
+ if port is not None and port != default:
117
+ return f"{host}:{port}"
118
+ return host
119
+
120
+ def _build_signature(
121
+ self,
122
+ components: List[Tuple[str, str]],
123
+ tag: str,
124
+ validity: int,
125
+ ) -> Tuple[str, str]:
126
+ """
127
+ Construct the RFC 9421 signature base over ``components`` (each an
128
+ ordered (component-name, component-value) pair), sign it, and return the
129
+ Signature-Input and Signature dictionary-member values for SIG_LABEL.
130
+ ``validity`` is the signature lifetime in seconds.
131
+
132
+ Structured-field values are serialized with ``http_sf`` so the
133
+ @signature-params line in the base and the Signature-Input header are
134
+ produced identically.
135
+ """
136
+ created = int(time.time())
137
+ params: http_sf.types.ParamsType = {
138
+ "created": created,
139
+ "expires": created + validity,
140
+ "keyid": self.keyid,
141
+ "alg": "ed25519",
142
+ "nonce": base64.b64encode(secrets.token_bytes(32)).decode("ascii"),
143
+ "tag": tag,
144
+ }
145
+ covered: List[http_sf.types.ItemType] = [name for name, _ in components]
146
+ inner_list: http_sf.types.InnerListType = (covered, params)
147
+
148
+ base_lines = [f"{http_sf.ser(name)}: {value}" for name, value in components]
149
+ base_lines.append(f'{http_sf.ser("@signature-params")}: {http_sf.ser([inner_list])}')
150
+ signature = self._key.sign("\n".join(base_lines).encode("utf-8"))
151
+
152
+ return http_sf.ser({SIG_LABEL: inner_list}), http_sf.ser({SIG_LABEL: signature})
153
+
154
+ def sign_request(self, uri: str) -> List[Tuple[bytes, bytes]]:
155
+ "Return the Web Bot Auth headers to add to a request for ``uri``."
156
+ agent_field = http_sf.ser(self.directory) # Signature-Agent value (an sf-string)
157
+ sig_input, sig = self._build_signature(
158
+ [("@authority", self.authority(uri)), ("signature-agent", agent_field)],
159
+ REQUEST_TAG,
160
+ self.validity,
161
+ )
162
+ return [
163
+ (b"Signature-Agent", agent_field.encode("ascii")),
164
+ (b"Signature-Input", sig_input.encode("ascii")),
165
+ (b"Signature", sig.encode("ascii")),
166
+ ]
167
+
168
+ def sign_directory(self, authority: str) -> List[Tuple[bytes, bytes]]:
169
+ "Return the signature headers for a directory response served at ``authority``."
170
+ sig_input, sig = self._build_signature(
171
+ [("@authority", authority)], DIRECTORY_TAG, DIRECTORY_VALIDITY
172
+ )
173
+ return [
174
+ (b"Signature-Input", sig_input.encode("ascii")),
175
+ (b"Signature", sig.encode("ascii")),
176
+ ]
177
+
178
+ def directory_json(self) -> bytes:
179
+ "The key directory body: a JWKS containing the public key."
180
+ key: Dict[str, object] = {
181
+ "kty": "OKP",
182
+ "crv": "Ed25519",
183
+ "x": self.public_x,
184
+ "kid": self.keyid,
185
+ "use": "sig",
186
+ }
187
+ if self.key_created is not None:
188
+ key["nbf"] = self.key_created
189
+ return json.dumps({"keys": [key]}, separators=(",", ":")).encode("utf-8")
190
+
191
+
192
+ _SIGNER_CACHE: Dict[str, WebBotAuthSigner] = {}
193
+
194
+
195
+ def _ui_uri_origin(config: SectionProxy) -> str:
196
+ "The scheme://host[:port] origin of ui_uri, or '' if it isn't a usable URL."
197
+ parts = urlsplit(config.get("ui_uri", "").strip())
198
+ if not parts.scheme or not parts.netloc:
199
+ return ""
200
+ return f"{parts.scheme}://{parts.netloc}"
201
+
202
+
203
+ def load_signer(config: SectionProxy) -> Optional[WebBotAuthSigner]:
204
+ """
205
+ Build a ``WebBotAuthSigner`` from REDbot configuration, or return None if
206
+ Web Bot Auth is not configured. Raises ``WebBotAuthError`` on misconfiguration
207
+ so problems surface at startup rather than mid-request. Successful signers are
208
+ cached, keyed by key path, directory, validity, and the key file's mtime.
209
+
210
+ The key directory location is ``web_bot_auth_directory``; if unset, it
211
+ defaults to the origin of ``ui_uri`` (so enabling signing requires ``ui_uri``
212
+ to be set correctly to this instance's public origin).
213
+ """
214
+ key_path = config.get("web_bot_auth_key", "").strip()
215
+ directory = config.get("web_bot_auth_directory", "").strip()
216
+ if not key_path:
217
+ if directory:
218
+ raise WebBotAuthError("web_bot_auth_directory is set but web_bot_auth_key is not.")
219
+ return None
220
+ if not directory:
221
+ directory = _ui_uri_origin(config)
222
+ if not directory:
223
+ raise WebBotAuthError(
224
+ "Web Bot Auth needs a key directory location: set web_bot_auth_directory, "
225
+ "or (for the daemon) set ui_uri to this instance's public origin."
226
+ )
227
+ validity = config.getint("web_bot_auth_validity", fallback=DEFAULT_VALIDITY)
228
+
229
+ try:
230
+ mtime = os.path.getmtime(key_path)
231
+ except OSError as why:
232
+ raise WebBotAuthError(f"Can't read Web Bot Auth key '{key_path}': {why}") from why
233
+
234
+ cache_key = "\0".join([key_path, directory, str(validity), str(mtime)])
235
+ if cache_key not in _SIGNER_CACHE:
236
+ try:
237
+ with open(key_path, "rb") as fh:
238
+ key_pem = fh.read()
239
+ except OSError as why:
240
+ raise WebBotAuthError(f"Can't read Web Bot Auth key '{key_path}': {why}") from why
241
+ _SIGNER_CACHE[cache_key] = WebBotAuthSigner(
242
+ key_pem, directory, validity, key_created=int(mtime)
243
+ )
244
+ return _SIGNER_CACHE[cache_key]
@@ -43,7 +43,10 @@ def _validate_req_hdrs(raw: List[str]) -> Tuple[List[Tuple[str, str]], Optional[
43
43
  if not name:
44
44
  return [], "Request header field name is empty."
45
45
  if any(c in _BAD_HEADER_NAME_CHARS or ord(c) < 33 or ord(c) > 126 for c in name):
46
- return [], f"Request header field name {name!r} contains invalid characters."
46
+ return (
47
+ [],
48
+ f"Request header field name {name!r} contains invalid characters.",
49
+ )
47
50
  if "\r" in value or "\n" in value:
48
51
  return [], f"Request header {name!r} value contains CR or LF."
49
52
  headers.append((name, value))
@@ -23,7 +23,10 @@ class RateLimiter:
23
23
  self.loop = thor.loop
24
24
 
25
25
  def process(
26
- self, webui: RedWebUiProtocol, test_uri: str, error_response: Callable[..., None]
26
+ self,
27
+ webui: RedWebUiProtocol,
28
+ test_uri: str,
29
+ error_response: Callable[..., None],
27
30
  ) -> None:
28
31
  """Enforce limits on webui."""
29
32
  if not self.running:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: redbot
3
- Version: 2.4.2
3
+ Version: 2.5.0
4
4
  Summary: REDbot is lint for HTTP.
5
5
  Author-email: Mark Nottingham <mnot@mnot.net>
6
6
  License-Expression: MIT
@@ -16,6 +16,8 @@ Classifier: Operating System :: MacOS :: MacOS X
16
16
  Requires-Python: >=3.9
17
17
  Description-Content-Type: text/markdown
18
18
  License-File: LICENSE.md
19
+ Requires-Dist: cryptography>=42.0.0
20
+ Requires-Dist: http-sf>=1.0
19
21
  Requires-Dist: httplint>=2026.05.01
20
22
  Requires-Dist: importlib_resources
21
23
  Requires-Dist: Jinja2>=3.1.2
@@ -113,6 +115,46 @@ or
113
115
  > podman run --rm -p 8000:8000 ghcr.io/mnot/redbot
114
116
 
115
117
 
118
+ ## Web Bot Auth
119
+
120
+ REDbot can authenticate its outgoing requests using [Web Bot Auth](https://datatracker.ietf.org/doc/draft-meunier-web-bot-auth-architecture/), which signs requests with an Ed25519 key using [HTTP Message Signatures (RFC 9421)](https://www.rfc-editor.org/rfc/rfc9421). This lets origins verify that requests genuinely come from your REDbot instance. The implementation follows the IETF drafts and is not specific to any one verifier.
121
+
122
+ Requests are sent unsigned by default. REDbot only attaches a signature when the origin challenges for one -- that is, when it returns a `401`, `403`, or `429` response carrying an `Accept-Signature` header -- and then transparently retries the request signed. This avoids advertising the bot's identity to servers that don't ask for it.
123
+
124
+ ### Generating a key
125
+
126
+ Create an Ed25519 private key in PEM form:
127
+
128
+ ~~~ bash
129
+ > openssl genpkey -algorithm ed25519 -out web-bot-auth-key.pem
130
+ ~~~
131
+
132
+ Keep this file private and readable by the REDbot process. The matching public key is published automatically (see below); you do not need to extract it yourself.
133
+
134
+ ### Configuring
135
+
136
+ Set these in `config.txt` (for `redbot_daemon`):
137
+
138
+ ~~~ ini
139
+ web_bot_auth_key = /path/to/web-bot-auth-key.pem
140
+ web_bot_auth_directory = https://your-redbot.example
141
+ ~~~
142
+
143
+ `web_bot_auth_directory` is the HTTPS origin where your public key directory is hosted. When you run `redbot_daemon`, it serves that directory at `/.well-known/http-message-signatures-directory` (a JWKS, self-signed per the spec), so a verifier can fetch your public key. Make sure that path is reachable at the origin you configured.
144
+
145
+ `web_bot_auth_directory` is optional: if you omit it, REDbot uses the origin of `ui_uri`. Because `ui_uri` defaults to `https://redbot.org/`, **enabling signing requires `ui_uri` to be set to your instance's real public origin** — otherwise you would publish the wrong bot identity. Set `web_bot_auth_directory` explicitly if your key directory lives on a different origin than the UI.
146
+
147
+ For the command-line tool, pass the equivalent flags (there is no `ui_uri`, so the directory is required):
148
+
149
+ ~~~ bash
150
+ > redbot --web-bot-auth-key web-bot-auth-key.pem --web-bot-auth-directory https://your-redbot.example https://example.com/
151
+ ~~~
152
+
153
+ ### Registering with a verifier
154
+
155
+ To be recognised by a specific verifier, register your directory URL with them according to their process.
156
+
157
+
116
158
  ## Credits
117
159
 
118
160
  Icons by [Font Awesome](https://fontawesome.com/). REDbot includes code from [tippy.js](https://atomiks.github.io/tippyjs/) and [prettify.js](https://github.com/google/code-prettify).
@@ -9,6 +9,7 @@ redbot/i18n.py
9
9
  redbot/note.py
10
10
  redbot/type.py
11
11
  redbot/utils.py
12
+ redbot/webbotauth.py
12
13
  redbot.egg-info/PKG-INFO
13
14
  redbot.egg-info/SOURCES.txt
14
15
  redbot.egg-info/dependency_links.txt
@@ -1,3 +1,5 @@
1
+ cryptography>=42.0.0
2
+ http-sf>=1.0
1
3
  httplint>=2026.05.01
2
4
  importlib_resources
3
5
  Jinja2>=3.1.2
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes