Aug 26 - Enhance security 4
CI / Python lint (flake8) (push) Has been cancelled
CI / Python syntax check (push) Has been cancelled
CI / Alembic migration chain (push) Has been cancelled
CI / JavaScript syntax check (push) Has been cancelled
CI / Pytest (push) Has been cancelled
CI / Build extension zip (push) Has been cancelled

This commit is contained in:
2026-08-26 14:19:25 -04:00
parent cc216b0d98
commit b84a6d9245
11 changed files with 546 additions and 33 deletions
+41 -3
View File
@@ -131,7 +131,7 @@ def _apply_reencrypted_items(user_id: int, items, allow_partial: bool = False) -
@auth_bp.route('/register', methods=['POST'])
@limiter.limit('10 per minute')
@limiter.limit('5 per minute')
def register():
data = request.get_json(silent=True) or {}
email = (data.get('email') or '').strip().lower()
@@ -147,8 +147,46 @@ def register():
if not enc_key_salt:
return jsonify({'error': 'enc_key_salt is required'}), 400
# ── Account-existence must not be observable ────────────────────────────
#
# This used to answer 409 "Email already registered", which let anyone probe
# whether a given address has a PassKeeper account — a useful target list for
# phishing, and exactly the kind of thing a password manager should not leak.
#
# Both branches now return the identical 202 body. The wording sends the user
# to the sign-in page either way, which is the correct next step in both
# cases: registering an address that already exists is harmless because the
# user simply signs in with the password they already have.
#
# Timing has to match too. Creating an account runs Argon2id (deliberately
# slow); returning early without it would make "exists" measurably faster and
# reinstate the oracle through the side door. So the existing-account branch
# performs and discards an equivalent hash.
#
# NOTE: fully closing this needs email verification (roadmap item 4) so the
# address owner is told when someone tries to register it. Until then this
# removes the oracle but cannot notify the legitimate owner.
generic_response = jsonify({
'message': (
'If that email address was available, your account has been created. '
'Please sign in.'
)
}), 202
time.sleep(0.1) # flatten timing across both branches
if User.query.filter_by(email=email).first():
return jsonify({'error': 'Email already registered'}), 409
hash_auth_token(auth_hash) # equalise work; result intentionally discarded
AuditLog.log(
user_id=0, # no account to attribute this to
action='auth.register_duplicate',
resource_type='user',
resource_id=None,
detail='Registration attempted for an address that already exists',
ip_address=client_ip(),
)
db.session.commit()
return generic_response
master_hash = hash_auth_token(auth_hash)
user = User(email=email, master_hash=master_hash, enc_key_salt=enc_key_salt)
@@ -165,7 +203,7 @@ def register():
)
db.session.commit()
return jsonify({'message': 'Account created successfully'}), 201
return generic_response
@auth_bp.route('/login', methods=['POST'])
+72 -21
View File
@@ -41,6 +41,39 @@ def list_emergency():
}), 200
def _log_for_both(ea: EmergencyAccess, action: str, grantor_detail: str,
grantee_detail: str) -> None:
"""
Write the audit entry twice — once under each party's user_id.
/api/auth/audit-log filters by user_id, so an entry written only under the
acting user is invisible to the other party. That meant a grantee could
request access and retrieve the vault snapshot without a single line of it
appearing in the grantor's own audit log or security dashboard — the person
whose vault it was had no way to see it had happened.
Until email notifications exist (roadmap item 4), the grantor's audit log is
the only channel that reaches them, so it must carry these events.
"""
AuditLog.log(
user_id=ea.grantor_id,
action=action,
resource_type='emergency_access',
resource_id=ea.id,
detail=grantor_detail,
ip_address=client_ip(),
)
if ea.grantee_id and ea.grantee_id != ea.grantor_id:
AuditLog.log(
user_id=ea.grantee_id,
action=action,
resource_type='emergency_access',
resource_id=ea.id,
detail=grantee_detail,
ip_address=client_ip(),
)
def _ea_as_grantee(ea: EmergencyAccess, grantor: 'User | None' = None) -> dict:
if grantor is None:
grantor = db.session.get(User, ea.grantor_id)
@@ -150,14 +183,13 @@ def accept_emergency(ea_id):
ea.status = 'accepted'
ea.grantee_id = user.id
db.session.flush()
AuditLog.log(
user_id=g.current_user_id,
action='emergency_access.accept',
resource_type='emergency_access',
resource_id=ea.id,
detail=f'Accepted emergency access invitation from grantor_id={ea.grantor_id}',
ip_address=client_ip(),
_log_for_both(
ea,
'emergency_access.accept',
grantor_detail=f'{ea.grantee_email} accepted your emergency access invitation',
grantee_detail=f'Accepted emergency access invitation from grantor_id={ea.grantor_id}',
)
db.session.commit()
@@ -221,14 +253,21 @@ def request_access(ea_id):
ea.status = 'pending'
ea.request_initiated_at = datetime.now(timezone.utc).replace(tzinfo=None)
db.session.flush()
AuditLog.log(
user_id=g.current_user_id,
action='emergency_access.request',
resource_type='emergency_access',
resource_id=ea.id,
detail=f'Requested emergency vault access from grantor_id={ea.grantor_id} (wait: {ea.wait_days}d)',
ip_address=client_ip(),
# The grantor has `wait_days` to notice and deny this. If it only appeared in
# the grantee's audit log they would never see it in time.
_log_for_both(
ea,
'emergency_access.request',
grantor_detail=(
f'ACTION REQUIRED: {ea.grantee_email} requested emergency access to '
f'your vault. It unlocks in {ea.wait_days} day(s) unless you deny it.'
),
grantee_detail=(
f'Requested emergency vault access from grantor_id={ea.grantor_id} '
f'(wait: {ea.wait_days}d)'
),
)
db.session.commit()
@@ -291,13 +330,25 @@ def get_emergency_vault(ea_id):
'error': f'Wait period not yet elapsed ({days_left:.1f} day(s) remaining)'
}), 403
AuditLog.log(
user_id=g.current_user_id,
action='emergency_access.vault_retrieved',
resource_type='emergency_access',
resource_id=ea.id,
detail=f'Retrieved emergency vault from grantor_id={ea.grantor_id}',
ip_address=client_ip(),
# Record the retrieval. Access is deliberately not revoked afterwards — the
# grantor may be unable to re-provision, and a failed import must not strand
# the grantee — but every retrieval is counted and shown to the grantor, who
# can revoke the grant outright.
now = datetime.now(timezone.utc).replace(tzinfo=None)
is_first = ea.vault_retrieved_at is None
if is_first:
ea.vault_retrieved_at = now
ea.vault_retrieval_count = (ea.vault_retrieval_count or 0) + 1
_log_for_both(
ea,
'emergency_access.vault_retrieved',
grantor_detail=(
f'{ea.grantee_email} retrieved your emergency vault snapshot '
f'({"first" if is_first else f"retrieval #{ea.vault_retrieval_count}"}). '
'Remove the grant if this was not expected.'
),
grantee_detail=f'Retrieved emergency vault from grantor_id={ea.grantor_id}',
)
db.session.commit()