Use a recovery code to reset the password and decrypt the database.
(self, username: str, recovery_code: str, new_password: str)
| 577 | # ========================================================================= |
| 578 | |
| 579 | def recover(self, username: str, recovery_code: str, new_password: str) -> dict: |
| 580 | """Use a recovery code to reset the password and decrypt the database.""" |
| 581 | if not self.is_configured(): |
| 582 | return {'success': False, 'error': 'Authentication not configured'} |
| 583 | |
| 584 | if len(new_password) < 8: |
| 585 | return {'success': False, 'error': 'New password must be at least 8 characters'} |
| 586 | |
| 587 | try: |
| 588 | with self._get_auth_conn() as conn: |
| 589 | cursor = conn.cursor() |
| 590 | |
| 591 | # Verify username |
| 592 | cursor.execute("SELECT * FROM auth WHERE username = ?", (username,)) |
| 593 | auth_row = cursor.fetchone() |
| 594 | if not auth_row: |
| 595 | return {'success': False, 'error': 'Invalid username or recovery code'} |
| 596 | |
| 597 | hw_fingerprint = auth_row['hardware_fingerprint'] |
| 598 | |
| 599 | # Check hardware |
| 600 | current_hw = self.get_hardware_fingerprint() |
| 601 | if not secrets.compare_digest(hw_fingerprint, current_hw): |
| 602 | return {'success': False, 'error': 'Hardware mismatch', 'hw_mismatch': True} |
| 603 | |
| 604 | # Normalize recovery code (strip spaces, uppercase, ensure dash format) |
| 605 | recovery_code = recovery_code.replace(' ', '').strip().upper() |
| 606 | if '-' not in recovery_code and len(recovery_code) == 8: |
| 607 | recovery_code = recovery_code[:4] + '-' + recovery_code[4:] |
| 608 | |
| 609 | # Find matching recovery code |
| 610 | cursor.execute("SELECT * FROM recovery_codes WHERE used = 0") |
| 611 | rows = cursor.fetchall() |
| 612 | |
| 613 | matched_row = None |
| 614 | for row in rows: |
| 615 | if self._verify_password(recovery_code, row['code_hash'], row['code_salt']): |
| 616 | matched_row = row |
| 617 | break |
| 618 | |
| 619 | if not matched_row: |
| 620 | return {'success': False, 'error': 'Invalid username or recovery code'} |
| 621 | |
| 622 | # Unwrap Fernet key using recovery code |
| 623 | code_wrapping = self._derive_wrapping_key(recovery_code, hw_fingerprint) |
| 624 | try: |
| 625 | fernet_key = self._unwrap_fernet_key(matched_row['encrypted_fernet_key'], code_wrapping) |
| 626 | except InvalidToken: |
| 627 | return {'success': False, 'error': 'Recovery code decryption failed'} |
| 628 | |
| 629 | # Mark recovery code as used |
| 630 | cursor.execute(""" |
| 631 | UPDATE recovery_codes SET used = 1, used_at = CURRENT_TIMESTAMP |
| 632 | WHERE id = ? |
| 633 | """, (matched_row['id'],)) |
| 634 | |
| 635 | # Re-wrap Fernet key with new password |
| 636 | new_pw_hash, new_pw_salt = self._hash_password(new_password) |
no test coverage detected