Skip to content

Commit e132120

Browse files
committed
feat: vault recovery via mnemonic (plan 35); bump __version__ to 0.6.0
1 parent 7c89143 commit e132120

14 files changed

Lines changed: 1189 additions & 36 deletions

File tree

backend/raspy/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
"""Raspy — modular FastAPI control plane for a personal Raspberry Pi."""
22

3-
__version__ = "0.5.3"
3+
__version__ = "0.6.0"

backend/raspy/core/auth/router.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,13 @@
1818
ACCESS_COOKIE,
1919
CSRF_COOKIE,
2020
REFRESH_COOKIE,
21+
Principal,
2122
check_csrf,
2223
client_ip,
2324
get_auth,
2425
refresh_token_from,
2526
require_admin,
27+
require_auth,
2628
)
2729
from .service import AuthError, AuthService, LoginResult
2830

@@ -64,6 +66,24 @@ class UpdateChildBody(BaseModel):
6466
allowed_apps: list[str] = Field(default_factory=list)
6567

6668

69+
class SetRecoveryBody(BaseModel):
70+
"""Opaque recovery wraps the browser computed (plan/35). All values are b64
71+
ciphertext / a public salt; the server stores them verbatim."""
72+
recovery_salt: str = Field(min_length=1, max_length=128)
73+
wrap_pw: str = Field(min_length=1, max_length=512)
74+
wrap_pw_nonce: str = Field(min_length=1, max_length=128)
75+
wrap_mn: str = Field(min_length=1, max_length=512)
76+
wrap_mn_nonce: str = Field(min_length=1, max_length=128)
77+
78+
79+
class RecoverBody(BaseModel):
80+
"""Break-glass password reset proven by the mnemonic (client-side). The server
81+
only takes the new auth_key (+ optional PIN); the wraps/DEK are untouched."""
82+
username: str = Field(min_length=1, max_length=128)
83+
new_auth_key: str = Field(min_length=1, max_length=512)
84+
new_pin: str | None = Field(default=None, min_length=1, max_length=256)
85+
86+
6787
def _settings(request: Request) -> Settings:
6888
return request.app.state.settings
6989

