05/19 Enhance codes 2

This commit is contained in:
2026-05-19 09:44:51 -04:00
parent f95461aee2
commit c42164e21c
6 changed files with 175 additions and 49 deletions
+9
View File
@@ -195,9 +195,18 @@ def create_app(config_name: str = 'development') -> Flask:
from app.models.token_blacklist import TokenBlacklist from app.models.token_blacklist import TokenBlacklist
from app.models.recovery_challenge import RecoveryChallenge from app.models.recovery_challenge import RecoveryChallenge
from app.models.totp_used_code import TotpUsedCode from app.models.totp_used_code import TotpUsedCode
from app.models.shared_item import SharedItem
TokenBlacklist.cleanup_expired() TokenBlacklist.cleanup_expired()
RecoveryChallenge.cleanup_expired() RecoveryChallenge.cleanup_expired()
TotpUsedCode.cleanup_expired() TotpUsedCode.cleanup_expired()
# Delete expired unaccepted shares.
from datetime import datetime, timezone
SharedItem.query.filter(
SharedItem.accepted == False,
SharedItem.expires_at != None,
SharedItem.expires_at <= datetime.now(timezone.utc).replace(tzinfo=None),
).delete()
db.session.commit()
import logging import logging
logging.getLogger(__name__).debug( logging.getLogger(__name__).debug(
'[PassKeeper] token_blacklist + recovery_challenges cleanup completed' '[PassKeeper] token_blacklist + recovery_challenges cleanup completed'
+11
View File
@@ -48,6 +48,15 @@ class SharedItem(db.Model):
accepted = db.Column(db.Boolean, default=False, nullable=False) accepted = db.Column(db.Boolean, default=False, nullable=False)
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc).replace(tzinfo=None), nullable=False) created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc).replace(tzinfo=None), nullable=False)
# Optional expiry — NULL means the share never expires.
# Expired unaccepted shares are hidden from the inbox; accepted shares remain.
expires_at = db.Column(db.DateTime, nullable=True)
def is_expired(self) -> bool:
"""Return True if the share has an expiry and it has passed."""
if not self.expires_at:
return False
return datetime.now(timezone.utc).replace(tzinfo=None) >= self.expires_at
def to_dict(self): def to_dict(self):
return { return {
@@ -64,4 +73,6 @@ class SharedItem(db.Model):
'iv_name': self.iv_name, 'iv_name': self.iv_name,
'accepted': self.accepted, 'accepted': self.accepted,
'created_at': self.created_at.isoformat() if self.created_at else None, 'created_at': self.created_at.isoformat() if self.created_at else None,
'expires_at': self.expires_at.isoformat() if self.expires_at else None,
'is_expired': self.is_expired(),
} }
+30 -2
View File
@@ -1,3 +1,5 @@
from datetime import datetime, timezone
from flask import Blueprint, request, jsonify, g from flask import Blueprint, request, jsonify, g
from app import db, limiter, client_ip from app import db, limiter, client_ip
from app.models.user import User from app.models.user import User
@@ -126,6 +128,21 @@ def create_share():
# Encrypted display name — encrypted with the ECDH shared secret client-side. # Encrypted display name — encrypted with the ECDH shared secret client-side.
enc_name = data.get('enc_name') or None enc_name = data.get('enc_name') or None
iv_name = data.get('iv_name') or None iv_name = data.get('iv_name') or None
# Optional expiry: number of days until the share expires (None = never).
# Accepted values: 1, 7, 30, 90, None.
expires_days = data.get('expires_days')
expires_at = None
if expires_days is not None:
try:
expires_days = int(expires_days)
if expires_days > 0:
from datetime import timedelta
expires_at = (
datetime.now(timezone.utc).replace(tzinfo=None)
+ timedelta(days=expires_days)
)
except (TypeError, ValueError):
pass
if not all([item_id, recipient_email, enc_data, iv, item_name]): if not all([item_id, recipient_email, enc_data, iv, item_name]):
return jsonify({'error': 'item_id, recipient_email, enc_data, iv, item_name are required'}), 400 return jsonify({'error': 'item_id, recipient_email, enc_data, iv, item_name are required'}), 400
@@ -153,6 +170,7 @@ def create_share():
iv=iv, iv=iv,
enc_name=enc_name, enc_name=enc_name,
iv_name=iv_name, iv_name=iv_name,
expires_at=expires_at,
) )
db.session.add(share) db.session.add(share)
db.session.flush() # populate share.id before logging db.session.flush() # populate share.id before logging
@@ -201,15 +219,25 @@ def delete_share(share_id):
@limiter.limit('60 per minute') @limiter.limit('60 per minute')
@require_jwt @require_jwt
def inbox(): def inbox():
"""List all items shared with the current user.""" """List all items shared with the current user.
Expired unaccepted shares are excluded — they can no longer be acted on.
Expired accepted shares remain visible since the data was already accepted.
"""
user = db.session.get(User, g.current_user_id) user = db.session.get(User, g.current_user_id)
now = datetime.now(timezone.utc).replace(tzinfo=None)
shares = ( shares = (
SharedItem.query SharedItem.query
.filter( .filter(
db.or_( db.or_(
SharedItem.recipient_email == user.email, SharedItem.recipient_email == user.email,
SharedItem.recipient_id == user.id, SharedItem.recipient_id == user.id,
) ),
# Exclude expired unaccepted shares.
db.or_(
SharedItem.accepted == True,
SharedItem.expires_at == None,
SharedItem.expires_at > now,
),
) )
.order_by(SharedItem.created_at.desc()) .order_by(SharedItem.created_at.desc())
.all() .all()
+81 -46
View File
@@ -471,8 +471,8 @@ const Vault = (() => {
_sortOrder === "folder" _sortOrder === "folder"
? Object.keys(groups).sort() ? Object.keys(groups).sort()
: ["(No folder)", ..._folders.map((f) => f.name)].filter( : ["(No folder)", ..._folders.map((f) => f.name)].filter(
(k) => groups[k], (k) => groups[k],
); );
Object.keys(groups).forEach((k) => { Object.keys(groups).forEach((k) => {
if (!keys.includes(k)) keys.push(k); if (!keys.includes(k)) keys.push(k);
}); });
@@ -540,7 +540,7 @@ const Vault = (() => {
case "card": case "card":
subText = item.plain.card_number subText = item.plain.card_number
? "•••• " + ? "•••• " +
String(item.plain.card_number).replace(/\s/g, "").slice(-4) String(item.plain.card_number).replace(/\s/g, "").slice(-4)
: ""; : "";
break; break;
case "bank": case "bank":
@@ -920,9 +920,9 @@ const Vault = (() => {
0, 0,
Math.round( Math.round(
100 - 100 -
(weak.length / total) * 40 - (weak.length / total) * 40 -
(reused.length / total) * 30 - (reused.length / total) * 30 -
(old.length / total) * 15, (old.length / total) * 15,
), ),
); );
const cls = const cls =
@@ -1072,16 +1072,16 @@ const Vault = (() => {
</div> </div>
<ul class="sec-item-list"> <ul class="sec-item-list">
${breached ${breached
.map((i) => { .map((i) => {
const count = const count =
hibpResults.find((r) => r.item.id === i.id)?.count || 0; hibpResults.find((r) => r.item.id === i.id)?.count || 0;
return `<li class="sec-item"> return `<li class="sec-item">
<span class="sec-item-name">${escHtml(i.name)}</span> <span class="sec-item-name">${escHtml(i.name)}</span>
<span class="sec-item-sub">${escHtml(i.plain?.username || "")} — seen ${count.toLocaleString()} time${count !== 1 ? "s" : ""} in breaches</span> <span class="sec-item-sub">${escHtml(i.plain?.username || "")} — seen ${count.toLocaleString()} time${count !== 1 ? "s" : ""} in breaches</span>
<button class="btn-secondary btn-sm" data-sec-edit="${i.id}">Change</button> <button class="btn-secondary btn-sm" data-sec-edit="${i.id}">Change</button>
</li>`; </li>`;
}) })
.join("")} .join("")}
</ul>`; </ul>`;
hibpSection.querySelectorAll("[data-sec-edit]").forEach((btn) => { hibpSection.querySelectorAll("[data-sec-edit]").forEach((btn) => {
btn.addEventListener("click", () => { btn.addEventListener("click", () => {
@@ -1577,12 +1577,21 @@ const Vault = (() => {
// name for shares created before the enc_name migration. // name for shares created before the enc_name migration.
const vaultItem = _items.find((i) => i.id === s.item_id); const vaultItem = _items.find((i) => i.id === s.item_id);
const displayName = vaultItem?.name || s.item_name; const displayName = vaultItem?.name || s.item_name;
const expiryLabel = (() => {
if (!s.expires_at) return "";
const daysLeft = Math.ceil(
(new Date(s.expires_at).getTime() - Date.now()) / 86400000,
);
if (daysLeft <= 0) return " · <span class=\"badge badge-danger\">Expired</span>";
if (daysLeft <= 3) return ` · <span class="badge badge-warn">Expires in ${daysLeft}d</span>`;
return ` · Expires ${new Date(s.expires_at).toLocaleDateString()}`;
})();
return ` return `
<li class="share-item"> <li class="share-item">
<div class="share-icon">${itemIcon(s.item_type)}</div> <div class="share-icon">${itemIcon(s.item_type)}</div>
<div class="share-info"> <div class="share-info">
<span class="share-name">${escHtml(displayName)}</span> <span class="share-name">${escHtml(displayName)}</span>
<span class="share-meta">Shared with ${escHtml(s.recipient_email)} · ${s.accepted ? "✅ Accepted" : "⏳ Pending"}</span> <span class="share-meta">Shared with ${escHtml(s.recipient_email)} · ${s.accepted ? "✅ Accepted" : "⏳ Pending"}${expiryLabel}</span>
</div> </div>
<button class="btn-icon" title="Revoke share" data-revoke="${s.id}">🗑️</button> <button class="btn-icon" title="Revoke share" data-revoke="${s.id}">🗑️</button>
</li>`; </li>`;
@@ -1616,14 +1625,26 @@ const Vault = (() => {
} }
ul.innerHTML = shares ul.innerHTML = shares
.map( .map(
(s) => ` (s) => {
// Expiry label for pending (unaccepted) items.
const expiryLabel = (() => {
if (!s.expires_at || s.accepted) return "";
const daysLeft = Math.ceil(
(new Date(s.expires_at).getTime() - Date.now()) / 86400000,
);
if (daysLeft <= 3)
return ` · <span class="badge badge-warn">Expires in ${daysLeft}d</span>`;
return ` · Expires ${new Date(s.expires_at).toLocaleDateString()}`;
})();
return `
<li class="share-item"> <li class="share-item">
<div class="share-icon">${itemIcon(s.item_type)}</div> <div class="share-icon">${itemIcon(s.item_type)}</div>
<div class="share-info"> <div class="share-info">
<span class="share-name">${escHtml(s.item_name)}</span> <span class="share-name">${escHtml(s.item_name)}</span>
<span class="share-meta">From ${escHtml(s.owner_email)}</span> <span class="share-meta">From ${escHtml(s.owner_email)}${expiryLabel}</span>
</div> </div>
${!s.accepted ${
!s.accepted
? `<button class="btn-primary btn-sm" data-accept="${s.id}">Accept</button>` ? `<button class="btn-primary btn-sm" data-accept="${s.id}">Accept</button>`
: `<button class="btn-secondary btn-sm" : `<button class="btn-secondary btn-sm"
data-view-share="${s.id}" data-view-share="${s.id}"
@@ -1634,8 +1655,9 @@ const Vault = (() => {
data-iv-name="${escHtml(s.iv_name || "")}" data-iv-name="${escHtml(s.iv_name || "")}"
data-name="${escHtml(s.item_name)}" data-name="${escHtml(s.item_name)}"
data-type="${escHtml(s.item_type || "")}">View</button>` data-type="${escHtml(s.item_type || "")}">View</button>`
} }
</li>`, </li>`;
},
) )
.join(""); .join("");
@@ -1814,6 +1836,11 @@ const Vault = (() => {
item_type: item.item_type, item_type: item.item_type,
enc_name, enc_name,
iv_name, iv_name,
expires_days: (() => {
const sel = document.getElementById("share-expiry-select");
const v = sel ? parseInt(sel.value) : NaN;
return isNaN(v) || v === 0 ? null : v;
})(),
}), }),
}); });
if (!res) return; if (!res) return;
@@ -2170,9 +2197,10 @@ const Vault = (() => {
<span class="em-vault-chevron">▸</span> <span class="em-vault-chevron">▸</span>
</button> </button>
<div class="em-vault-item-body hidden" id="em-vault-body-${idx}"> <div class="em-vault-item-body hidden" id="em-vault-body-${idx}">
${hasFields ${
? '<div class="detail-body em-detail-body"></div>' hasFields
: '<p class="vault-empty">Could not decrypt this item.</p>' ? '<div class="detail-body em-detail-body"></div>'
: '<p class="vault-empty">Could not decrypt this item.</p>'
} }
</div> </div>
</li>`; </li>`;
@@ -2501,8 +2529,8 @@ const Vault = (() => {
const typeLabel = transports.includes("internal") const typeLabel = transports.includes("internal")
? "📱 Device" ? "📱 Device"
: transports.some((t) => ["usb", "nfc", "ble", "smart-card"].includes(t)) : transports.some((t) => ["usb", "nfc", "ble", "smart-card"].includes(t))
? "🔑 Security key" ? "🔑 Security key"
: "🔑 Passkey"; : "🔑 Passkey";
return `<div class="passkey-item" data-cred-id="${c.id}"> return `<div class="passkey-item" data-cred-id="${c.id}">
<div class="passkey-info"> <div class="passkey-info">
<span class="passkey-name">${escHtml(c.name)}</span> <span class="passkey-name">${escHtml(c.name)}</span>
@@ -2765,15 +2793,16 @@ const Vault = (() => {
<h3 style="margin:0 0 8px;font-size:16px;color:#111827;"> <h3 style="margin:0 0 8px;font-size:16px;color:#111827;">
${isFirstTime ? "🔐 MFA enabled — save your backup codes" : "🔐 New backup codes"} ${isFirstTime ? "🔐 MFA enabled — save your backup codes" : "🔐 New backup codes"}
</h3> </h3>
${isFirstTime ${
? `<p style="font-size:13px;color:#374151;margin-bottom:12px;"> isFirstTime
? `<p style="font-size:13px;color:#374151;margin-bottom:12px;">
These codes let you sign in if you lose access to your authenticator app. These codes let you sign in if you lose access to your authenticator app.
<strong>Save them now — they will not be shown again.</strong> <strong>Save them now — they will not be shown again.</strong>
</p>` </p>`
: `<p style="font-size:13px;color:#374151;margin-bottom:12px;"> : `<p style="font-size:13px;color:#374151;margin-bottom:12px;">
Your previous codes have been invalidated. Save these new codes securely. Your previous codes have been invalidated. Save these new codes securely.
</p>` </p>`
} }
<div style="background:#f9fafb;border:1px solid #e5e7eb;border-radius:8px;padding:12px;display:flex;flex-wrap:wrap;gap:6px;margin-bottom:16px;"> <div style="background:#f9fafb;border:1px solid #e5e7eb;border-radius:8px;padding:12px;display:flex;flex-wrap:wrap;gap:6px;margin-bottom:16px;">
${codesHtml} ${codesHtml}
</div> </div>
@@ -3282,23 +3311,29 @@ const Vault = (() => {
const pool = !_activeFilter const pool = !_activeFilter
? _items ? _items
: _activeFilter.type === "itemType" : _activeFilter.type === "itemType"
? _items.filter((i) => i.item_type === _activeFilter.value) ? _items.filter((i) => i.item_type === _activeFilter.value)
: _activeFilter.type === "folder" : _activeFilter.type === "folder"
? _items.filter((i) => i.folder_id === _activeFilter.value) ? _items.filter((i) => i.folder_id === _activeFilter.value)
: _activeFilter.type === "tag" : _activeFilter.type === "tag"
? _items.filter((i) => ? _items.filter((i) =>
(i.plain?.tags || []).includes(_activeFilter.value), (i.plain?.tags || []).includes(_activeFilter.value),
) )
: _items; : _items;
renderItemList( renderItemList(
pool.filter( pool.filter((item) => {
(item) => const p = item.plain || {};
return (
item.name.toLowerCase().includes(q) || item.name.toLowerCase().includes(q) ||
(item.plain?.username || "").toLowerCase().includes(q) || (p.username || "").toLowerCase().includes(q) ||
(item.plain?.url || "").toLowerCase().includes(q) || (p.url || "").toLowerCase().includes(q) ||
(item.plain?.note_body || "").toLowerCase().includes(q) || (p.note_body || "").toLowerCase().includes(q) ||
(item.plain?.rp_id || "").toLowerCase().includes(q), (p.rp_id || "").toLowerCase().includes(q) ||
), (p.notes || "").toLowerCase().includes(q) ||
(p.cardholder_name || "").toLowerCase().includes(q) ||
(p.email || "").toLowerCase().includes(q) ||
(p.tags || []).some((t) => t.toLowerCase().includes(q))
);
}),
); );
} }
@@ -3440,7 +3475,7 @@ const Vault = (() => {
if ( if (
(mode === "add" && (mode === "add" &&
(document.getElementById("field-type").value || "password") === (document.getElementById("field-type").value || "password") ===
"password") || "password") ||
(mode === "edit" && (item?.item_type || "password") === "password") (mode === "edit" && (item?.item_type || "password") === "password")
) { ) {
initPasswordFieldEnhancements(); initPasswordFieldEnhancements();
@@ -3697,7 +3732,7 @@ const Vault = (() => {
"X-CSRFToken": csrfToken(), "X-CSRFToken": csrfToken(),
}, },
body: JSON.stringify({ refresh_token: refreshToken }), body: JSON.stringify({ refresh_token: refreshToken }),
}).catch(() => { }); }).catch(() => {});
VaultSession.clear(); VaultSession.clear();
SharingSession.clear(); SharingSession.clear();
sessionStorage.removeItem("access_token"); sessionStorage.removeItem("access_token");
@@ -3802,11 +3837,11 @@ const Vault = (() => {
// Auto-clear clipboard after 30 seconds — industry-standard hygiene. // Auto-clear clipboard after 30 seconds — industry-standard hygiene.
if (_clipboardClearTimer) clearTimeout(_clipboardClearTimer); if (_clipboardClearTimer) clearTimeout(_clipboardClearTimer);
_clipboardClearTimer = setTimeout(() => { _clipboardClearTimer = setTimeout(() => {
navigator.clipboard.writeText("").catch(() => { }); navigator.clipboard.writeText("").catch(() => {});
_clipboardClearTimer = null; _clipboardClearTimer = null;
}, 30_000); }, 30_000);
}) })
.catch(() => { }); .catch(() => {});
} }
function escHtml(str) { function escHtml(str) {
+10
View File
@@ -1156,6 +1156,16 @@
placeholder="recipient@example.com" placeholder="recipient@example.com"
/> />
</div> </div>
<div class="form-group">
<label for="share-expiry-select">Expires</label>
<select id="share-expiry-select">
<option value="0">Never</option>
<option value="1">1 day</option>
<option value="7" selected>7 days</option>
<option value="30">30 days</option>
<option value="90">90 days</option>
</select>
</div>
<div class="modal-actions"> <div class="modal-actions">
<button type="button" class="btn-secondary" id="btn-cancel-share"> <button type="button" class="btn-secondary" id="btn-cancel-share">
Cancel Cancel
@@ -0,0 +1,33 @@
"""add expires_at to shared_items
Revision ID: i9j0k1l2m3n4
Revises: h8i9j0k1l2m3
Create Date: 2026-05-19 00:00:00.000000
Adds an optional expiry timestamp to shared items.
- NULL = share never expires (backwards compatible with existing rows)
- value = share is considered expired after this datetime
Expired unaccepted shares are filtered from the recipient's inbox.
Expired accepted shares remain visible (data already accepted into vault).
The APScheduler cleanup job deletes expired unaccepted shares nightly.
"""
from alembic import op
import sqlalchemy as sa
revision = 'i9j0k1l2m3n4'
down_revision = 'h8i9j0k1l2m3'
branch_labels = None
depends_on = None
def upgrade():
op.add_column(
'shared_items',
sa.Column('expires_at', sa.DateTime, nullable=True),
)
def downgrade():
op.drop_column('shared_items', 'expires_at')