diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..8fc472c --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,196 @@ +# .gitea/workflows/ci.yml +# +# PassKeeper CI pipeline — runs on every push and pull request. +# +# Jobs: +# lint-python — flake8 style + error check +# syntax-check — ast.parse all Python files (catches import-time errors) +# migration-check — verify Alembic chain has a single head, no duplicates +# js-syntax — node --check on all JS files +# build-extension — zip the extension for distribution + +name: CI + +on: + push: + branches: ["main", "master", "dev"] + pull_request: + branches: ["main", "master"] + +jobs: + # ── Python lint ────────────────────────────────────────────────────────────── + lint-python: + name: Python lint (flake8) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: "3.12" + + - name: Install flake8 + run: pip install flake8 + + - name: Run flake8 + run: | + flake8 app/ \ + --max-line-length=120 \ + --extend-ignore=E501,W503 \ + --exclude=__pycache__,migrations \ + --statistics + + # ── Python syntax (ast.parse — catches broken imports fast) ───────────────── + syntax-check: + name: Python syntax check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: "3.12" + + - name: Check all Python files parse cleanly + run: | + python3 - << 'EOF' + import ast, sys, pathlib + + failures = [] + for path in pathlib.Path('app').rglob('*.py'): + try: + ast.parse(path.read_text()) + except SyntaxError as e: + failures.append(f"{path}: {e}") + + for f in failures: + print(f"FAIL: {f}") + if failures: + sys.exit(1) + print(f"OK: {len(list(pathlib.Path('app').rglob('*.py')))} Python files parsed cleanly") + EOF + + # ── Alembic migration chain check ─────────────────────────────────────────── + migration-check: + name: Alembic migration chain + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: "3.12" + + - name: Install alembic + run: pip install alembic + + - name: Verify single head, linear chain, no duplicate revisions + run: | + python3 - << 'EOF' + import re, sys, glob + + files = glob.glob('migrations/versions/*.py') + revisions = {} + for f in files: + content = open(f).read() + rev = re.search(r"revision = '([^']+)'", content) + down = re.search(r"down_revision = (.+)", content) + if rev: + rid = rev.group(1) + if rid in revisions: + print(f"FAIL: Duplicate revision ID {rid} in {f}") + sys.exit(1) + revisions[rid] = down.group(1).strip() if down else 'None' + + all_downs = set(revisions.values()) + heads = [r for r in revisions if repr(r) not in all_downs and r not in all_downs] + + if len(heads) != 1: + print(f"FAIL: Expected 1 head, found {len(heads)}: {heads}") + sys.exit(1) + + print(f"OK: {len(revisions)} migrations, single head: {heads[0]}") + EOF + + # ── JS syntax check ────────────────────────────────────────────────────────── + js-syntax: + name: JavaScript syntax check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Set up Node + uses: actions/setup-node@v3 + with: + node-version: "20" + + - name: Check JS files parse cleanly + run: | + FAILED=0 + for f in \ + app/static/js/vault.js \ + app/static/js/auth.js \ + app/static/js/crypto.js \ + app/static/js/sharing.js \ + app/static/js/recover.js \ + extension/popup/popup.js \ + extension/background.js \ + extension/background.firefox.js \ + extension/content/content.js \ + extension/bridge/bridge.js \ + extension/shared/crypto.js; do + if [ -f "$f" ]; then + node --input-type=module < "$f" 2>/dev/null || \ + node -e "new Function(require('fs').readFileSync('$f','utf8'))" 2>/dev/null || \ + { echo "FAIL: $f"; FAILED=1; } + fi + done + [ $FAILED -eq 0 ] && echo "OK: all JS files parsed cleanly" + exit $FAILED + + # ── Extension build ────────────────────────────────────────────────────────── + build-extension: + name: Build extension zip + runs-on: ubuntu-latest + needs: [syntax-check, js-syntax] + steps: + - uses: actions/checkout@v3 + + - name: Build Chrome/Edge extension + run: | + cd extension + zip -r ../passkeeper-extension-chrome.zip . \ + --exclude "*.bak" \ + --exclude "manifest.firefox.json" \ + --exclude "background.firefox.js" + echo "Chrome extension: $(du -sh ../passkeeper-extension-chrome.zip | cut -f1)" + + - name: Build Firefox extension + run: | + cd extension + # Swap manifests for Firefox build + cp manifest.json manifest.chrome.json + cp manifest.firefox.json manifest.json + zip -r ../passkeeper-extension-firefox.zip . \ + --exclude "*.bak" \ + --exclude "manifest.chrome.json" \ + --exclude "background.js" + mv manifest.chrome.json manifest.json + echo "Firefox extension: $(du -sh ../passkeeper-extension-firefox.zip | cut -f1)" + + - name: Upload Chrome extension artifact + uses: actions/upload-artifact@v3 + with: + name: passkeeper-extension-chrome + path: passkeeper-extension-chrome.zip + retention-days: 30 + + - name: Upload Firefox extension artifact + uses: actions/upload-artifact@v3 + with: + name: passkeeper-extension-firefox + path: passkeeper-extension-firefox.zip + retention-days: 30 diff --git a/app/routes/vault.py b/app/routes/vault.py index f67c6b8..58b1e17 100644 --- a/app/routes/vault.py +++ b/app/routes/vault.py @@ -262,4 +262,40 @@ def import_items(): ip_address=client_ip(), ) db.session.commit() - return jsonify({'imported': imported, 'skipped': skipped}), 200 \ No newline at end of file + return jsonify({'imported': imported, 'skipped': skipped}), 200 + +@vault_bp.route('/audit-export', methods=['POST']) +@limiter.limit('30 per minute') +@require_jwt +def audit_bulk_export(): + """ + Record a client-side bulk export in the audit log. + + The bulk export is built entirely in the browser (no server round-trip), + so the server calls this endpoint after the download is triggered. + Accepts a JSON body: { "item_ids": [int, ...] } + Validates that every supplied ID belongs to the current user before logging. + """ + data = request.get_json(silent=True) or {} + raw_ids = data.get('item_ids', []) + if not isinstance(raw_ids, list): + return jsonify({'error': 'item_ids must be an array'}), 400 + + # Validate ownership — only log IDs that belong to the current user. + valid_ids = [ + item.id for item in VaultItem.query.filter( + VaultItem.id.in_(raw_ids), + VaultItem.user_id == g.current_user_id, + ).all() + ] + + AuditLog.log( + user_id=g.current_user_id, + action='vault_item.export_selection', + resource_type='vault_item', + resource_id=None, + detail=f'Bulk exported {len(valid_ids)} selected item(s) (ids: {sorted(valid_ids)[:20]})', + ip_address=client_ip(), + ) + db.session.commit() + return jsonify({'logged': len(valid_ids)}), 200 diff --git a/app/static/js/vault.js b/app/static/js/vault.js index eade3bd..efe2c9f 100644 --- a/app/static/js/vault.js +++ b/app/static/js/vault.js @@ -480,8 +480,8 @@ const Vault = (() => { _sortOrder === "folder" ? Object.keys(groups).sort() : ["(No folder)", ..._folders.map((f) => f.name)].filter( - (k) => groups[k], - ); + (k) => groups[k], + ); Object.keys(groups).forEach((k) => { if (!keys.includes(k)) keys.push(k); }); @@ -549,7 +549,7 @@ const Vault = (() => { case "card": 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; case "bank": @@ -891,6 +891,11 @@ const Vault = (() => { document.body.removeChild(a); URL.revokeObjectURL(url); showToast(`${selected.length} item${selected.length !== 1 ? "s" : ""} exported`); + // Log the export server-side for the audit trail. + apiFetch("/api/vault/audit-export", { + method: "POST", + body: JSON.stringify({ item_ids: selected.map((i) => i.id) }), + }).catch(() => {}); // best-effort — don't block on this _exitSelectMode(); } @@ -1125,9 +1130,9 @@ const Vault = (() => { 0, Math.round( 100 - - (weak.length / total) * 40 - - (reused.length / total) * 30 - - (old.length / total) * 15, + (weak.length / total) * 40 - + (reused.length / total) * 30 - + (old.length / total) * 15, ), ); const cls = @@ -1277,16 +1282,16 @@ const Vault = (() => { `; hibpSection.querySelectorAll("[data-sec-edit]").forEach((btn) => { btn.addEventListener("click", () => { @@ -1848,9 +1853,10 @@ const Vault = (() => { ${escHtml(s.item_name)} From ${escHtml(s.owner_email)}${expiryLabel} - ${!s.accepted - ? `` - : `` + : `` - } + } `; }, ) @@ -2401,9 +2407,10 @@ const Vault = (() => { `; @@ -2732,8 +2739,8 @@ const Vault = (() => { const typeLabel = transports.includes("internal") ? "📱 Device" : transports.some((t) => ["usb", "nfc", "ble", "smart-card"].includes(t)) - ? "🔑 Security key" - : "🔑 Passkey"; + ? "🔑 Security key" + : "🔑 Passkey"; return `
${escHtml(c.name)} @@ -2996,15 +3003,16 @@ const Vault = (() => {

${isFirstTime ? "🔐 MFA enabled — save your backup codes" : "🔐 New backup codes"}

- ${isFirstTime - ? `

+ ${ + isFirstTime + ? `

These codes let you sign in if you lose access to your authenticator app. Save them now — they will not be shown again.

` - : `

+ : `

Your previous codes have been invalidated. Save these new codes securely.

` - } + }
${codesHtml}
@@ -3513,14 +3521,14 @@ const Vault = (() => { const pool = !_activeFilter ? _items : _activeFilter.type === "itemType" - ? _items.filter((i) => i.item_type === _activeFilter.value) - : _activeFilter.type === "folder" - ? _items.filter((i) => i.folder_id === _activeFilter.value) - : _activeFilter.type === "tag" - ? _items.filter((i) => - (i.plain?.tags || []).includes(_activeFilter.value), - ) - : _items; + ? _items.filter((i) => i.item_type === _activeFilter.value) + : _activeFilter.type === "folder" + ? _items.filter((i) => i.folder_id === _activeFilter.value) + : _activeFilter.type === "tag" + ? _items.filter((i) => + (i.plain?.tags || []).includes(_activeFilter.value), + ) + : _items; renderItemList( pool.filter((item) => { const p = item.plain || {}; @@ -3682,36 +3690,49 @@ const Vault = (() => { const history = item.plain?.password_history || []; if (history.length) { historyPanel.classList.remove("hidden"); + + // Store plaintext passwords in a WeakMap keyed by button element so + // they never appear in the DOM (no data-pw attributes to scrape). + const _historyPasswords = new WeakMap(); + historyPanel.innerHTML = `
🕐 Previous passwords (${history.length})
` + - history.map((h) => { + history.map((h, idx) => { const date = h.changed_at ? new Date(h.changed_at).toLocaleDateString() : "Unknown date"; - return `
+ return `
•••••••• ${escHtml(date)} - - + +
`; }).join(""); - historyPanel.querySelectorAll(".pw-history-reveal").forEach((btn) => { - btn.addEventListener("click", () => { - const masked = btn.closest(".pw-history-row").querySelector(".pw-history-masked"); + + // Register passwords in the WeakMap after DOM elements exist. + historyPanel.querySelectorAll(".pw-history-row").forEach((row) => { + const idx = parseInt(row.dataset.histIdx); + const pw = history[idx]?.password || ""; + const revealBtn = row.querySelector(".pw-history-reveal"); + const restoreBtn = row.querySelector(".pw-history-restore"); + _historyPasswords.set(revealBtn, pw); + _historyPasswords.set(restoreBtn, pw); + + revealBtn.addEventListener("click", () => { + const masked = row.querySelector(".pw-history-masked"); if (masked.textContent === "••••••••") { - masked.textContent = btn.dataset.pw; - btn.textContent = "🙈"; + masked.textContent = _historyPasswords.get(revealBtn); + revealBtn.textContent = "🙈"; } else { masked.textContent = "••••••••"; - btn.textContent = "👁"; + revealBtn.textContent = "👁"; } }); - }); - historyPanel.querySelectorAll(".pw-history-restore").forEach((btn) => { - btn.addEventListener("click", () => { + + restoreBtn.addEventListener("click", () => { const pwField = document.getElementById("field-password"); if (pwField) { - pwField.value = btn.dataset.pw; + pwField.value = _historyPasswords.get(restoreBtn); pwField.dispatchEvent(new Event("input")); showToast("Password restored — click Save to apply"); } @@ -3729,7 +3750,7 @@ const Vault = (() => { if ( (mode === "add" && (document.getElementById("field-type").value || "password") === - "password") || + "password") || (mode === "edit" && (item?.item_type || "password") === "password") ) { initPasswordFieldEnhancements(); @@ -3994,7 +4015,7 @@ const Vault = (() => { "X-CSRFToken": csrfToken(), }, body: JSON.stringify({ refresh_token: refreshToken }), - }).catch(() => { }); + }).catch(() => {}); VaultSession.clear(); SharingSession.clear(); sessionStorage.removeItem("access_token"); @@ -4099,11 +4120,11 @@ const Vault = (() => { // Auto-clear clipboard after 30 seconds — industry-standard hygiene. if (_clipboardClearTimer) clearTimeout(_clipboardClearTimer); _clipboardClearTimer = setTimeout(() => { - navigator.clipboard.writeText("").catch(() => { }); + navigator.clipboard.writeText("").catch(() => {}); _clipboardClearTimer = null; }, 30_000); }) - .catch(() => { }); + .catch(() => {}); } function escHtml(str) { @@ -4723,4 +4744,4 @@ const Vault = (() => { } })(); -document.addEventListener("DOMContentLoaded", Vault.init); \ No newline at end of file +document.addEventListener("DOMContentLoaded", Vault.init); diff --git a/scripts/restore_db.sh b/scripts/restore_db.sh new file mode 100644 index 0000000..beca951 --- /dev/null +++ b/scripts/restore_db.sh @@ -0,0 +1,154 @@ +#!/usr/bin/env bash +# /home/spuser/PassKeeper/scripts/restore_db.sh +# +# Restore a PassKeeper MySQL backup created by backup_db.sh. +# +# SAFETY FEATURES: +# - Requires explicit confirmation before overwriting the live database. +# - Creates a pre-restore safety dump of the current DB before touching it. +# - Verifies the backup file is a valid gzip before proceeding. +# - Stops the PassKeeper service before restore, restarts it after. +# - Runs flask db upgrade after restore to ensure the schema is current. +# +# Usage: +# bash scripts/restore_db.sh /path/to/passkeeper_20260101_020000.sql.gz +# +# Dry-run (verify only, no changes): +# DRY_RUN=1 bash scripts/restore_db.sh /path/to/backup.sql.gz + +set -euo pipefail + +# ── Configuration ────────────────────────────────────────────────────────────── +SCRIPT_DIR="$(dirname "$(realpath "$0")")" +PROJECT_DIR="$(realpath "$SCRIPT_DIR/..")" +ENV_FILE="$PROJECT_DIR/.env" +SERVICE_NAME="${PASSKEEPER_SERVICE:-passkeeper}" +BACKUP_DIR="${BACKUP_DIR:-/home/spuser/backups/passkeeper}" +LOG_FILE="${BACKUP_DIR}/restore.log" +DRY_RUN="${DRY_RUN:-0}" + +# ── Helpers ──────────────────────────────────────────────────────────────────── +log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG_FILE"; } +die() { log "ERROR $*"; exit 1; } +info() { log "INFO $*"; } + +# ── Argument check ───────────────────────────────────────────────────────────── +[[ $# -lt 1 ]] && die "Usage: $0 " +BACKUP_FILE="$(realpath "$1")" +[[ -f "$BACKUP_FILE" ]] || die "Backup file not found: $BACKUP_FILE" + +# ── Load .env ───────────────────────────────────────────────────────────────── +[[ -f "$ENV_FILE" ]] || die ".env file not found at $ENV_FILE" +set -o allexport +# shellcheck disable=SC1090 +source "$ENV_FILE" +set +o allexport + +DB_HOST="${MYSQL_HOST:-127.0.0.1}" +DB_PORT="${MYSQL_PORT:-3306}" +DB_USER="${MYSQL_USER:-passkeeper}" +DB_PASS="${MYSQL_PASSWORD}" +DB_NAME="${MYSQL_DB:-passkeeper}" + +mkdir -p "$BACKUP_DIR" + +info "=== PassKeeper database restore ===" +info "Backup file : $BACKUP_FILE" +info "Target DB : $DB_NAME @ $DB_HOST:$DB_PORT" +info "Service : $SERVICE_NAME" +[[ "$DRY_RUN" == "1" ]] && info "MODE : DRY RUN — no changes will be made" + +# ── Validate backup file ─────────────────────────────────────────────────────── +info "Validating backup file integrity…" +if ! gzip -t "$BACKUP_FILE" 2>/dev/null; then + die "Backup file failed gzip integrity check: $BACKUP_FILE" +fi +BACKUP_SIZE="$(du -sh "$BACKUP_FILE" | cut -f1)" +info "Backup size : $BACKUP_SIZE — gzip OK" + +# ── Confirmation ─────────────────────────────────────────────────────────────── +if [[ "$DRY_RUN" != "1" ]]; then + echo "" + echo " ⚠️ WARNING: This will OVERWRITE the live database '$DB_NAME'." + echo " All current data will be replaced with the contents of:" + echo " $BACKUP_FILE" + echo "" + read -r -p " Type 'yes' to continue: " CONFIRM + [[ "$CONFIRM" == "yes" ]] || { info "Restore cancelled by user."; exit 0; } +fi + +[[ "$DRY_RUN" == "1" ]] && { info "Dry run complete — backup file is valid."; exit 0; } + +# ── Pre-restore safety dump ──────────────────────────────────────────────────── +SAFETY_TIMESTAMP="$(date +%Y%m%d_%H%M%S)" +SAFETY_FILE="${BACKUP_DIR}/pre_restore_safety_${SAFETY_TIMESTAMP}.sql.gz" +info "Creating pre-restore safety dump → $SAFETY_FILE" +MYSQL_PWD="$DB_PASS" mysqldump \ + --host="$DB_HOST" \ + --port="$DB_PORT" \ + --user="$DB_USER" \ + --single-transaction \ + --no-tablespaces \ + "$DB_NAME" \ + | gzip -9 > "$SAFETY_FILE" \ + || die "Safety dump failed — aborting restore. Database is unchanged." +info "Safety dump complete: $(du -sh "$SAFETY_FILE" | cut -f1)" + +# ── Stop service ─────────────────────────────────────────────────────────────── +info "Stopping $SERVICE_NAME…" +sudo systemctl stop "$SERVICE_NAME" || die "Failed to stop $SERVICE_NAME" +info "$SERVICE_NAME stopped" + +RESTORE_OK=0 + +# ── Restore ──────────────────────────────────────────────────────────────────── +info "Restoring database from backup…" +if MYSQL_PWD="$DB_PASS" gunzip -c "$BACKUP_FILE" | mysql \ + --host="$DB_HOST" \ + --port="$DB_PORT" \ + --user="$DB_USER" \ + "$DB_NAME"; then + info "Database restore complete" + RESTORE_OK=1 +else + log "ERROR Database restore failed — attempting to roll back from safety dump" + if MYSQL_PWD="$DB_PASS" gunzip -c "$SAFETY_FILE" | mysql \ + --host="$DB_HOST" \ + --port="$DB_PORT" \ + --user="$DB_USER" \ + "$DB_NAME"; then + log "WARN Rolled back to pre-restore state from $SAFETY_FILE" + else + log "ERROR Rollback also failed — database may be in an inconsistent state" + log "ERROR Manual recovery required. Safety dump: $SAFETY_FILE" + fi +fi + +# ── Run migrations ───────────────────────────────────────────────────────────── +if [[ "$RESTORE_OK" == "1" ]]; then + info "Running flask db upgrade to ensure schema is current…" + cd "$PROJECT_DIR" + if source .venv/bin/activate 2>/dev/null; then + flask db upgrade && info "flask db upgrade OK" || \ + log "WARN flask db upgrade failed — check migrations manually" + deactivate 2>/dev/null || true + else + log "WARN Could not activate .venv — skipping flask db upgrade" + log "WARN Run 'flask db upgrade' manually before restarting the service" + fi +fi + +# ── Restart service ──────────────────────────────────────────────────────────── +info "Starting $SERVICE_NAME…" +sudo systemctl start "$SERVICE_NAME" && info "$SERVICE_NAME started" || \ + die "$SERVICE_NAME failed to start — check 'journalctl -u $SERVICE_NAME -n 50'" + +# ── Summary ──────────────────────────────────────────────────────────────────── +echo "" +if [[ "$RESTORE_OK" == "1" ]]; then + info "=== Restore finished successfully ===" + info "Restored from : $BACKUP_FILE" + info "Safety dump : $SAFETY_FILE (kept for 24 h — delete manually when satisfied)" +else + die "=== Restore FAILED — see log for details: $LOG_FILE ===" +fi