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.
- oath_relay-0.1.0/LICENSE +21 -0
- oath_relay-0.1.0/PKG-INFO +242 -0
- oath_relay-0.1.0/README.md +215 -0
- oath_relay-0.1.0/pyproject.toml +57 -0
- oath_relay-0.1.0/setup.cfg +4 -0
- oath_relay-0.1.0/src/oath_relay/__init__.py +5 -0
- oath_relay-0.1.0/src/oath_relay/__main__.py +3 -0
- oath_relay-0.1.0/src/oath_relay/_vendor/__init__.py +0 -0
- oath_relay-0.1.0/src/oath_relay/_vendor/httpserver.py +2409 -0
- oath_relay-0.1.0/src/oath_relay/app.py +267 -0
- oath_relay-0.1.0/src/oath_relay/cli.py +264 -0
- oath_relay-0.1.0/src/oath_relay/config.py +317 -0
- oath_relay-0.1.0/src/oath_relay/otp.py +121 -0
- oath_relay-0.1.0/src/oath_relay/store.py +139 -0
- oath_relay-0.1.0/src/oath_relay.egg-info/PKG-INFO +242 -0
- oath_relay-0.1.0/src/oath_relay.egg-info/SOURCES.txt +21 -0
- oath_relay-0.1.0/src/oath_relay.egg-info/dependency_links.txt +1 -0
- oath_relay-0.1.0/src/oath_relay.egg-info/entry_points.txt +2 -0
- oath_relay-0.1.0/src/oath_relay.egg-info/requires.txt +5 -0
- oath_relay-0.1.0/src/oath_relay.egg-info/top_level.txt +1 -0
- oath_relay-0.1.0/tests/test_app.py +260 -0
- oath_relay-0.1.0/tests/test_otp.py +116 -0
- oath_relay-0.1.0/tests/test_store.py +115 -0
oath_relay-0.1.0/LICENSE
ADDED
|
@@ -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"]
|
|
File without changes
|