oath-relay 0.1.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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Peng Ding
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,242 @@
1
+ Metadata-Version: 2.4
2
+ Name: oath-relay
3
+ Version: 0.1.0
4
+ Summary: Share a stateful HOTP token across devices from one counter
5
+ Author: Peng Ding
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/Oaklight/oath-relay
8
+ Project-URL: Issues, https://github.com/Oaklight/oath-relay/issues
9
+ Keywords: hotp,totp,otp,2fa,oath,duo
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: End Users/Desktop
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Topic :: Security
18
+ Classifier: Topic :: System :: Systems Administration
19
+ Requires-Python: >=3.11
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Provides-Extra: dev
23
+ Requires-Dist: pytest>=8; extra == "dev"
24
+ Requires-Dist: ruff>=0.11; extra == "dev"
25
+ Requires-Dist: build; extra == "dev"
26
+ Dynamic: license-file
27
+
28
+ # oath-relay
29
+
30
+ **Share a stateful HOTP token across devices from a single counter.**
31
+
32
+ `oath-relay` is a tiny, dependency-free service that owns the counter for one
33
+ or more HOTP tokens and hands out the next code over HTTP. It exists because
34
+ HOTP is the one 2FA scheme that cannot be synced the way TOTP can.
35
+
36
+ ```
37
+ HOTP = HMAC-SHA1(secret, counter)
38
+ TOTP = HMAC-SHA1(secret, floor(unixtime / 30))
39
+ ```
40
+
41
+ TOTP is a pure function of the clock, so every device computes the same code
42
+ from the same secret. HOTP is a function of a **mutable counter**: two clients
43
+ holding the same secret advance independently and diverge permanently. There
44
+ is no way to merge two counters. The only correct answer is one writer.
45
+
46
+ `oath-relay` is that writer.
47
+
48
+ ## Why not just use a password manager?
49
+
50
+ Bitwarden — and Vaultwarden, which is what this was built against — supports
51
+ TOTP only. Handed an `otpauth://hotp/...` URI it silently computes a TOTP from
52
+ the same secret, so the codes never match. Measured:
53
+
54
+ ```
55
+ Bitwarden from an otpauth://hotp/ URI : 332512 (== the TOTP for that secret)
56
+ Real HOTP at counter 1 : 996554
57
+ ```
58
+
59
+ Some services are HOTP-only. Cisco Duo, for instance, decides per tenant via a
60
+ `use_totp` flag in the enrolment response; when it is `false`, no TOTP client
61
+ will ever work.
62
+
63
+ ## Install
64
+
65
+ ```bash
66
+ pipx install oath-relay # or: pip install oath-relay
67
+ ```
68
+
69
+ Python 3.11+. No runtime dependencies outside the standard library.
70
+
71
+ ## Quick start
72
+
73
+ ```bash
74
+ oath-relay init # writes ./oath-relay.toml (0600)
75
+ $EDITOR oath-relay.toml # add your tokens
76
+ oath-relay hash # generate auth.basic_password_hash
77
+ oath-relay serve # http://127.0.0.1:8790
78
+ ```
79
+
80
+ ```bash
81
+ curl -X POST -H 'Authorization: Bearer <token>' \
82
+ http://127.0.0.1:8790/api/tokens/my-token/next
83
+ # {"name":"my-token","type":"hotp","code":"755224","counter":0,"next_counter":1}
84
+ ```
85
+
86
+ Or open `http://127.0.0.1:8790/` in a browser and press the button.
87
+
88
+ ## Configuration
89
+
90
+ Static configuration (secrets) lives in TOML. Mutable state (counters) lives
91
+ in a separate JSON file, so the state file is safe to back up and diff.
92
+
93
+ ```toml
94
+ [server]
95
+ host = "127.0.0.1"
96
+ port = 8790
97
+ allow_peek = false
98
+
99
+ [state]
100
+ path = "./state/state.json"
101
+
102
+ [auth]
103
+ basic_user = "alice"
104
+ basic_password_hash = "scrypt$..." # from `oath-relay hash`
105
+ bearer_tokens = ["long-random-string"] # several allowed, for rotation
106
+
107
+ [[tokens]]
108
+ name = "example"
109
+ type = "hotp" # or "totp"
110
+ secret = "JBSWY3DPEHPK3PXP"
111
+ digits = 6
112
+ algorithm = "sha1" # sha1 | sha256 | sha512
113
+ counter_start = 0 # used only until the first code is issued
114
+ ```
115
+
116
+ Environment overrides use `OATH_RELAY_<SECTION>__<KEY>`:
117
+
118
+ ```bash
119
+ OATH_RELAY_SERVER__PORT=9000
120
+ OATH_RELAY_AUTH__BEARER_TOKENS=one,two
121
+ ```
122
+
123
+ Config search order: `./oath-relay.toml`, `/config/config.toml`,
124
+ `/etc/oath-relay/config.toml`. Override with `--config PATH`.
125
+
126
+ ## HTTP API
127
+
128
+ | Method | Path | Effect |
129
+ | --- | --- | --- |
130
+ | GET | `/` | HTML page, one button per token |
131
+ | GET | `/healthz` | `{"status":"ok"}`, no auth |
132
+ | GET | `/api/tokens` | Names, types and counters; never secrets |
133
+ | GET | `/api/tokens/{name}/status` | Current counter, read-only |
134
+ | POST | `/api/tokens/{name}/next` | **Issues a code and advances the counter** |
135
+ | POST | `/api/tokens/{name}/resync` | Force a counter (recovery) |
136
+ | GET | `/api/tokens/{name}/peek` | Code without advancing (opt-in, see below) |
137
+
138
+ `POST .../next` accepts no body and returns:
139
+
140
+ ```json
141
+ {"name": "example", "type": "hotp", "code": "755224", "counter": 0, "next_counter": 1}
142
+ ```
143
+
144
+ TOTP tokens return `remaining` (seconds until rotation) instead of
145
+ `next_counter`, and never touch the state file.
146
+
147
+ `POST .../resync` takes `{"counter": 42}`. It is the recovery path for drift.
148
+
149
+ ### GET never mutates
150
+
151
+ This is deliberate and enforced by tests. Browser prefetch, link previews and
152
+ crawlers all issue GETs; a counter that advances on a prefetch drifts silently.
153
+ Only the two POST endpoints above change state.
154
+
155
+ ## CLI
156
+
157
+ ```bash
158
+ oath-relay serve [--host H] [--port P] # run the server
159
+ oath-relay code NAME [-q] # print next code, consume counter
160
+ oath-relay peek NAME [-q] # print current code, do not consume
161
+ oath-relay list # tokens and their counters
162
+ oath-relay status NAME # counter only
163
+ oath-relay resync NAME COUNTER # force a counter
164
+ oath-relay hash [-p PASSWORD] # scrypt hash for basic auth
165
+ oath-relay init [PATH] [-f] # write a template config
166
+ ```
167
+
168
+ `code` and `serve` share the same state file, so a local CLI call and an HTTP
169
+ call are serialised by the same `flock`.
170
+
171
+ ## Docker
172
+
173
+ ```bash
174
+ docker build -t oath-relay:dev .
175
+ docker run --rm -p 127.0.0.1:8790:8790 \
176
+ -v "$PWD/config.toml:/config/config.toml:ro" \
177
+ -v "$PWD/state:/state" \
178
+ oath-relay:dev
179
+ ```
180
+
181
+ `docker/entrypoint.sh` writes a template config on first run if none is
182
+ mounted. The container runs as a non-root user and declares `/state` as a
183
+ volume.
184
+
185
+ ## Threat model
186
+
187
+ Read this before exposing the service.
188
+
189
+ - **A bearer token is a second factor.** Anyone who can call
190
+ `POST .../next` can log in as you. Protect the endpoint as carefully as the
191
+ secret itself: bind it to a private network, keep tokens long, rotate them.
192
+ - **With auth disabled the service is a credential printer.** It warns at
193
+ startup. Only acceptable on a host reachable by you alone.
194
+ - **`peek` defeats the purpose of a counter.** It returns the current code
195
+ without advancing, which is useful for debugging and dangerous in
196
+ production, because a code you generated but never used desynchronises you.
197
+ It is off by default.
198
+ - **One writer per token.** Point every device at this service. Do not also
199
+ import the same secret into a phone authenticator: two writers is exactly
200
+ the divergence this tool exists to prevent.
201
+ - **Codes are not logged.** Neither the secret nor any generated code is
202
+ written to stdout or to the state file.
203
+
204
+ ## What this is not
205
+
206
+ - Not an identity provider. It does not authenticate users; it issues codes.
207
+ - Not a TOTP sync service. TOTP needs no server — compute it locally.
208
+ - Not a way to bypass someone else's 2FA. Use it for accounts you own.
209
+
210
+ ## Development
211
+
212
+ ```bash
213
+ python -m venv .venv && . .venv/bin/activate
214
+ pip install -e '.[dev]'
215
+ pytest
216
+ ruff check src/ tests/ && ruff format --check src/ tests/
217
+ ```
218
+
219
+ The test suite covers the RFC 4226 and RFC 6238 vectors, concurrent counter
220
+ increments across processes, and the HTTP contract (including that GET never
221
+ mutates).
222
+
223
+ To check a real enrolment secret, set `OATH_RELAY_DUO_SECRET` and
224
+ `OATH_RELAY_DUO_CODES` (comma separated, starting at counter 0) before running
225
+ pytest. The test is skipped when they are unset, so secrets never land in the
226
+ repository.
227
+
228
+ ## 中文
229
+
230
+ `oath-relay` 解决的是 HOTP 不能同步的问题。
231
+
232
+ TOTP 是时间的纯函数,多端各自计算即可;HOTP 依赖一个**可变的计数器**,两端各持一份 secret
233
+ 必然分叉,且无法合并。唯一正确的做法是只有一个写入者 —— 这个服务就是那个写入者。
234
+
235
+ Bitwarden / Vaultwarden 只支持 TOTP:喂给它 `otpauth://hotp/...`,它会拿同一个 secret
236
+ 当 TOTP 算,码永远对不上。有些服务(例如 Duo 的某些租户)只认 HOTP。
237
+
238
+ 用法见上文 Quick start。注意威胁模型:**bearer token 等于你的第二因子**,务必只绑内网。
239
+
240
+ ## License
241
+
242
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,215 @@
1
+ # oath-relay
2
+
3
+ **Share a stateful HOTP token across devices from a single counter.**
4
+
5
+ `oath-relay` is a tiny, dependency-free service that owns the counter for one
6
+ or more HOTP tokens and hands out the next code over HTTP. It exists because
7
+ HOTP is the one 2FA scheme that cannot be synced the way TOTP can.
8
+
9
+ ```
10
+ HOTP = HMAC-SHA1(secret, counter)
11
+ TOTP = HMAC-SHA1(secret, floor(unixtime / 30))
12
+ ```
13
+
14
+ TOTP is a pure function of the clock, so every device computes the same code
15
+ from the same secret. HOTP is a function of a **mutable counter**: two clients
16
+ holding the same secret advance independently and diverge permanently. There
17
+ is no way to merge two counters. The only correct answer is one writer.
18
+
19
+ `oath-relay` is that writer.
20
+
21
+ ## Why not just use a password manager?
22
+
23
+ Bitwarden — and Vaultwarden, which is what this was built against — supports
24
+ TOTP only. Handed an `otpauth://hotp/...` URI it silently computes a TOTP from
25
+ the same secret, so the codes never match. Measured:
26
+
27
+ ```
28
+ Bitwarden from an otpauth://hotp/ URI : 332512 (== the TOTP for that secret)
29
+ Real HOTP at counter 1 : 996554
30
+ ```
31
+
32
+ Some services are HOTP-only. Cisco Duo, for instance, decides per tenant via a
33
+ `use_totp` flag in the enrolment response; when it is `false`, no TOTP client
34
+ will ever work.
35
+
36
+ ## Install
37
+
38
+ ```bash
39
+ pipx install oath-relay # or: pip install oath-relay
40
+ ```
41
+
42
+ Python 3.11+. No runtime dependencies outside the standard library.
43
+
44
+ ## Quick start
45
+
46
+ ```bash
47
+ oath-relay init # writes ./oath-relay.toml (0600)
48
+ $EDITOR oath-relay.toml # add your tokens
49
+ oath-relay hash # generate auth.basic_password_hash
50
+ oath-relay serve # http://127.0.0.1:8790
51
+ ```
52
+
53
+ ```bash
54
+ curl -X POST -H 'Authorization: Bearer <token>' \
55
+ http://127.0.0.1:8790/api/tokens/my-token/next
56
+ # {"name":"my-token","type":"hotp","code":"755224","counter":0,"next_counter":1}
57
+ ```
58
+
59
+ Or open `http://127.0.0.1:8790/` in a browser and press the button.
60
+
61
+ ## Configuration
62
+
63
+ Static configuration (secrets) lives in TOML. Mutable state (counters) lives
64
+ in a separate JSON file, so the state file is safe to back up and diff.
65
+
66
+ ```toml
67
+ [server]
68
+ host = "127.0.0.1"
69
+ port = 8790
70
+ allow_peek = false
71
+
72
+ [state]
73
+ path = "./state/state.json"
74
+
75
+ [auth]
76
+ basic_user = "alice"
77
+ basic_password_hash = "scrypt$..." # from `oath-relay hash`
78
+ bearer_tokens = ["long-random-string"] # several allowed, for rotation
79
+
80
+ [[tokens]]
81
+ name = "example"
82
+ type = "hotp" # or "totp"
83
+ secret = "JBSWY3DPEHPK3PXP"
84
+ digits = 6
85
+ algorithm = "sha1" # sha1 | sha256 | sha512
86
+ counter_start = 0 # used only until the first code is issued
87
+ ```
88
+
89
+ Environment overrides use `OATH_RELAY_<SECTION>__<KEY>`:
90
+
91
+ ```bash
92
+ OATH_RELAY_SERVER__PORT=9000
93
+ OATH_RELAY_AUTH__BEARER_TOKENS=one,two
94
+ ```
95
+
96
+ Config search order: `./oath-relay.toml`, `/config/config.toml`,
97
+ `/etc/oath-relay/config.toml`. Override with `--config PATH`.
98
+
99
+ ## HTTP API
100
+
101
+ | Method | Path | Effect |
102
+ | --- | --- | --- |
103
+ | GET | `/` | HTML page, one button per token |
104
+ | GET | `/healthz` | `{"status":"ok"}`, no auth |
105
+ | GET | `/api/tokens` | Names, types and counters; never secrets |
106
+ | GET | `/api/tokens/{name}/status` | Current counter, read-only |
107
+ | POST | `/api/tokens/{name}/next` | **Issues a code and advances the counter** |
108
+ | POST | `/api/tokens/{name}/resync` | Force a counter (recovery) |
109
+ | GET | `/api/tokens/{name}/peek` | Code without advancing (opt-in, see below) |
110
+
111
+ `POST .../next` accepts no body and returns:
112
+
113
+ ```json
114
+ {"name": "example", "type": "hotp", "code": "755224", "counter": 0, "next_counter": 1}
115
+ ```
116
+
117
+ TOTP tokens return `remaining` (seconds until rotation) instead of
118
+ `next_counter`, and never touch the state file.
119
+
120
+ `POST .../resync` takes `{"counter": 42}`. It is the recovery path for drift.
121
+
122
+ ### GET never mutates
123
+
124
+ This is deliberate and enforced by tests. Browser prefetch, link previews and
125
+ crawlers all issue GETs; a counter that advances on a prefetch drifts silently.
126
+ Only the two POST endpoints above change state.
127
+
128
+ ## CLI
129
+
130
+ ```bash
131
+ oath-relay serve [--host H] [--port P] # run the server
132
+ oath-relay code NAME [-q] # print next code, consume counter
133
+ oath-relay peek NAME [-q] # print current code, do not consume
134
+ oath-relay list # tokens and their counters
135
+ oath-relay status NAME # counter only
136
+ oath-relay resync NAME COUNTER # force a counter
137
+ oath-relay hash [-p PASSWORD] # scrypt hash for basic auth
138
+ oath-relay init [PATH] [-f] # write a template config
139
+ ```
140
+
141
+ `code` and `serve` share the same state file, so a local CLI call and an HTTP
142
+ call are serialised by the same `flock`.
143
+
144
+ ## Docker
145
+
146
+ ```bash
147
+ docker build -t oath-relay:dev .
148
+ docker run --rm -p 127.0.0.1:8790:8790 \
149
+ -v "$PWD/config.toml:/config/config.toml:ro" \
150
+ -v "$PWD/state:/state" \
151
+ oath-relay:dev
152
+ ```
153
+
154
+ `docker/entrypoint.sh` writes a template config on first run if none is
155
+ mounted. The container runs as a non-root user and declares `/state` as a
156
+ volume.
157
+
158
+ ## Threat model
159
+
160
+ Read this before exposing the service.
161
+
162
+ - **A bearer token is a second factor.** Anyone who can call
163
+ `POST .../next` can log in as you. Protect the endpoint as carefully as the
164
+ secret itself: bind it to a private network, keep tokens long, rotate them.
165
+ - **With auth disabled the service is a credential printer.** It warns at
166
+ startup. Only acceptable on a host reachable by you alone.
167
+ - **`peek` defeats the purpose of a counter.** It returns the current code
168
+ without advancing, which is useful for debugging and dangerous in
169
+ production, because a code you generated but never used desynchronises you.
170
+ It is off by default.
171
+ - **One writer per token.** Point every device at this service. Do not also
172
+ import the same secret into a phone authenticator: two writers is exactly
173
+ the divergence this tool exists to prevent.
174
+ - **Codes are not logged.** Neither the secret nor any generated code is
175
+ written to stdout or to the state file.
176
+
177
+ ## What this is not
178
+
179
+ - Not an identity provider. It does not authenticate users; it issues codes.
180
+ - Not a TOTP sync service. TOTP needs no server — compute it locally.
181
+ - Not a way to bypass someone else's 2FA. Use it for accounts you own.
182
+
183
+ ## Development
184
+
185
+ ```bash
186
+ python -m venv .venv && . .venv/bin/activate
187
+ pip install -e '.[dev]'
188
+ pytest
189
+ ruff check src/ tests/ && ruff format --check src/ tests/
190
+ ```
191
+
192
+ The test suite covers the RFC 4226 and RFC 6238 vectors, concurrent counter
193
+ increments across processes, and the HTTP contract (including that GET never
194
+ mutates).
195
+
196
+ To check a real enrolment secret, set `OATH_RELAY_DUO_SECRET` and
197
+ `OATH_RELAY_DUO_CODES` (comma separated, starting at counter 0) before running
198
+ pytest. The test is skipped when they are unset, so secrets never land in the
199
+ repository.
200
+
201
+ ## 中文
202
+
203
+ `oath-relay` 解决的是 HOTP 不能同步的问题。
204
+
205
+ TOTP 是时间的纯函数,多端各自计算即可;HOTP 依赖一个**可变的计数器**,两端各持一份 secret
206
+ 必然分叉,且无法合并。唯一正确的做法是只有一个写入者 —— 这个服务就是那个写入者。
207
+
208
+ Bitwarden / Vaultwarden 只支持 TOTP:喂给它 `otpauth://hotp/...`,它会拿同一个 secret
209
+ 当 TOTP 算,码永远对不上。有些服务(例如 Duo 的某些租户)只认 HOTP。
210
+
211
+ 用法见上文 Quick start。注意威胁模型:**bearer token 等于你的第二因子**,务必只绑内网。
212
+
213
+ ## License
214
+
215
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,57 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "oath-relay"
7
+ dynamic = ["version"]
8
+ description = "Share a stateful HOTP token across devices from one counter"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = "MIT"
12
+ authors = [{ name = "Peng Ding" }]
13
+ keywords = ["hotp", "totp", "otp", "2fa", "oath", "duo"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Environment :: Console",
17
+ "Intended Audience :: End Users/Desktop",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.11",
20
+ "Programming Language :: Python :: 3.12",
21
+ "Programming Language :: Python :: 3.13",
22
+ "Topic :: Security",
23
+ "Topic :: System :: Systems Administration",
24
+ ]
25
+ dependencies = []
26
+
27
+ [project.optional-dependencies]
28
+ dev = ["pytest>=8", "ruff>=0.11", "build"]
29
+
30
+ [project.scripts]
31
+ oath-relay = "oath_relay.cli:main"
32
+
33
+ [project.urls]
34
+ Homepage = "https://github.com/Oaklight/oath-relay"
35
+ Issues = "https://github.com/Oaklight/oath-relay/issues"
36
+
37
+ [tool.setuptools.dynamic]
38
+ version = { attr = "oath_relay.__version__" }
39
+
40
+ [tool.setuptools.packages.find]
41
+ where = ["src"]
42
+
43
+ [tool.zerodep]
44
+ vendor-dir = "src/oath_relay/_vendor"
45
+
46
+ [tool.pytest.ini_options]
47
+ testpaths = ["tests"]
48
+ addopts = "-q"
49
+
50
+ [tool.ruff]
51
+ target-version = "py311"
52
+ line-length = 100
53
+ exclude = ["src/oath_relay/_vendor"]
54
+
55
+ [tool.ruff.lint]
56
+ select = ["E", "F", "I", "W", "UP", "B"]
57
+ ignore = ["E501"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,5 @@
1
+ """oath-relay: share a stateful HOTP token across devices from one counter."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ __all__ = ["__version__"]
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
File without changes