Repository navigation
Expand file tree
/
Copy pathauth.py
More file actions
272 lines (199 loc) · 8.7 KB
/
Copy pathauth.py
File metadata and controls
272 lines (199 loc) · 8.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
"""Login for shelldeck: one password, per-browser sessions, a host-only CLI token.
- No password yet: the first visit creates one. From anywhere but the host itself that also
needs the setup code printed on the host (and kept in <config>/setup-code), so nobody else
can claim a fresh remote server first.
- Each browser login is a random token in an HttpOnly cookie. Only its sha256 is stored
(auth_sessions table), so logins survive restarts and a stolen database holds no tokens.
A session idle for LOCK_TIMEOUT_SECONDS is dropped: that browser is locked.
- The CLI on the host reads <config>/cli-token (owner-only) and sends it as a header.
- Emergency (forgotten password): `shelldeck reset-password` on the host. See README.
"""
import hashlib
import hmac
import json
import os
import secrets
import time
from . import db
LOCK_TIMEOUT_SECONDS = 30 * 60
MIN_PASSWORD = 8
SHARE_MIN_PASSWORD = 12 # `sd share` puts the login page on the internet
COOKIE = "sd_session"
TOKEN_HEADER = "x-shelldeck-token"
_PASSWORD_KEY = "auth_password_hash"
_STRONG_KEY = "auth_password_strong" # "1" when the password is long enough to share
_TOUCH_EVERY = 60 # seconds; last_seen writes are throttled
_CACHE_TTL = 5 # seconds a session lookup is trusted before re-reading the db
_cache: dict[str, tuple[float, dict | None]] = {}
_failures: dict[str, tuple[int, float]] = {} # client -> (count, blocked until)
# ------------------------------------------------------------------ password
def _hash_password(password: str) -> str:
salt = os.urandom(32)
iterations = 600_000
digest = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, iterations)
return f"{salt.hex()}:{iterations}:{digest.hex()}"
def _verify_password(password: str, stored: str) -> bool:
try:
salt_hex, iterations_s, hash_hex = stored.split(":")
salt = bytes.fromhex(salt_hex)
iterations = int(iterations_s)
except ValueError:
return False
computed = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, iterations)
return hmac.compare_digest(computed.hex(), hash_hex)
# The hash lives in <config>/password (owner-only), not the database: a database write can sit in the
# WAL of a server that never exits cleanly and be lost, and then every reinstall asked for a new password.
# Only reset() removes it.
def _stored() -> str:
try:
return _file("password").read_text(encoding="utf-8").strip()
except OSError:
return ""
def has_password() -> bool:
return bool(_stored())
def check_password(password: str) -> bool:
stored = _stored()
ok = bool(stored) and _verify_password(password, stored)
if ok:
_note_strength(password) # passwords set before this flag existed get it at their next login
return ok
def _note_strength(password: str) -> None:
strong = "1" if len(password) >= SHARE_MIN_PASSWORD else ""
if db.get_setting(_STRONG_KEY, "") != strong:
db.set_setting(_STRONG_KEY, strong)
def strong_password() -> bool:
return db.get_setting(_STRONG_KEY, "") == "1"
def validate_new(password: str, confirm: str) -> str | None:
"""Error code for an unacceptable new password, else None."""
if len(password) < MIN_PASSWORD:
return "password_too_short"
if password != confirm:
return "passwords_do_not_match"
return None
def terms_accepted(version: int) -> bool:
"""The host accepted the share terms of this version (<config>/share-consent, kept like the password)."""
try:
return json.loads(_file("share-consent").read_text(encoding="utf-8")).get("version") == version
except (OSError, ValueError, AttributeError):
return False
def accept_terms(version: int) -> None:
_write_private("share-consent", json.dumps({"version": version, "accepted_at": int(time.time())}))
def set_password(password: str) -> None:
hashed = _hash_password(password)
_write_private("password", hashed)
db.set_setting(_PASSWORD_KEY, hashed) # read by 0.0.4 and older, should you go back to one
_note_strength(password)
_file("setup-code").unlink(missing_ok=True)
# ------------------------------------------------------------------ host files
def _file(name: str):
return db.config_dir() / name
def _write_private(name: str, text: str) -> None:
path = _file(name)
path.write_text(text, encoding="utf-8")
try:
os.chmod(path, 0o600) # ponytail: on Windows the user profile folder is already private
except OSError:
pass
def setup_code() -> str | None:
"""The one-time code remote setup needs; created while no password exists."""
if has_password():
return None
path = _file("setup-code")
if path.exists():
return path.read_text(encoding="utf-8").strip()
code = "-".join(secrets.token_hex(2) for _ in range(3))
_write_private("setup-code", code)
return code
def cli_token() -> str:
"""Token the local CLI sends; rotated on every server start."""
token = secrets.token_urlsafe(32)
_write_private("cli-token", token)
return token
def read_cli_token() -> str:
try:
return _file("cli-token").read_text(encoding="utf-8").strip()
except OSError:
return ""
def init_auth() -> str | None:
"""On server start: drop termy's seeded password, rotate the CLI token. Returns the setup code, if any."""
# versions up to 0.0.4 keep it in the database; a hash there that differs was set by one of them since
if (legacy := db.get_setting(_PASSWORD_KEY)) and legacy != _stored():
_write_private("password", legacy)
if (stored := _stored()) and _verify_password("nopassword", stored):
_file("password").unlink(missing_ok=True)
db.delete_setting(_PASSWORD_KEY)
cli_token()
return setup_code()
def reset() -> str:
"""Emergency, host-only: forget the password and every login. Returns the new setup code."""
_file("password").unlink(missing_ok=True)
db.delete_setting(_PASSWORD_KEY)
db.delete_setting(_STRONG_KEY)
db.delete_auth_sessions()
_file("setup-code").unlink(missing_ok=True)
_cache.clear()
return setup_code() or ""
# ------------------------------------------------------------------ sessions
def _h(token: str) -> str:
return hashlib.sha256(token.encode()).hexdigest()
def new_session(client: str, via: str = "local", kind: str = "browser") -> str:
token = secrets.token_urlsafe(32)
db.add_auth_session(_h(token), time.time(), client, via, kind)
return token
def _row(h: str) -> dict | None:
hit = _cache.get(h)
return hit[1] if hit and time.time() - hit[0] < _CACHE_TTL else db.get_auth_session(h)
def remote(h: str | None) -> bool:
"""A remote (iOS app) login: it never takes the one-device slot."""
row = _row(h) if h else None
return bool(row) and row.get("kind") == "remote"
def alive(h: str | None) -> bool:
"""A session (by hash) that still exists and hasn't idled out."""
if not h:
return False
row = _row(h)
return bool(row) and time.time() - row["last_seen"] <= LOCK_TIMEOUT_SECONDS
def session_hash(token: str | None) -> str | None:
"""sha256 of a live session token, else None. Idle sessions are removed here."""
if not token:
return None
h = _h(token)
now = time.time()
hit = _cache.get(h)
row = hit[1] if hit and now - hit[0] < _CACHE_TTL else db.get_auth_session(h)
_cache[h] = (now, row)
if not row:
return None
if now - row["last_seen"] > LOCK_TIMEOUT_SECONDS:
end_session(h)
return None
return h
def touch(h: str) -> None:
"""User activity on a session (keeps it from locking)."""
now = time.time()
row = _cache.get(h, (0, None))[1]
if row and now - row["last_seen"] > _TOUCH_EVERY:
row["last_seen"] = now
db.touch_auth_session(h, now)
def end_session(h: str) -> None:
db.delete_auth_sessions(only=h)
_cache.pop(h, None)
def end_other_sessions(keep: str) -> None:
db.delete_auth_sessions(keep=keep)
_cache.clear()
def cli_ok(token: str | None) -> bool:
expected = read_cli_token()
return bool(token and expected) and hmac.compare_digest(token, expected)
# ------------------------------------------------------------------ brute force
def blocked_for(client: str) -> int:
"""Seconds this client must wait before trying a password again."""
count, until = _failures.get(client, (0, 0.0))
return max(0, int(until - time.time() + 0.999))
def record_attempt(client: str, ok: bool) -> None:
if ok:
_failures.pop(client, None)
return
count = _failures.get(client, (0, 0.0))[0] + 1
# 5 free tries, then 2, 4, 8 … seconds, capped at 5 minutes
wait = 0 if count < 5 else min(300, 2 ** (count - 4))
_failures[client] = (count, time.time() + wait)