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
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:
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
}× · 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}
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
Reference in New Issue
Block a user