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
+14
View File
@@ -16,6 +16,10 @@ class EmergencyAccess(db.Model):
pending → grantor calls /deny → ready (reset, grantee can request again)
pending (wait_days elapsed) → grantable (grantee fetches vault)
Retrieval does not change `status`: the grant stays 'pending' so the grantor
keeps seeing it as active and can revoke it. What retrieval does change is
vault_retrieved_at / vault_retrieval_count, which the grantor's UI surfaces.
Zero-knowledge: enc_vault is a JSON array of vault items re-encrypted by the grantor
using the ECDH shared secret (grantor private key + grantee public key).
"""
@@ -40,6 +44,12 @@ class EmergencyAccess(db.Model):
# JSON string: [{ id, name, item_type, enc_data, iv }, ...]
enc_vault = db.Column(db.Text, nullable=True)
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc).replace(tzinfo=None), nullable=False)
# Retrieval tracking — makes grantee access to the snapshot visible to the
# grantor. Retrieval is not blocked after the first time (the grantor may be
# unable to re-provision, which is the entire premise of emergency access);
# the wait period is the gate, and these make use of it auditable.
vault_retrieved_at = db.Column(db.DateTime, nullable=True)
vault_retrieval_count = db.Column(db.Integer, default=0, nullable=False, server_default='0')
@property
def wait_elapsed(self):
@@ -62,6 +72,10 @@ class EmergencyAccess(db.Model):
self.request_initiated_at.isoformat() if self.request_initiated_at else None
),
'created_at': self.created_at.isoformat() if self.created_at else None,
'vault_retrieved_at': (
self.vault_retrieved_at.isoformat() if self.vault_retrieved_at else None
),
'vault_retrieval_count': self.vault_retrieval_count or 0,
# True when enc_vault contains items in the old format (has a plaintext
# 'name' field instead of enc_name/iv_name). Grantor should re-provision.
'enc_vault_is_legacy': self._enc_vault_is_legacy(),
+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()
+19
View File
@@ -953,6 +953,25 @@ ul {
border: 1px solid #ffb74d;
}
/* Emergency access: a pending request or a retrieved snapshot is the one thing
in this list the grantor must not scroll past, so it gets the strongest
treatment available rather than the amber used for ordinary warnings. */
.badge-danger {
background: #ffebee;
color: #b71c1c;
border: 1px solid #ef9a9a;
}
.share-item.em-alert {
border-left: 3px solid #c62828;
background: #fff5f5;
}
.share-meta.em-retrieved {
color: #b71c1c;
font-weight: 500;
}
.badge-info {
background: #e3f2fd;
color: #1565c0;
+30 -5
View File
@@ -2445,6 +2445,16 @@ const Vault = (() => {
}
}
/**
* Whole days left before a pending emergency request unlocks. Mirrors the
* server's wait_elapsed calculation in EmergencyAccess.wait_elapsed.
*/
function _emDaysRemaining(g) {
if (!g.request_initiated_at) return g.wait_days;
const elapsedMs = Date.now() - new Date(g.request_initiated_at).getTime();
return Math.max(0, Math.ceil(g.wait_days - elapsedMs / 86400000));
}
function renderEmergencyGrants(grants) {
const ul = document.getElementById("em-grants-list");
if (!ul) return;
@@ -2471,17 +2481,32 @@ const Vault = (() => {
}
}
if (g.status === "pending") {
// The wait period is the only thing standing between a request and
// the grantee reading the vault, so make the countdown explicit
// rather than showing a bare status word.
const waitInfo = g.wait_elapsed
? "Wait period elapsed"
: "Access requested";
actions = `<span class="badge badge-warn">${waitInfo}</span>
<button class="btn-secondary btn-sm" data-deny="${g.id}">Deny</button>`;
? "Wait elapsed — access is available now"
: `⏳ Unlocks in ${_emDaysRemaining(g)} day(s)`;
actions = `<span class="badge badge-danger">${waitInfo}</span>
<button class="btn-primary btn-sm" data-deny="${g.id}">Deny</button>`;
}
return `<li class="share-item">
// Retrieval is not blocked after the wait elapses, so the grantor's
// signal that it happened is this badge plus their audit log.
const retrieved = g.vault_retrieval_count
? `<span class="share-meta em-retrieved">⚠ Vault retrieved ${
g.vault_retrieval_count
}&times; · first on ${new Date(
g.vault_retrieved_at,
).toLocaleDateString()} remove this grant if unexpected</span>`
: "";
return `<li class="share-item${g.status === "pending" || g.vault_retrieval_count ? " em-alert" : ""}">
<div class="share-icon">🚨</div>
<div class="share-info">
<span class="share-name">${escHtml(g.grantee_email)}</span>
<span class="share-meta">Status: ${escHtml(g.status)} · Wait: ${g.wait_days} day(s)</span>
${retrieved}
</div>
<div class="share-actions">
${actions}
+6 -1
View File
@@ -7,8 +7,13 @@ block body_class %}auth-page{% endblock %} {% block body %}
<span class="logo-text">PassKeeper</span>
</div>
<!-- Deliberately non-committal: the server returns the same response whether
or not the address was already registered, so that registration cannot
be used to probe which addresses have PassKeeper accounts. Asserting
"account created" here would leak what the API withholds. -->
<p id="register-notice" class="notice-success hidden">
Account created! Please sign in.
If that email address was available, your account has been created — please sign in below.
Already had an account? Sign in with your existing master password.
</p>
<!-- Step 1: Email + Master Password -->