@@ -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 ,
0 commit comments