@@ -131,6 +151,64 @@ async def kdf_params(request: Request, username: str, svc: AuthService = Depends
131151
return salts
132152

133153

154+
@router.get("/recovery/{username}")
155+
async def recovery_material(
156+
request: Request, username: str, svc: AuthService = Depends(get_auth)
157+
):
158+
"""Public: the opaque recovery wraps + salt the client needs to recover the
159+
DEK from a mnemonic (or to detect whether migration has happened). Like
160+
/kdf, the values are non-secret. A missing account returns the same shape with
161+
nulls + dek_migrated:false, so it doesn't reveal whether the user exists."""
162+
material = await svc.recovery_material(username)
163+
if material is None:
164+
material = {
165+
"recovery_salt": None,
166+
"wrap_pw": None, "wrap_pw_nonce": None,
167+
"wrap_mn": None, "wrap_mn_nonce": None,
168+
"dek_migrated": False,
169+
}
170+
return material
171+
172+
173+
@router.put("/recovery", status_code=204)
174+
async def set_recovery(
175+
request: Request, body: SetRecoveryBody,
176+
svc: AuthService = Depends(get_auth),
177+
principal: Principal = Depends(require_auth),
178+
):
179+
"""Authed: the client stores its recovery wraps for its OWN account (migration
180+
or a wrap rotation). Scoped to the principal — a user can only write their own
181+
envelope."""
182+
check_csrf(request, _settings(request).auth)
183+
try:
184+
await svc.set_recovery(
185+
principal.username,
186+
recovery_salt=body.recovery_salt,
187+
wrap_pw=body.wrap_pw, wrap_pw_nonce=body.wrap_pw_nonce,
188+
wrap_mn=body.wrap_mn, wrap_mn_nonce=body.wrap_mn_nonce,
189+
)
190+
except AuthError as exc:
191+
raise HTTPException(400, str(exc))
192+
193+
194+
@router.post("/recover", status_code=204)
195+
async def recover(
196+
request: Request, response: Response, body: RecoverBody,
197+
svc: AuthService = Depends(get_auth),
198+
):
199+
"""Public, rate-limited: break-glass password reset proven by the mnemonic.
200+
The client has already unwrapped the DEK from wrap_mn locally; here we only
201+
reset the login hash, then the client re-PUTs wrap_pw under the new password.
202+
Sessions are revoked so the user signs in fresh."""
203+
try:
204+
await svc.recover_password(
205+
body.username, body.new_auth_key, body.new_pin, ip=_ip(request)
206+
)
207+
except AuthError as exc:
208+
raise _auth_error(exc)
209+
_clear_session_cookies(response)
210+
211+
134212
@router.post("/login")
135213
async def login(
136214
request: Request, response: Response, body: LoginBody,

backend/raspy/core/auth/service.py

Lines changed: 97 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,19 @@ async def init(self) -> None:
107107
must_reset INTEGER NOT NULL DEFAULT 0,
108108
-- JSON list of attachment ids this account may see; NULL = all
109109
-- (admin). Children get an explicit allow-list.
110-
allowed_apps TEXT
110+
allowed_apps TEXT,
111+
-- Recovery envelope (plan/35). The vault root is a random DEK;
112+
-- it is wrapped under the password-KEK and (optionally) a
113+
-- mnemonic-KEK, both client-side. The server stores only opaque
114+
-- wraps — it never sees the DEK, password, or mnemonic. All NULL
115+
-- + dek_migrated=0 means a legacy account whose DEK == the
116+
-- password-derived master_key (migrated on next browser unlock).
117+
recovery_salt TEXT, -- salt for the mnemonic KEK (16B, b64)
118+
wrap_pw TEXT, -- secretbox(DEK, nonce_pw, KEK_pw), b64
119+
wrap_pw_nonce TEXT,
120+
wrap_mn TEXT, -- secretbox(DEK, nonce_mn, KEK_mn), b64
121+
wrap_mn_nonce TEXT,
122+
dek_migrated INTEGER NOT NULL DEFAULT 0
111123
)
112124
"""
113125
)
@@ -174,6 +186,15 @@ async def _migrate_account_columns(self) -> None:
174186
"role": "TEXT NOT NULL DEFAULT 'admin'",
175187
"must_reset": "INTEGER NOT NULL DEFAULT 0",
176188
"allowed_apps": "TEXT",
189+
# Recovery envelope (plan/35) — additive, all nullable. Existing rows
190+
# get dek_migrated=0 ⇒ treated as legacy (DEK == master_key) until the
191+
# client migrates on next password unlock.
192+
"recovery_salt": "TEXT",
193+
"wrap_pw": "TEXT",
194+
"wrap_pw_nonce": "TEXT",
195+
"wrap_mn": "TEXT",
196+
"wrap_mn_nonce": "TEXT",
197+
"dek_migrated": "INTEGER NOT NULL DEFAULT 0",
177198
}
178199
for col, decl in adds.items():
179200
if col not in have:
@@ -228,6 +249,81 @@ def _salt(tag: str) -> str:
228249
"argon_parallelism": self._cfg.argon_parallelism,
229250
}
230251

252+
# --- recovery envelope (plan/35) -----------------------------------------
253+
254+
async def recovery_material(self, username: str) -> dict[str, Any] | None:
255+
"""The opaque recovery wraps + salt the client needs to recover the DEK
256+
from a mnemonic (and to know whether the account is migrated yet). Returns
257+
None for a missing account. All values are opaque/non-secret ciphertext or
258+
public salts — like kdf_salts, this is safe to serve pre-auth."""
259+
account = await self._get_account(username)
260+
if account is None:
261+
return None
262+
return {
263+
"recovery_salt": account.get("recovery_salt"),
264+
"wrap_pw": account.get("wrap_pw"),
265+
"wrap_pw_nonce": account.get("wrap_pw_nonce"),
266+
"wrap_mn": account.get("wrap_mn"),
267+
"wrap_mn_nonce": account.get("wrap_mn_nonce"),
268+
"dek_migrated": bool(account.get("dek_migrated")),
269+
}
270+
271+
async def set_recovery(
272+
self, username: str, *,
273+
recovery_salt: str,
274+
wrap_pw: str, wrap_pw_nonce: str,
275+
wrap_mn: str, wrap_mn_nonce: str,
276+
) -> None:
277+
"""Persist the recovery envelope wraps for an account and flag it migrated.
278+
279+
Written by the browser after it mints the DEK + mnemonic (first migration)
280+
or rotates a wrap (password change / mnemonic re-issue). The server stores
281+
only opaque blobs; it never learns the DEK, password, or mnemonic."""
282+
if await self._get_account(username) is None:
283+
raise AuthError(f"no such account {username!r}")
284+
await self._db.execute(
285+
f"UPDATE {_T_ACCOUNT} SET "
286+
f"recovery_salt = ?, wrap_pw = ?, wrap_pw_nonce = ?, "
287+
f"wrap_mn = ?, wrap_mn_nonce = ?, dek_migrated = 1 "
288+
f"WHERE username = ?",
289+
(recovery_salt, wrap_pw, wrap_pw_nonce, wrap_mn, wrap_mn_nonce, username),
290+
)
291+
292+
async def recover_password(
293+
self, username: str, new_auth_key: str, new_pin: str | None, *, ip: str | None
294+
) -> None:
295+
"""Break-glass reset: set a new password (+ optional PIN) without touching
296+
the recovery wraps or the DEK. The caller has already proven possession of
297+
the mnemonic *client-side* by unwrapping wrap_mn → DEK; the server only
298+
resets the login hash here. The client then re-PUTs a fresh wrap_pw under
299+
the new password-KEK (set_recovery), so the vault stays intact.
300+
301+
Rate-limited on the account + IP like a login, so this can't be used to
302+
brute the username space. Revokes existing sessions."""
303+
await self._check_locked("ip", ip or "?")
304+
await self._check_locked("account", username)
305+
account = await self._get_account(username)
306+
if account is None or not bool(account.get("dek_migrated")):
307+
# No migrated account ⇒ no mnemonic exists to recover with. Record a
308+
# failure (constant-ish) so probing is rate-limited, then refuse.
309+
await self._record_failure("ip", ip or "?")
310+
await self._record_failure("account", username)
311+
await self._audit("recover", False, ip, username)
312+
raise AuthError("recovery unavailable")
313+
updates = "pw_hash = ?"
314+
params: list[Any] = [self._ph.hash(new_auth_key)]
315+
if new_pin:
316+
updates += ", pin_hash = ?"
317+
params.append(self._ph.hash(new_pin))
318+
params.append(username)
319+
await self._db.execute(
320+
f"UPDATE {_T_ACCOUNT} SET {updates} WHERE username = ?", tuple(params)
321+
)
322+
await self._reset_attempts("ip", ip or "?")
323+
await self._reset_attempts("account", username)
324+
await self.revoke_all(username)
325+
await self._audit("recover", True, ip, username)
326+
231327
async def create_account(
232328
self, username: str, auth_key: str, pin: str | None,
233329
auth_salt: str, master_salt: str,

backend/tests/test_auth.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,3 +251,92 @@ async def test_child_setup_requires_temp_pin_and_blocks_unallowed_apps(setup):
251251
assert "vault" not in app_ids
252252
assert (await client.get("/api/att/notes/notes")).status_code == 200
253253
assert (await client.get("/api/att/vault/manifest")).status_code == 403
254+
255+
256+
# --- recovery envelope (plan/35) ---------------------------------------------
257+
258+
_WRAPS = {
259+
"recovery_salt": "cmVjb3Zlcnktc2FsdC0xNg",
260+
"wrap_pw": "d3JhcF9wdw",
261+
"wrap_pw_nonce": "d3JhcF9wd19ub25jZQ",
262+
"wrap_mn": "d3JhcF9tbg",
263+
"wrap_mn_nonce": "d3JhcF9tbl9ub25jZQ",
264+
}
265+
266+
267+
async def test_recovery_material_legacy_unmigrated(setup):
268+
client, _ = setup
269+
r = await client.get(f"/api/auth/recovery/{USERNAME}")
270+
assert r.status_code == 200, r.text
271+
body = r.json()
272+
assert body["dek_migrated"] is False
273+
assert body["wrap_pw"] is None and body["wrap_mn"] is None
274+
275+
276+
async def test_recovery_material_missing_user_is_decoy(setup):
277+
client, _ = setup
278+
# A non-existent user returns the same null shape, not a 404 (no existence leak).
279+
r = await client.get("/api/auth/recovery/ghost")
280+
assert r.status_code == 200
281+
assert r.json()["dek_migrated"] is False
282+
283+
284+
async def test_put_recovery_requires_auth(setup):
285+
client, _ = setup
286+
client.cookies.clear()
287+
r = await client.put("/api/auth/recovery", json=_WRAPS)
288+
assert r.status_code == 401
289+
290+
291+
async def test_put_recovery_migrates_and_persists(setup):
292+
client, salt = setup
293+
csrf = (await _login(client, salt)).json()["csrf_token"]
294+
r = await client.put(
295+
"/api/auth/recovery", json=_WRAPS, headers={"X-CSRF-Token": csrf}
296+
)
297+
assert r.status_code == 204, r.text
298+
mat = (await client.get(f"/api/auth/recovery/{USERNAME}")).json()
299+
assert mat["dek_migrated"] is True
300+
assert mat["wrap_pw"] == _WRAPS["wrap_pw"]
301+
assert mat["wrap_mn"] == _WRAPS["wrap_mn"]
302+
assert mat["recovery_salt"] == _WRAPS["recovery_salt"]
303+
304+
305+
async def test_recover_refused_before_migration(setup):
306+
client, _ = setup
307+
client.cookies.clear()
308+
r = await client.post(
309+
"/api/auth/recover",
310+
json={"username": USERNAME, "new_auth_key": "brand-new-key"},
311+
)
312+
# No mnemonic exists yet → refused (401), and the wraps stay absent.
313+
assert r.status_code == 401
314+
315+
316+
async def test_recover_after_migration_resets_and_revokes(setup):
317+
client, salt = setup
318+
# Migrate first (authed PUT), then recover from a clean (pre-auth) client.
319+
csrf = (await _login(client, salt)).json()["csrf_token"]
320+
assert (await client.put(
321+
"/api/auth/recovery", json=_WRAPS, headers={"X-CSRF-Token": csrf}
322+
)).status_code == 204
323+
client.cookies.clear()
324+
325+
new_key = "recovered-auth-key"
326+
r = await client.post(
327+
"/api/auth/recover",
328+
json={"username": USERNAME, "new_auth_key": new_key, "new_pin": "424242"},
329+
)
330+
assert r.status_code == 204, r.text
331+
332+
# The wraps/DEK are untouched by recover (only the login hash changed).
333+
mat = (await client.get(f"/api/auth/recovery/{USERNAME}")).json()
334+
assert mat["wrap_pw"] == _WRAPS["wrap_pw"] and mat["wrap_mn"] == _WRAPS["wrap_mn"]
335+
336+
# The old password no longer works; the new one does.
337+
assert (await client.post(
338+
"/api/auth/login", json={"username": USERNAME, "auth_key": AUTH_KEY}
339+
)).status_code == 401
340+
assert (await client.post(
341+
"/api/auth/login", json={"username": USERNAME, "auth_key": new_key}
342+
)).status_code == 200

frontend/bun.lock

Lines changed: 7 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

frontend/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
},
2626
"dependencies": {
2727
"@chenglou/pretext": "0.0.8",
28+
"@scure/bip39": "^2.2.0",
2829
"@xterm/addon-fit": "^0.11.0",
2930
"@xterm/xterm": "^6.0.0",
3031
"libsodium-wrappers": "^0.8.4",

0 commit comments

Comments
 (0)