diff --git a/README.md b/README.md index 256365e..f8f04ee 100644 --- a/README.md +++ b/README.md @@ -1,78 +1,125 @@ # SpotNotes -SpotNotes is a location-based note-taking web application. It allows users to drop notes on an interactive map, organize them into notepads, and optionally share them with the public or specific users. +SpotNotes is a privacy-first, location-based note-taking web application. It allows users to drop notes on an interactive map, organize them into notepads, and share them publicly or securely with specific users using zero-knowledge end-to-end encryption. + +--- + +## πŸ”’ Encryption-First Architecture (Zero-Knowledge E2EE) + +SpotNotes is built with an **encryption-first mindset**: + +- **Public Notes:** Stored unencrypted at rest on the server and served openly for community guides, road trips, and landmarks. +- **Private Notes:** 100% **End-to-End Encrypted (E2EE)** at rest. All private notes (including title, content, and geographic coordinates) are encrypted client-side in the user's browser using the standard **Web Crypto API** before transmission. +- **Zero-Knowledge for Server Operators:** The server operator, database administrators, and hosting infrastructure cannot read private notes, note titles, or note coordinates. The server stores only ciphertext payloads. +- **Secure Asymmetric Sharing:** + - **Collaborators:** When an owner shares a private notepad with another user, the owner's browser retrieves the recipient's RSA public key and encrypts the symmetric notepad key with it. Only the collaborator's private key can decrypt the notepad. + - **Zero-Knowledge Link Sharing:** When sharing a private note or notepad via link, the decryption key is placed into the URL hash fragment (`#key=...`). Browsers never send the URL hash to the HTTP server, ensuring zero-knowledge link sharing. + - **Decrypted Client-Side CSV Export:** When exporting private notepads to CSV, notes are decrypted and formatted directly in the browser. + +### Cryptographic Specifications +1. **User Keypairs:** RSA-OAEP (2048-bit modulus, SHA-256). +2. **Password Key Derivation (KEK):** PBKDF2 with SHA-256, 100,000 iterations, and a unique 16-byte random salt per user. +3. **Private Key Protection:** The user's RSA private key is encrypted with AES-GCM-256 using the password-derived KEK before being stored on the server. +4. **Notepad Keys:** Symmetric AES-GCM 256-bit keys, wrapped per user via RSA-OAEP. +5. **Note Payload Encryption:** AES-GCM-256 with a unique 12-byte cryptographic nonce (IV) per note revision. + +--- ## Features -- **Interactive Map:** View and create notes on a map powered by Leaflet and OpenStreetMap. -- **Location-Based Notes:** Every note is tied to specific geographic coordinates. -- **Notepads:** Organize notes into collections called "Notepads". -- **Privacy & Sharing:** Notepads can be private (only you), public (everyone), or shared via link tokens. -- **User Authentication:** Secure registration and login system. -- **Favorites:** Save and easily access your favorite public notepads. -- **Search & Geocoding:** Search through your notes or use geocoding to find locations worldwide. -- **Admin Dashboard:** Manage users, notepads, and notes. +- **Interactive Map:** View and create notes on a high-performance Leaflet & OpenStreetMap interface. +- **Location-Based Notes:** Every note is placed at precise coordinates. +- **Public & Private Notepads:** Choose between open public discovery or zero-knowledge private notes. +- **E2EE Collaboration:** Add collaborators with Read or Read/Write permissions with public-key key exchange. +- **Zero-Knowledge Sharing Links:** Share private notes/notepads securely via link tokens and URL key fragments. +- **Local Decrypted Search & Geocoding:** Search notes across public feeds and client-side decrypted private notebooks, or geocode addresses globally. +- **Client-Side CSV Export:** Download clean CSV backups of your decrypted notes. +- **Mobile-Optimized Interface:** Responsive bottom-sheet design with fully rounded open/close menu toggle handles. +- **Admin Panel:** Content moderation for public notes and IP management without exposing private ciphertext. + +--- ## Tech Stack -- **Backend:** Python, Flask, Flask-SQLAlchemy (with SQLite for development/PostgreSQL for production). -- **Frontend:** HTML5, CSS3 (Vanilla), Vanilla JavaScript, Leaflet.js for maps. -- **Deployment:** Configured for Gunicorn and standard WSGI environments (includes `Procfile` and `gunicorn_config.py`). +- **Backend:** Python, Flask, Flask-SQLAlchemy (SQLite for development / PostgreSQL for production), Gunicorn. +- **Frontend:** Vanilla HTML5, Modern CSS3 with Glassmorphism, Vanilla JavaScript (ES6+), Web Crypto API, Leaflet.js. +- **Security:** Web Crypto API (`crypto.subtle`), PBKDF2, AES-GCM, RSA-OAEP, Werkzeug password hashing. + +--- ## Project Structure ``` spotnotes.app/ β”‚ -β”œβ”€β”€ app.py # Main application entry point +β”œβ”€β”€ app.py # Main application entry point & DB initialization β”œβ”€β”€ config.py # Configuration settings β”œβ”€β”€ extensions.py # Flask extensions (SQLAlchemy, CORS) β”œβ”€β”€ requirements.txt # Python dependencies +β”œβ”€β”€ seed_notes.py # CLI tool to seed public landmark notes from CSV β”‚ β”œβ”€β”€ blueprints/ # Flask Blueprints (Controllers) β”‚ β”œβ”€β”€ admin.py # Admin dashboard routes -β”‚ β”œβ”€β”€ api.py # Main REST API for notes/notepads -β”‚ β”œβ”€β”€ auth.py # User authentication routes -β”‚ β”œβ”€β”€ search.py # Search and geocoding logic +β”‚ β”œβ”€β”€ api.py # REST API for notes, notepads, keys, & collaborators +β”‚ β”œβ”€β”€ auth.py # User authentication with public key exchange +β”‚ β”œβ”€β”€ search.py # Search (public notes) and geocoding proxy β”‚ └── static_routes.py # Serving static HTML pages β”‚ β”œβ”€β”€ models/ # SQLAlchemy Database Models β”‚ β”œβ”€β”€ banned_ip.py # Security model for IP bans +β”‚ β”œβ”€β”€ collaborator.py # Notepad collaboration permissions β”‚ β”œβ”€β”€ favorite.py # User's favorited notepads -β”‚ β”œβ”€β”€ note.py # Core note data structure +β”‚ β”œβ”€β”€ note.py # Note model (supports encrypted & plaintext notes) β”‚ β”œβ”€β”€ notepad.py # Collections of notes -β”‚ └── user.py # User account data +β”‚ β”œβ”€β”€ notepad_key.py # User-specific wrapped AES notepad keys +β”‚ └── user.py # User model with RSA public key & encrypted private key β”‚ └── static/ # Frontend Assets β”œβ”€β”€ index.html # Main map interface - β”œβ”€β”€ login.html # Login page - β”œβ”€β”€ register.html # Registration page - β”œβ”€β”€ admin.html # Admin interface + β”œβ”€β”€ login.html # Login page with private key unlock + β”œβ”€β”€ register.html # Registration page with keypair generation + β”œβ”€β”€ admin.html # Admin moderation interface + β”œβ”€β”€ about.html # About page & privacy overview + β”œβ”€β”€ contact.html # Contact page └── assets/ - β”œβ”€β”€ css/ # Modular stylesheets - β”œβ”€β”€ js/ # Modular vanilla JS scripts - └── images/ # Images and icons + β”œβ”€β”€ css/ # Modular stylesheets (base, map, navbar, sidebar, modals) + β”œβ”€β”€ js/ + β”‚ β”œβ”€β”€ crypto.js # Client-side cryptographic engine (E2EE) + β”‚ β”œβ”€β”€ actions.js # Notepad/note creation and modal actions + β”‚ β”œβ”€β”€ index.js # Core app controller, note loader, and renderer + β”‚ β”œβ”€β”€ map.js # Leaflet map configuration and marker logic + β”‚ β”œβ”€β”€ sidebar.js # Collapsible sidebar & notepad navigation + β”‚ β”œβ”€β”€ auth.js # Client authentication & key initialization + β”‚ β”œβ”€β”€ search.js # Dual public + decrypted local note search + β”‚ β”œβ”€β”€ nav.js # Navbar & session management + β”‚ └── admin.js # Admin dashboard controller + └── images/ # Icons and branding ``` +--- + ## Getting Started ### Prerequisites - Python 3.8+ -- pip (Python package installer) +- Modern Web Browser (Chrome, Edge, Firefox, Safari) with Web Crypto API support ### Installation -1. **Clone the repository** (or download the source): +1. **Clone the repository:** ```bash git clone cd spotnotes.app ``` -2. **Create a Virtual Environment (Optional but recommended):** +2. **Set up a Virtual Environment:** ```bash - python -m venv venv - source venv/bin/activate # On Windows use: venv\Scripts\activate + python -m venv .venv + # On Windows: + .venv\Scripts\activate + # On macOS/Linux: + source .venv/bin/activate ``` 3. **Install Dependencies:** @@ -80,21 +127,20 @@ spotnotes.app/ pip install -r requirements.txt ``` -4. **Run the Application:** +4. **Seed Sample Public Notes (Optional):** + ```bash + python seed_notes.py notes/general_public.csv "Global Public Notes" + python seed_notes.py notes/route66.csv "Route 66 Landmarks" + python seed_notes.py notes/birding.csv "Birding Hotspots" + ``` + +5. **Run the Application:** ```bash python app.py ``` - The app will start on `http://127.0.0.1:5000/`. The database (`spotnotes.db`) will be created automatically upon the first run. + The application will start on `http://127.0.0.1:5000/`. -## Planned Features (Roadmap) - -As detailed in the `GEMINI.md` file, the following features are actively planned or in development: - -1. **Advanced Collaboration:** Share notepads with specific users granting explicit Read or Read/Write permissions. -2. **Content Editing:** Ability to edit existing notes and notepads. -3. **Rich Text & Multiline Notes:** Support for multiline text and bulleted lists. -4. **CSV Export:** Export all notes within a notepad to a `.csv` file. -5. **Location-Based Alerts:** Browser notifications when physically approaching a saved note. +--- ## License diff --git a/app.py b/app.py index 3c82eca..b96d1ae 100644 --- a/app.py +++ b/app.py @@ -21,6 +21,7 @@ with app.app_context(): from models.note import Note from models.banned_ip import BannedIP from models.collaborator import Collaborator + from models.notepad_key import NotepadKey db.create_all() diff --git a/blueprints/admin.py b/blueprints/admin.py index 7d73794..21b8474 100644 --- a/blueprints/admin.py +++ b/blueprints/admin.py @@ -75,7 +75,11 @@ def delete_notepad(notepad_id): for note in notepad.notes: db.session.delete(note) from models.favorite import Favorite + from models.notepad_key import NotepadKey + from models.collaborator import Collaborator Favorite.query.filter_by(notepad_id=notepad.id).delete() + NotepadKey.query.filter_by(notepad_id=notepad.id).delete() + Collaborator.query.filter_by(notepad_id=notepad.id).delete() db.session.delete(notepad) db.session.commit() return jsonify({'message': 'Notepad deleted'}), 200 @@ -87,13 +91,19 @@ def delete_user(user_id): # Clean up user's notepads and notes notepads = Notepad.query.filter_by(user_id=user.id).all() from models.favorite import Favorite + from models.notepad_key import NotepadKey + from models.collaborator import Collaborator for notepad in notepads: for note in notepad.notes: db.session.delete(note) Favorite.query.filter_by(notepad_id=notepad.id).delete() + NotepadKey.query.filter_by(notepad_id=notepad.id).delete() + Collaborator.query.filter_by(notepad_id=notepad.id).delete() db.session.delete(notepad) Favorite.query.filter_by(user_id=user.id).delete() + NotepadKey.query.filter_by(user_id=user.id).delete() + Collaborator.query.filter_by(user_id=user.id).delete() db.session.delete(user) db.session.commit() return jsonify({'message': 'User deleted'}), 200 diff --git a/blueprints/api.py b/blueprints/api.py index 8d4f11f..8493c1b 100644 --- a/blueprints/api.py +++ b/blueprints/api.py @@ -6,6 +6,7 @@ from models.note import Note from models.collaborator import Collaborator from models.user import User from models.favorite import Favorite +from models.notepad_key import NotepadKey from sqlalchemy import func import csv from io import StringIO @@ -46,6 +47,25 @@ def check_pad_write_permission(notepad, user_id): collab = Collaborator.query.filter_by(user_id=user_id, notepad_id=notepad.id, permission='write').first() return collab is not None +@api_bp.route('/users/lookup', methods=['GET']) +def lookup_user(): + user_id = session.get('user_id') + if not user_id: + return jsonify({'error': 'Unauthorized'}), 401 + email = request.args.get('email') + if not email: + return jsonify({'error': 'Email is required'}), 400 + user = User.query.filter_by(email=email).first() + if not user: + return jsonify({'error': 'User not found'}), 404 + return jsonify({ + 'id': user.id, + 'email': user.email, + 'first_name': user.first_name, + 'last_name': user.last_name, + 'public_key': user.public_key + }) + @api_bp.route('/notepads', methods=['GET']) def get_notepads(): user_id = session.get('user_id') @@ -73,15 +93,17 @@ def get_notepads(): # Optimize note count query (O(1) database hit instead of O(N)) note_counts = dict(db.session.query(Note.notepad_id, func.count(Note.id)).filter(Note.is_hidden == False).group_by(Note.notepad_id).all()) - # Optimize write permissions check + # Optimize write permissions check and keys collab_write_pad_ids = set() + user_keys = {} if user_id: collab_write_pad_ids = {c.notepad_id for c in Collaborator.query.filter_by(user_id=user_id, permission='write').all()} + user_keys = {k.notepad_id: k.encrypted_key for k in NotepadKey.query.filter_by(user_id=user_id).all()} results = [] for pad in pads: note_count = note_counts.get(pad.id, 0) - p_dict = pad.to_dict(note_count=note_count) + p_dict = pad.to_dict(note_count=note_count, encrypted_key=user_keys.get(pad.id)) if user_id: if pad.user_id == user_id: p_dict['share_token'] = get_share_token('notepad', pad.id) @@ -101,7 +123,13 @@ def get_notepad(pad_id): if not check_pad_read_permission(notepad, user_id, token): return jsonify({'error': 'Unauthorized'}), 403 - p_dict = notepad.to_dict() + enc_key = None + if user_id and not notepad.is_public: + nk = NotepadKey.query.filter_by(notepad_id=pad_id, user_id=user_id).first() + if nk: + enc_key = nk.encrypted_key + + p_dict = notepad.to_dict(encrypted_key=enc_key) p_dict['can_write'] = check_pad_write_permission(notepad, user_id) if user_id and notepad.user_id == user_id: p_dict['share_token'] = get_share_token('notepad', pad_id) @@ -114,10 +142,27 @@ def create_notepad(): return jsonify({'error': 'Unauthorized'}), 401 data = request.get_json() - new_pad = Notepad(name=data['name'], is_public=data.get('is_public', False), user_id=user_id, color=data.get('color', '#3388ff')) + is_public = data.get('is_public', False) + new_pad = Notepad( + name=data['name'], + is_public=is_public, + user_id=user_id, + color=data.get('color', '#3388ff') + ) db.session.add(new_pad) + db.session.flush() + + encrypted_key = data.get('encrypted_key') + if not is_public and encrypted_key: + notepad_key = NotepadKey( + notepad_id=new_pad.id, + user_id=user_id, + encrypted_key=encrypted_key + ) + db.session.add(notepad_key) + db.session.commit() - return jsonify(new_pad.to_dict()), 201 + return jsonify(new_pad.to_dict(encrypted_key=encrypted_key)), 201 @api_bp.route('/notepads//notes', methods=['GET']) def get_notes(pad_id): @@ -175,13 +220,14 @@ def get_my_all_notes(): notes = Note.query.join(Notepad).filter(Notepad.user_id == user_id, Note.is_hidden == False).all() - # Eagerly load note counts to avoid N+1 queries when calling pad.to_dict() + # Eagerly load note counts and user keys to avoid N+1 queries note_counts = dict(db.session.query(Note.notepad_id, func.count(Note.id)).filter(Note.is_hidden == False).group_by(Note.notepad_id).all()) + user_keys = {k.notepad_id: k.encrypted_key for k in NotepadKey.query.filter_by(user_id=user_id).all()} results = [] for note in notes: n_dict = note.to_dict() - n_dict['notepad'] = note.notepad.to_dict(note_count=note_counts.get(note.notepad_id, 0)) + n_dict['notepad'] = note.notepad.to_dict(note_count=note_counts.get(note.notepad_id, 0), encrypted_key=user_keys.get(note.notepad_id)) n_dict['owner_id'] = user_id n_dict['is_public'] = note.notepad.is_public n_dict['share_token'] = get_share_token('note', note.id) @@ -191,24 +237,25 @@ def get_my_all_notes(): @api_bp.route('/notes/default', methods=['GET']) def get_default_notes(): user_id = session.get('user_id') + user_keys = {} if user_id: - # Get owned notepads + favorite notepads + # Get owned notepads + favorite notepads + shared notepads owned_pad_ids = [p.id for p in Notepad.query.filter_by(user_id=user_id).all()] fav_pad_ids = [f.notepad_id for f in Favorite.query.filter_by(user_id=user_id).all()] all_visible_pad_ids = list(set(owned_pad_ids + fav_pad_ids)) notes = Note.query.filter(Note.notepad_id.in_(all_visible_pad_ids), Note.is_hidden == False).all() + user_keys = {k.notepad_id: k.encrypted_key for k in NotepadKey.query.filter_by(user_id=user_id).all()} else: # Logged out: All public notes notes = Note.query.join(Notepad).filter(Notepad.is_public == True, Note.is_hidden == False).all() - # Eagerly load note counts to avoid N+1 queries when calling pad.to_dict() note_counts = dict(db.session.query(Note.notepad_id, func.count(Note.id)).filter(Note.is_hidden == False).group_by(Note.notepad_id).all()) results = [] for note in notes: n_dict = note.to_dict() - n_dict['notepad'] = note.notepad.to_dict(note_count=note_counts.get(note.notepad_id, 0)) + n_dict['notepad'] = note.notepad.to_dict(note_count=note_counts.get(note.notepad_id, 0), encrypted_key=user_keys.get(note.notepad_id)) n_dict['owner_id'] = note.notepad.user_id n_dict['is_public'] = note.notepad.is_public if user_id and note.notepad.user_id == user_id: @@ -232,13 +279,25 @@ def create_note(): if not check_pad_write_permission(notepad, user_id): return jsonify({'error': 'Forbidden'}), 403 - new_note = Note( - title=data.get('title', 'Untitled'), - content=data['content'], - lat=data['lat'], - lng=data['lng'], - notepad_id=notepad_id - ) + is_encrypted = not notepad.is_public + if is_encrypted: + new_note = Note( + title=data.get('title', 'πŸ”’ Encrypted Note'), + content=data['content'], + lat=0.0, + lng=0.0, + is_encrypted=True, + notepad_id=notepad_id + ) + else: + new_note = Note( + title=data.get('title', 'Untitled'), + content=data['content'], + lat=float(data.get('lat', 0.0)), + lng=float(data.get('lng', 0.0)), + is_encrypted=False, + notepad_id=notepad_id + ) db.session.add(new_note) db.session.commit() return jsonify(new_note.to_dict()), 201 @@ -255,6 +314,11 @@ def update_note(note_id): data = request.get_json() note.title = data.get('title', note.title) note.content = data.get('content', note.content) + if not note.is_encrypted: + if 'lat' in data: + note.lat = float(data['lat']) + if 'lng' in data: + note.lng = float(data['lng']) db.session.commit() return jsonify(note.to_dict()) @@ -357,6 +421,15 @@ def add_collaborator(pad_id): else: new_collab = Collaborator(notepad_id=pad_id, user_id=collaborator_user.id, permission=permission) db.session.add(new_collab) + + encrypted_key = data.get('encrypted_key') + if not notepad.is_public and encrypted_key: + existing_key = NotepadKey.query.filter_by(notepad_id=pad_id, user_id=collaborator_user.id).first() + if existing_key: + existing_key.encrypted_key = encrypted_key + else: + new_key = NotepadKey(notepad_id=pad_id, user_id=collaborator_user.id, encrypted_key=encrypted_key) + db.session.add(new_key) db.session.commit() return jsonify({'message': 'Collaborator added/updated successfully'}), 200 @@ -373,6 +446,7 @@ def remove_collaborator(pad_id, collaborator_user_id): if not collab_to_remove: return jsonify({'error': 'Collaborator not found'}), 404 + NotepadKey.query.filter_by(notepad_id=pad_id, user_id=collaborator_user_id).delete() db.session.delete(collab_to_remove) db.session.commit() return jsonify({'message': 'Collaborator removed'}), 200 @@ -413,6 +487,8 @@ def delete_notepad(pad_id): for note in notepad.notes: db.session.delete(note) Favorite.query.filter_by(notepad_id=notepad.id).delete() + NotepadKey.query.filter_by(notepad_id=notepad.id).delete() + Collaborator.query.filter_by(notepad_id=notepad.id).delete() db.session.delete(notepad) db.session.commit() return jsonify({'message': 'Notepad deleted'}), 200 diff --git a/blueprints/auth.py b/blueprints/auth.py index 08f9ddf..85d51f7 100644 --- a/blueprints/auth.py +++ b/blueprints/auth.py @@ -17,6 +17,9 @@ def register(): password = data.get('password') first_name = data.get('first_name') last_name = data.get('last_name') + public_key = data.get('public_key') + encrypted_private_key = data.get('encrypted_private_key') + encryption_salt = data.get('encryption_salt') if not email or not password or not first_name or not last_name: return jsonify({'error': 'All fields are required'}), 400 @@ -24,7 +27,15 @@ def register(): if User.query.filter_by(email=email).first(): return jsonify({'error': 'Email already exists'}), 400 - new_user = User(email=email, first_name=first_name, last_name=last_name, last_ip=ip_address) + new_user = User( + email=email, + first_name=first_name, + last_name=last_name, + last_ip=ip_address, + public_key=public_key, + encrypted_private_key=encrypted_private_key, + encryption_salt=encryption_salt + ) new_user.set_password(password) db.session.add(new_user) @@ -52,7 +63,7 @@ def login(): db.session.commit() session.permanent = True session['user_id'] = user.id - return jsonify({'message': 'Login successful', 'user': user.to_dict()}), 200 + return jsonify({'message': 'Login successful', 'user': user.to_dict(include_secrets=True)}), 200 return jsonify({'error': 'Invalid username or password'}), 401 @@ -69,5 +80,25 @@ def get_current_user(): user = User.query.get(user_id) if user: - return jsonify({'authenticated': True, 'user': user.to_dict()}), 200 - return jsonify({'authenticated': False}), 200 \ No newline at end of file + return jsonify({'authenticated': True, 'user': user.to_dict(include_secrets=True)}), 200 + return jsonify({'authenticated': False}), 200 + +@auth_bp.route('/init-keys', methods=['POST']) +def init_keys(): + user_id = session.get('user_id') + if not user_id: + return jsonify({'error': 'Unauthorized'}), 401 + + user = User.query.get(user_id) + if not user: + return jsonify({'error': 'User not found'}), 404 + + data = request.get_json() + if not user.encrypted_private_key: + user.public_key = data.get('public_key') + user.encrypted_private_key = data.get('encrypted_private_key') + user.encryption_salt = data.get('encryption_salt') + db.session.commit() + return jsonify({'message': 'Keys initialized successfully', 'user': user.to_dict(include_secrets=True)}), 200 + + return jsonify({'message': 'Keys already initialized', 'user': user.to_dict(include_secrets=True)}), 200 \ No newline at end of file diff --git a/blueprints/search.py b/blueprints/search.py index 19e261c..dbf6d83 100644 --- a/blueprints/search.py +++ b/blueprints/search.py @@ -5,7 +5,6 @@ from models.note import Note from models.notepad import Notepad from models.favorite import Favorite from models.collaborator import Collaborator -from .api import check_pad_write_permission import urllib.request import urllib.parse import json @@ -18,25 +17,19 @@ def search_notes(): if not query: return jsonify([]) - # Join Note and Notepad to filter by permissions - # Explicitly join on the foreign key to ensure correct results + user_id = session.get('user_id') + + # Server searches public unencrypted notes. + # Private notes are encrypted client-side and searched locally in browser for zero-knowledge privacy. sql_query = db.session.query(Note, Notepad).join(Notepad, Note.notepad_id == Notepad.id).filter( + Notepad.is_public == True, + Note.is_encrypted == False, + Note.is_hidden == False, or_( Note.title.ilike(f'%{query}%'), Note.content.ilike(f'%{query}%') ) ) - - user_id = session.get('user_id') - if user_id: - sql_query = sql_query.filter( - or_( - Notepad.is_public == True, - Notepad.user_id == user_id - ) - ) - else: - sql_query = sql_query.filter(Notepad.is_public == True) results = sql_query.all() diff --git a/models/note.py b/models/note.py index 51b071d..fc0d4e4 100644 --- a/models/note.py +++ b/models/note.py @@ -9,6 +9,7 @@ class Note(db.Model): lng = db.Column(db.Float, nullable=False) created_at = db.Column(db.DateTime, default=datetime.utcnow) is_hidden = db.Column(db.Boolean, default=False) + is_encrypted = db.Column(db.Boolean, default=False, nullable=False) notepad_id = db.Column(db.Integer, db.ForeignKey('notepad.id'), nullable=False) def __init__(self, **kwargs): @@ -21,5 +22,6 @@ class Note(db.Model): 'content': self.content, 'lat': self.lat, 'lng': self.lng, + 'is_encrypted': self.is_encrypted, 'created_at': self.created_at.isoformat() } \ No newline at end of file diff --git a/models/notepad.py b/models/notepad.py index a0d1fff..9f3f04c 100644 --- a/models/notepad.py +++ b/models/notepad.py @@ -11,16 +11,19 @@ class Notepad(db.Model): user_id = db.Column(db.Integer, db.ForeignKey('user.id')) notes = db.relationship('Note', backref='notepad', lazy='dynamic', cascade="all, delete-orphan") + keys = db.relationship('NotepadKey', backref='notepad', lazy='dynamic', cascade="all, delete-orphan") def __init__(self, **kwargs): super().__init__(**kwargs) - def to_dict(self, note_count=None): + def to_dict(self, note_count=None, encrypted_key=None): return { 'id': self.id, 'name': self.name, 'is_public': self.is_public, + 'is_encrypted': not self.is_public, 'owner_id': self.user_id, 'color': self.color, + 'encrypted_key': encrypted_key, 'note_count': note_count if note_count is not None else self.notes.filter_by(is_hidden=False).count() } \ No newline at end of file diff --git a/models/notepad_key.py b/models/notepad_key.py new file mode 100644 index 0000000..764ac11 --- /dev/null +++ b/models/notepad_key.py @@ -0,0 +1,29 @@ +from extensions import db +from datetime import datetime + +class NotepadKey(db.Model): + __tablename__ = 'notepad_key' + + id = db.Column(db.Integer, primary_key=True) + notepad_id = db.Column(db.Integer, db.ForeignKey('notepad.id', ondelete='CASCADE'), nullable=False) + user_id = db.Column(db.Integer, db.ForeignKey('user.id', ondelete='CASCADE'), nullable=False) + encrypted_key = db.Column(db.Text, nullable=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + user = db.relationship('User', backref=db.backref('notepad_keys', cascade='all, delete-orphan', lazy='dynamic')) + + __table_args__ = ( + db.UniqueConstraint('notepad_id', 'user_id', name='unique_notepad_key_user'), + ) + + def __init__(self, **kwargs): + super().__init__(**kwargs) + + def to_dict(self): + return { + 'id': self.id, + 'notepad_id': self.notepad_id, + 'user_id': self.user_id, + 'encrypted_key': self.encrypted_key, + 'created_at': self.created_at.isoformat() if self.created_at else None + } diff --git a/models/user.py b/models/user.py index 199436c..29d9163 100644 --- a/models/user.py +++ b/models/user.py @@ -7,6 +7,9 @@ class User(db.Model): first_name = db.Column(db.String(50), nullable=False) last_name = db.Column(db.String(50), nullable=False) password_hash = db.Column(db.String(256)) + public_key = db.Column(db.Text, nullable=True) + encrypted_private_key = db.Column(db.Text, nullable=True) + encryption_salt = db.Column(db.String(64), nullable=True) is_admin = db.Column(db.Boolean, default=False) is_banned = db.Column(db.Boolean, default=False) last_ip = db.Column(db.String(45)) @@ -20,13 +23,18 @@ class User(db.Model): def check_password(self, password): return check_password_hash(self.password_hash, password) - def to_dict(self): - return { + def to_dict(self, include_secrets=False): + data = { 'id': self.id, 'email': self.email, 'first_name': self.first_name, 'last_name': self.last_name, + 'public_key': self.public_key, 'is_admin': self.is_admin, 'is_banned': self.is_banned, 'last_ip': self.last_ip - } \ No newline at end of file + } + if include_secrets: + data['encrypted_private_key'] = self.encrypted_private_key + data['encryption_salt'] = self.encryption_salt + return data \ No newline at end of file diff --git a/seed_admin.py b/seed_admin.py new file mode 100644 index 0000000..d5ea90c --- /dev/null +++ b/seed_admin.py @@ -0,0 +1,101 @@ +import os +import json +import base64 +from app import app, db +from models.user import User +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.ciphers.aead import AESGCM + +def int_to_b64url(val): + byte_len = (val.bit_length() + 7) // 8 + b = val.to_bytes(byte_len, 'big') + return base64.urlsafe_b64encode(b).decode('utf-8').rstrip('=') + +def seed_admin_user(email="benjimanmiller@gmail.com", password="0kl4h0m4", first_name="Benjiman", last_name="Miller"): + with app.app_context(): + # 1. Generate RSA-OAEP 2048 keypair + priv = rsa.generate_private_key(public_exponent=65537, key_size=2048) + pub = priv.public_key() + priv_nums = priv.private_numbers() + pub_nums = pub.public_numbers() + + pub_jwk = { + 'kty': 'RSA', + 'n': int_to_b64url(pub_nums.n), + 'e': int_to_b64url(pub_nums.e), + 'alg': 'RSA-OAEP-256', + 'ext': True, + 'key_ops': ['encrypt'] + } + + priv_jwk = { + 'kty': 'RSA', + 'n': int_to_b64url(pub_nums.n), + 'e': int_to_b64url(pub_nums.e), + 'd': int_to_b64url(priv_nums.d), + 'p': int_to_b64url(priv_nums.p), + 'q': int_to_b64url(priv_nums.q), + 'dp': int_to_b64url(priv_nums.dmp1), + 'dq': int_to_b64url(priv_nums.dmq1), + 'qi': int_to_b64url(priv_nums.iqmp), + 'alg': 'RSA-OAEP-256', + 'ext': True, + 'key_ops': ['decrypt'] + } + + # 2. Derive KEK via PBKDF2 + salt = os.urandom(16) + kdf = PBKDF2HMAC( + algorithm=hashes.SHA256(), + length=32, + salt=salt, + iterations=100000 + ) + kek = kdf.derive(password.encode('utf-8')) + + # 3. Encrypt private key via AES-GCM + iv = os.urandom(12) + aesgcm = AESGCM(kek) + ct = aesgcm.encrypt(iv, json.dumps(priv_jwk).encode('utf-8'), None) + + enc_bundle = json.dumps({ + 'iv': base64.b64encode(iv).decode('utf-8'), + 'ct': base64.b64encode(ct).decode('utf-8') + }) + salt_b64 = base64.b64encode(salt).decode('utf-8') + pub_str = json.dumps(pub_jwk) + + # 4. Check if user exists + user = User.query.filter_by(email=email).first() + if user: + print(f"Updating existing user '{email}' to admin with new keys and password...") + user.first_name = first_name + user.last_name = last_name + user.is_admin = True + user.is_banned = False + user.set_password(password) + user.public_key = pub_str + user.encrypted_private_key = enc_bundle + user.encryption_salt = salt_b64 + else: + print(f"Creating new admin user '{email}'...") + user = User( + email=email, + first_name=first_name, + last_name=last_name, + is_admin=True, + is_banned=False, + public_key=pub_str, + encrypted_private_key=enc_bundle, + encryption_salt=salt_b64 + ) + user.set_password(password) + db.session.add(user) + + db.session.commit() + print(f"Successfully seeded admin user: {email} (is_admin={user.is_admin})") + +if __name__ == '__main__': + seed_admin_user() diff --git a/seed_notes.py b/seed_notes.py index fef9001..4027f00 100644 --- a/seed_notes.py +++ b/seed_notes.py @@ -53,6 +53,7 @@ def seed_from_csv(csv_file, notepad_name, is_public=True, owner_email=None): content=row['content'], lat=float(row['lat']), lng=float(row['lng']), + is_encrypted=False, notepad_id=notepad.id ) db.session.add(note) diff --git a/static/about.html b/static/about.html index 6dc32cf..5bf674c 100644 --- a/static/about.html +++ b/static/about.html @@ -47,16 +47,16 @@

Create notes that are tied to exact map coordinates. View them in context with the surrounding area.

-

🌍 Public & Private Notepads

-

Organize your notes into notepads. Keep them private for personal use or make them public to share with the community.

+

πŸ”’ Zero-Knowledge Encryption

+

All private notes are encrypted client-side using AES-GCM-256 and RSA-OAEP before storage. The server operator has zero access to your private thoughts or locations.

-

❀️ Favorites

-

Found a notepad you like? Add it to your favorites for quick and easy access later.

+

🌍 Public & Open Notepads

+

Publish guides, landmarks, and discoveries publicly. Public notes remain unencrypted so anyone worldwide can discover and explore them.

-

πŸ›‘οΈ Community Driven

-

Help keep the map clean by reporting inappropriate content. We believe in a safe and useful environment for everyone.

+

🀝 Secure E2EE Collaboration

+

Share private notepads securely with specific users using public-key cryptography, or via link with decryption key fragments.

@@ -65,18 +65,18 @@

Frequently Asked Questions

-

Is SpotNotes free to use?

-

Yes, SpotNotes is completely free. You can create unlimited notes and notepads.

+

Can the server operator or host see my private notes?

+

No. Private notes are encrypted client-side in your browser using the Web Crypto API before being transmitted. The server only stores ciphertext.

-

Do I need an account to view notes?

-

No, you can explore public notes and the Global Public Feed without an account. However, you will need to register to create your own notes.

+

How does sharing private notepads work?

+

When you add a collaborator, your browser encrypts the notepad key using the collaborator's public key. Only their private key can unlock it.

-

Can I collaborate on notepads?

-

Currently, public notepads can be viewed and edited by anyone.

+

Do I need an account to view public notes?

+

No, you can explore public notes and global feeds without an account. Registration is required to create and save private encrypted notes.

diff --git a/static/assets/css/navbar.css b/static/assets/css/navbar.css index 2a01d43..39ef8c8 100644 --- a/static/assets/css/navbar.css +++ b/static/assets/css/navbar.css @@ -133,16 +133,30 @@ .nav-toggle { display: none; - background: none; - border: none; - font-size: 1.5rem; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius-full); + font-size: 1.25rem; cursor: pointer; color: var(--text-main); - padding: 5px; + width: 38px; + height: 38px; + padding: 0; + align-items: center; + justify-content: center; + box-shadow: var(--shadow-sm); + transition: var(--transition); +} +.nav-toggle:hover { + color: var(--primary); + border-color: var(--primary); } /* Mobile Navbar */ @media (max-width: 768px) { + .nav-toggle { + display: inline-flex; + } .navbar { flex-wrap: wrap; height: auto; diff --git a/static/assets/css/sidebar.css b/static/assets/css/sidebar.css index 44f3ebc..f312288 100644 --- a/static/assets/css/sidebar.css +++ b/static/assets/css/sidebar.css @@ -369,19 +369,18 @@ .sidebar-toggle { left: 50%; right: auto; - top: -40px; - width: 48px; - height: 40px; + top: -46px; + width: 50px; + height: 38px; border: 1px solid var(--border-glass); - border-bottom: none; - border-radius: var(--radius-md) var(--radius-md) 0 0; + border-radius: var(--radius-full); transform: translateX(-50%) rotate(90deg); - box-shadow: 0 -4px 10px rgba(0,0,0,0.05); + box-shadow: 0 4px 14px rgba(0,0,0,0.12); } .sidebar-toggle:hover { - width: 48px; + width: 50px; left: 50%; - transform: translateX(-50%) rotate(90deg) scale(1.05); + transform: translateX(-50%) rotate(90deg) scale(1.06); } .sidebar-header { padding: 12px 16px; diff --git a/static/assets/js/actions.js b/static/assets/js/actions.js index fac380a..d28d313 100644 --- a/static/assets/js/actions.js +++ b/static/assets/js/actions.js @@ -10,37 +10,66 @@ function initModals() { const btnOpenPad = document.getElementById('btn-create-pad'); const formPad = document.getElementById('form-create-pad'); - if(btnOpenPad) { + if (btnOpenPad) { btnOpenPad.addEventListener('click', () => { modalPad.classList.remove('hidden'); }); } - if(formPad) { + if (formPad) { formPad.addEventListener('submit', async (e) => { e.preventDefault(); - const name = document.getElementById('pad-name').value; + const name = document.getElementById('pad-name').value.trim(); const isPublic = document.getElementById('pad-public').checked; const color = document.getElementById('pad-color').value; try { + let encryptedKey = null; + let padKeyObj = null; + + // For private notepads, generate an AES-GCM 256 key and wrap with user's RSA public key + if (!isPublic) { + if (!window.SpotNotes.currentUser || !window.SpotNotes.currentUser.public_key) { + alert('Your user account does not have an encryption key configured. Please log out and register or log in again.'); + return; + } + + const keyData = await window.SpotCrypto.createNotepadKey(window.SpotNotes.currentUser.public_key); + encryptedKey = keyData.encryptedKeyB64; + padKeyObj = keyData.padKey; + } + const res = await fetch('/api/notepads', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name, is_public: isPublic, color: color }) + body: JSON.stringify({ + name, + is_public: isPublic, + color: color, + encrypted_key: encryptedKey + }) }); if (res.ok) { const newPad = await res.json(); modalPad.classList.add('hidden'); formPad.reset(); + + if (!isPublic && padKeyObj) { + window.SpotCrypto.setCachedPadKey(newPad.id, padKeyObj); + } + // Transition to the new notepad - if (typeof window.SpotNotes.loadNotesForPad === 'function') window.SpotNotes.loadNotesForPad(newPad.id, newPad.name, newPad.is_public, newPad.owner_id); + if (typeof window.SpotNotes.loadNotesForPad === 'function') { + window.SpotNotes.loadNotesForPad(newPad.id, newPad.name, newPad.is_public, newPad.owner_id, false, null, true, encryptedKey); + } } else { - alert('Failed to create notepad'); + const err = await res.json(); + alert(err.error || 'Failed to create notepad'); } } catch (err) { console.error(err); + alert('An error occurred during notepad creation.'); } }); } @@ -49,43 +78,79 @@ function initModals() { const modalNote = document.getElementById('modal-create-note'); const formNote = document.getElementById('form-create-note'); - if(formNote) { + if (formNote) { formNote.addEventListener('submit', async (e) => { e.preventDefault(); - if (!activeNotepadId) { + const targetPadId = activeNotepadId || window.SpotNotes.activePadId; + if (!targetPadId) { alert("No notepad selected!"); return; } - const title = document.getElementById('note-title').value; + const title = document.getElementById('note-title').value.trim(); const content = document.getElementById('note-content').value; // Use current map center - const center = window.SpotNotes.map.getCenter(); + const center = window.SpotNotes.map ? window.SpotNotes.map.getCenter() : { lat: 0, lng: 0 }; + const isPrivate = !window.SpotNotes.activePadIsPublic; try { - const res = await fetch('/api/notes', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ + let notePayload = {}; + + if (isPrivate) { + // Private note: client-side zero-knowledge encryption + let padKey = window.SpotCrypto.getCachedPadKey(targetPadId); + if (!padKey && window.SpotNotes.activePadEncryptedKey) { + padKey = await window.SpotCrypto.unwrapNotepadKey(targetPadId, window.SpotNotes.activePadEncryptedKey); + } + + if (!padKey) { + alert("Encryption key for this private notepad is not available. Please unlock your account or re-enter via share link."); + return; + } + + const encPayload = await window.SpotCrypto.encryptNote({ title, content, lat: center.lat, - lng: center.lng, - notepad_id: activeNotepadId - }) + lng: center.lng + }, padKey); + + notePayload = { + notepad_id: targetPadId, + content: encPayload // Ciphertext stored at rest on server + }; + } else { + // Public note: unencrypted at rest + notePayload = { + notepad_id: targetPadId, + title, + content, + lat: center.lat, + lng: center.lng + }; + } + + const res = await fetch('/api/notes', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(notePayload) }); if (res.ok) { modalNote.classList.add('hidden'); formNote.reset(); // Refresh notes - if (typeof window.SpotNotes.loadNotesForPad === 'function') window.SpotNotes.loadNotesForPad(activeNotepadId); + if (typeof window.SpotNotes.loadNotesForPad === 'function') { + window.SpotNotes.loadNotesForPad(targetPadId); + } } else { - alert('Failed to create note'); + const err = await res.json(); + alert(err.error || 'Failed to create note'); } } catch (err) { console.error(err); + alert('An error occurred during note creation.'); } }); } diff --git a/static/assets/js/admin.js b/static/assets/js/admin.js index df7178e..1f0a750 100644 --- a/static/assets/js/admin.js +++ b/static/assets/js/admin.js @@ -142,7 +142,12 @@ async function loadNotes(notepadId) { noteDiv.className = 'user-item'; const infoDiv = document.createElement('div'); - infoDiv.innerHTML = `${escapeHtml(note.title)}: ${escapeHtml(note.content)}`; + if (note.is_encrypted) { + const preview = note.content ? (note.content.length > 40 ? note.content.substring(0, 40) + '...' : note.content) : ''; + infoDiv.innerHTML = `πŸ”’ [Encrypted Private Note]: [Zero-Knowledge at Rest: ${escapeHtml(preview)}]`; + } else { + infoDiv.innerHTML = `${escapeHtml(note.title)}: ${escapeHtml(note.content)}`; + } noteDiv.appendChild(infoDiv); // Add delete button for each note diff --git a/static/assets/js/auth.js b/static/assets/js/auth.js index 87c8fb9..c8daaad 100644 --- a/static/assets/js/auth.js +++ b/static/assets/js/auth.js @@ -17,6 +17,31 @@ document.addEventListener('DOMContentLoaded', () => { const data = await res.json(); if (res.ok) { + if (data.user && window.SpotCrypto) { + try { + if (data.user.encrypted_private_key) { + await window.SpotCrypto.unlockUserPrivateKey(data.user, password); + } else { + const regData = await window.SpotCrypto.generateUserRegistrationPayload( + email, + password, + data.user.first_name || 'User', + data.user.last_name || '' + ); + await fetch('/auth/init-keys', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + public_key: regData.public_key, + encrypted_private_key: regData.encrypted_private_key, + encryption_salt: regData.encryption_salt + }) + }); + } + } catch (err) { + console.error('Failed to initialize or decrypt private key:', err); + } + } window.location.href = '/'; // Redirect to map } else { alert(data.error || 'Login failed'); @@ -31,28 +56,48 @@ document.addEventListener('DOMContentLoaded', () => { if (registerForm) { registerForm.addEventListener('submit', async (e) => { e.preventDefault(); - const email = document.getElementById('email').value; - const firstName = document.getElementById('first-name').value; - const lastName = document.getElementById('last-name').value; + const email = document.getElementById('email').value.trim(); + const firstName = document.getElementById('first-name').value.trim(); + const lastName = document.getElementById('last-name').value.trim(); const password = document.getElementById('password').value; + const submitBtn = registerForm.querySelector('button[type="submit"]'); + if (submitBtn) { + submitBtn.disabled = true; + submitBtn.textContent = 'Generating Secure Keys...'; + } + try { + let regPayload = { email, password, first_name: firstName, last_name: lastName }; + + if (window.SpotCrypto) { + regPayload = await window.SpotCrypto.generateUserRegistrationPayload(email, password, firstName, lastName); + } + const res = await fetch('/auth/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email, password, first_name: firstName, last_name: lastName }) + body: JSON.stringify(regPayload) }); const data = await res.json(); if (res.ok) { - alert('Registration successful! Please log in.'); + alert('Registration successful with client-side encryption! Please log in.'); window.location.href = '/login.html'; } else { + if (submitBtn) { + submitBtn.disabled = false; + submitBtn.textContent = 'Register'; + } alert(data.error || 'Registration failed'); } } catch (err) { console.error(err); - alert('An error occurred'); + if (submitBtn) { + submitBtn.disabled = false; + submitBtn.textContent = 'Register'; + } + alert('An error occurred during secure key generation or registration.'); } }); } diff --git a/static/assets/js/crypto.js b/static/assets/js/crypto.js new file mode 100644 index 0000000..ca97d55 --- /dev/null +++ b/static/assets/js/crypto.js @@ -0,0 +1,412 @@ +// SpotNotes End-to-End Encryption (E2EE) Module +// Provides client-side zero-knowledge encryption using standard Web Crypto API. +// Server operator never has access to plaintext private notes or user private keys. + +window.SpotCrypto = window.SpotCrypto || {}; + +(function() { + const PBKDF2_ITERATIONS = 100000; + const SESSION_KEY_NAME = 'spotnotes_user_privkey'; + + // In-memory cache for decrypted symmetric notepad keys: padId -> CryptoKey + const padKeyCache = new Map(); + let currentUserPrivateKey = null; + + // --- Utility: Encoding / Decoding --- + function bufToB64(buffer) { + let binary = ''; + const bytes = new Uint8Array(buffer); + for (let i = 0; i < bytes.byteLength; i++) { + binary += String.fromCharCode(bytes[i]); + } + return window.btoa(binary); + } + + function b64ToBuf(base64) { + const binary = window.atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes.buffer; + } + + function bufToB64Url(buffer) { + return bufToB64(buffer).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + } + + function b64UrlToBuf(b64url) { + let b64 = b64url.replace(/-/g, '+').replace(/_/g, '/'); + while (b64.length % 4) b64 += '='; + return b64ToBuf(b64); + } + + // --- Key Derivation (PBKDF2) for User Password --- + async function deriveKEK(password, saltUint8Array) { + const enc = new TextEncoder(); + const baseKey = await crypto.subtle.importKey( + 'raw', + enc.encode(password), + 'PBKDF2', + false, + ['deriveKey'] + ); + return await crypto.subtle.deriveKey( + { + name: 'PBKDF2', + salt: saltUint8Array, + iterations: PBKDF2_ITERATIONS, + hash: 'SHA-256' + }, + baseKey, + { name: 'AES-GCM', length: 256 }, + false, + ['encrypt', 'decrypt'] + ); + } + + // --- User Keypair (RSA-OAEP) Generation & Storage --- + window.SpotCrypto.generateUserRegistrationPayload = async function(email, password, firstName, lastName) { + // 1. Generate RSA-OAEP Keypair + const keyPair = await crypto.subtle.generateKey( + { + name: 'RSA-OAEP', + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: 'SHA-256' + }, + true, + ['encrypt', 'decrypt'] + ); + + // 2. Export Public Key as JWK string + const pubJwk = await crypto.subtle.exportKey('jwk', keyPair.publicKey); + const publicKeyStr = JSON.stringify(pubJwk); + + // 3. Generate random 16-byte salt for user password KEK + const salt = crypto.getRandomValues(new Uint8Array(16)); + const saltStr = bufToB64(salt.buffer); + + // 4. Derive KEK and encrypt the private key + const kek = await deriveKEK(password, salt); + const privJwk = await crypto.subtle.exportKey('jwk', keyPair.privateKey); + const privJsonBytes = new TextEncoder().encode(JSON.stringify(privJwk)); + + const iv = crypto.getRandomValues(new Uint8Array(12)); + const encryptedPrivBuffer = await crypto.subtle.encrypt( + { name: 'AES-GCM', iv: iv }, + kek, + privJsonBytes + ); + + const encPrivatePayload = JSON.stringify({ + iv: bufToB64(iv.buffer), + ct: bufToB64(encryptedPrivBuffer) + }); + + // Cache private key in session + currentUserPrivateKey = keyPair.privateKey; + try { + sessionStorage.setItem(SESSION_KEY_NAME, JSON.stringify(privJwk)); + } catch (e) { console.warn('sessionStorage unavailable', e); } + + return { + email, + password, + first_name: firstName, + last_name: lastName, + public_key: publicKeyStr, + encrypted_private_key: encPrivatePayload, + encryption_salt: saltStr + }; + }; + + // Unlock User Private Key on Login + window.SpotCrypto.unlockUserPrivateKey = async function(user, password) { + if (!user.encrypted_private_key || !user.encryption_salt) { + console.warn('User has no encryption key stored.'); + return null; + } + + try { + const salt = new Uint8Array(b64ToBuf(user.encryption_salt)); + const kek = await deriveKEK(password, salt); + + const encBundle = JSON.parse(user.encrypted_private_key); + const iv = new Uint8Array(b64ToBuf(encBundle.iv)); + const ct = b64ToBuf(encBundle.ct); + + const decryptedBuf = await crypto.subtle.decrypt( + { name: 'AES-GCM', iv: iv }, + kek, + ct + ); + + const privJwk = JSON.parse(new TextDecoder().decode(decryptedBuf)); + const privateKey = await crypto.subtle.importKey( + 'jwk', + privJwk, + { name: 'RSA-OAEP', hash: 'SHA-256' }, + true, + ['decrypt'] + ); + + currentUserPrivateKey = privateKey; + try { + sessionStorage.setItem(SESSION_KEY_NAME, JSON.stringify(privJwk)); + } catch (e) { console.warn('sessionStorage unavailable', e); } + + return privateKey; + } catch (err) { + console.error('Failed to unlock private key:', err); + throw new Error('Failed to decrypt user private encryption key.'); + } + }; + + // Restore Private Key from Session Storage (e.g., page refresh) + window.SpotCrypto.restoreUserPrivateKeyFromSession = async function() { + if (currentUserPrivateKey) return currentUserPrivateKey; + + try { + const stored = sessionStorage.getItem(SESSION_KEY_NAME); + if (!stored) return null; + const privJwk = JSON.parse(stored); + currentUserPrivateKey = await crypto.subtle.importKey( + 'jwk', + privJwk, + { name: 'RSA-OAEP', hash: 'SHA-256' }, + true, + ['decrypt'] + ); + return currentUserPrivateKey; + } catch (e) { + console.warn('Could not restore private key from session', e); + return null; + } + }; + + window.SpotCrypto.clearSession = function() { + currentUserPrivateKey = null; + padKeyCache.clear(); + try { + sessionStorage.removeItem(SESSION_KEY_NAME); + } catch (e) {} + }; + + // --- Notepad Encryption Keys (AES-GCM 256) --- + + // Generate a fresh random AES-GCM 256 key for a new private notepad + window.SpotCrypto.createNotepadKey = async function(ownerPublicKeyJwkStr) { + // 1. Generate AES-GCM key + const padKey = await crypto.subtle.generateKey( + { name: 'AES-GCM', length: 256 }, + true, + ['encrypt', 'decrypt'] + ); + + // 2. Export raw key bytes + const rawKey = await crypto.subtle.exportKey('raw', padKey); + + // 3. Encrypt raw key with Owner's RSA Public Key + const pubJwk = JSON.parse(ownerPublicKeyJwkStr); + const ownerPubKey = await crypto.subtle.importKey( + 'jwk', + pubJwk, + { name: 'RSA-OAEP', hash: 'SHA-256' }, + false, + ['encrypt'] + ); + + const wrappedKeyBuf = await crypto.subtle.encrypt( + { name: 'RSA-OAEP' }, + ownerPubKey, + rawKey + ); + + const encryptedKeyB64 = bufToB64(wrappedKeyBuf); + return { + padKey, + rawKey, + encryptedKeyB64 + }; + }; + + // Encrypt an existing Notepad Key for a collaborator using their RSA public key + window.SpotCrypto.wrapPadKeyForCollaborator = async function(padKey, collaboratorPublicKeyJwkStr) { + const rawKey = await crypto.subtle.exportKey('raw', padKey); + const pubJwk = typeof collaboratorPublicKeyJwkStr === 'string' ? JSON.parse(collaboratorPublicKeyJwkStr) : collaboratorPublicKeyJwkStr; + const collabPubKey = await crypto.subtle.importKey( + 'jwk', + pubJwk, + { name: 'RSA-OAEP', hash: 'SHA-256' }, + false, + ['encrypt'] + ); + + const wrappedKeyBuf = await crypto.subtle.encrypt( + { name: 'RSA-OAEP' }, + collabPubKey, + rawKey + ); + return bufToB64(wrappedKeyBuf); + }; + + // Decrypt a wrapped Notepad Key using user's private key + window.SpotCrypto.unwrapNotepadKey = async function(padId, encryptedKeyB64) { + if (!encryptedKeyB64) return null; + if (padKeyCache.has(padId)) return padKeyCache.get(padId); + + const privKey = await window.SpotCrypto.restoreUserPrivateKeyFromSession(); + if (!privKey) { + console.warn(`No private key available to decrypt notepad ${padId}`); + return null; + } + + try { + const wrappedBuf = b64ToBuf(encryptedKeyB64); + const rawKeyBuf = await crypto.subtle.decrypt( + { name: 'RSA-OAEP' }, + privKey, + wrappedBuf + ); + + const padKey = await crypto.subtle.importKey( + 'raw', + rawKeyBuf, + { name: 'AES-GCM', length: 256 }, + true, + ['encrypt', 'decrypt'] + ); + + padKeyCache.set(padId, padKey); + return padKey; + } catch (err) { + console.error(`Failed to unwrap key for notepad ${padId}:`, err); + return null; + } + }; + + // Export a Notepad Key to base64url for zero-knowledge Link Sharing (#key=...) + window.SpotCrypto.exportPadKeyForLink = async function(padKey) { + const rawKey = await crypto.subtle.exportKey('raw', padKey); + return bufToB64Url(rawKey); + }; + + // Import a Notepad Key from a link URL fragment (#key=...) + window.SpotCrypto.importPadKeyFromLink = async function(padId, keyB64Url) { + try { + const rawKeyBuf = b64UrlToBuf(keyB64Url); + const padKey = await crypto.subtle.importKey( + 'raw', + rawKeyBuf, + { name: 'AES-GCM', length: 256 }, + true, + ['encrypt', 'decrypt'] + ); + padKeyCache.set(padId, padKey); + return padKey; + } catch (err) { + console.error('Failed to import pad key from link hash:', err); + return null; + } + }; + + window.SpotCrypto.getCachedPadKey = function(padId) { + return padKeyCache.get(padId) || null; + }; + + window.SpotCrypto.setCachedPadKey = function(padId, key) { + padKeyCache.set(padId, key); + }; + + // --- Note Content Encryption & Decryption --- + + // Encrypts { title, content, lat, lng } client-side + window.SpotCrypto.encryptNote = async function(noteData, padKey) { + const payload = { + title: noteData.title || '', + content: noteData.content || '', + lat: parseFloat(noteData.lat) || 0.0, + lng: parseFloat(noteData.lng) || 0.0, + ts: Date.now() + }; + + const jsonBytes = new TextEncoder().encode(JSON.stringify(payload)); + const iv = crypto.getRandomValues(new Uint8Array(12)); + + const ciphertextBuf = await crypto.subtle.encrypt( + { name: 'AES-GCM', iv: iv }, + padKey, + jsonBytes + ); + + return `ENC1:${bufToB64(iv.buffer)}:${bufToB64(ciphertextBuf)}`; + }; + + // Decrypts a note client-side. If not encrypted, returns note as-is. + window.SpotCrypto.decryptNote = async function(note, padKey) { + if (!note || !note.is_encrypted) { + return { + ...note, + title: note.title || '', + content: note.content || '', + lat: note.lat, + lng: note.lng + }; + } + + if (!padKey) { + return { + ...note, + title: 'πŸ”’ [Encrypted Private Note]', + content: 'Key required to decrypt this note.', + is_locked: true + }; + } + + try { + const rawContent = note.content || ''; + if (!rawContent.startsWith('ENC1:')) { + return { + ...note, + title: 'πŸ”’ [Encrypted Private Note]', + content: 'Unrecognized encryption format.' + }; + } + + const parts = rawContent.split(':'); + const iv = new Uint8Array(b64ToBuf(parts[1])); + const ct = b64ToBuf(parts[2]); + + const decryptedBuf = await crypto.subtle.decrypt( + { name: 'AES-GCM', iv: iv }, + padKey, + ct + ); + + const payload = JSON.parse(new TextDecoder().decode(decryptedBuf)); + return { + ...note, + title: payload.title, + content: payload.content, + lat: payload.lat, + lng: payload.lng, + is_locked: false + }; + } catch (err) { + console.warn(`Failed to decrypt note ${note.id}:`, err); + return { + ...note, + title: 'πŸ”’ [Decryption Error]', + content: 'Failed to decrypt note content with current key.', + is_locked: true + }; + } + }; + + // Decrypt an array of notes + window.SpotCrypto.decryptNotes = async function(notes, padKey) { + if (!notes || !Array.isArray(notes)) return []; + return Promise.all(notes.map(n => window.SpotCrypto.decryptNote(n, padKey))); + }; +})(); diff --git a/static/assets/js/index.js b/static/assets/js/index.js index 48a0fca..d77a065 100644 --- a/static/assets/js/index.js +++ b/static/assets/js/index.js @@ -9,6 +9,7 @@ window.SpotNotes.activePadId = null; window.SpotNotes.activePadIsPublic = false; window.SpotNotes.activePadOwnerId = null; window.SpotNotes.activePadCanWrite = false; +window.SpotNotes.activePadEncryptedKey = null; window.SpotNotes.currentUser = null; window.SpotNotes.isTracking = false; window.SpotNotes.userMarker = null; @@ -22,19 +23,48 @@ document.addEventListener('DOMContentLoaded', () => { window.SpotNotes.initSidebar(); window.SpotNotes.loadNotepads(true); + // --- Add Collaborator Modal Handler --- const form = document.getElementById('form-add-collaborator'); if (form) { form.addEventListener('submit', async (e) => { e.preventDefault(); const padId = document.getElementById('share-pad-id').value; - const email = document.getElementById('collaborator-email').value; + const email = document.getElementById('collaborator-email').value.trim(); const permission = document.getElementById('collaborator-permission').value; try { + let encryptedKey = null; + + // If private notepad, encrypt symmetric key with collaborator's public key + if (!window.SpotNotes.activePadIsPublic) { + const lookupRes = await fetch(`/api/users/lookup?email=${encodeURIComponent(email)}`); + if (!lookupRes.ok) { + const errData = await lookupRes.json(); + alert(errData.error || `User with email "${email}" was not found. Collaborator must be a registered user.`); + return; + } + const collaboratorUser = await lookupRes.json(); + if (!collaboratorUser.public_key) { + alert(`User "${email}" does not have an active public key registered.`); + return; + } + + let padKey = window.SpotCrypto.getCachedPadKey(padId); + if (!padKey && window.SpotNotes.activePadEncryptedKey) { + padKey = await window.SpotCrypto.unwrapNotepadKey(padId, window.SpotNotes.activePadEncryptedKey); + } + if (!padKey) { + alert('Could not access encryption key for this notepad.'); + return; + } + + encryptedKey = await window.SpotCrypto.wrapPadKeyForCollaborator(padKey, collaboratorUser.public_key); + } + const res = await fetch(`/api/notepads/${padId}/collaborators`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email, permission }) + body: JSON.stringify({ email, permission, encrypted_key: encryptedKey }) }); if (res.ok) { @@ -47,11 +77,12 @@ document.addEventListener('DOMContentLoaded', () => { } } catch (err) { console.error(err); - alert('An error occurred.'); + alert('An error occurred adding collaborator.'); } }); } + // --- Edit Notepad Modal Handler --- const formEditPad = document.getElementById('form-edit-pad'); if (formEditPad) { formEditPad.addEventListener('submit', async (e) => { @@ -69,7 +100,7 @@ document.addEventListener('DOMContentLoaded', () => { }); if (res.ok) { document.getElementById('modal-edit-pad').classList.add('hidden'); - window.SpotNotes.loadNotepads(); // Refresh everything + window.SpotNotes.loadNotepads(); } else { alert('Failed to update notepad.'); } @@ -77,29 +108,52 @@ document.addEventListener('DOMContentLoaded', () => { }); } + // --- Edit Note Modal Handler --- const formEditNote = document.getElementById('form-edit-note'); if (formEditNote) { formEditNote.addEventListener('submit', async (e) => { e.preventDefault(); - const noteId = document.getElementById('note-id-edit').value; - const title = document.getElementById('note-title-edit').value; + const noteId = parseInt(document.getElementById('note-id-edit').value, 10); + const title = document.getElementById('note-title-edit').value.trim(); const content = document.getElementById('note-content-edit').value; + const existingNote = window.SpotNotes.activeNotes.find(n => n.id === noteId); + const isEncrypted = existingNote ? existingNote.is_encrypted : !window.SpotNotes.activePadIsPublic; + try { + let bodyPayload = {}; + if (isEncrypted) { + const targetPadId = (existingNote && existingNote.notepad_id) || window.SpotNotes.activePadId; + let padKey = window.SpotCrypto.getCachedPadKey(targetPadId); + if (!padKey && window.SpotNotes.activePadEncryptedKey) { + padKey = await window.SpotCrypto.unwrapNotepadKey(targetPadId, window.SpotNotes.activePadEncryptedKey); + } + if (!padKey) { + alert('Encryption key not available to edit this private note.'); + return; + } + const encPayload = await window.SpotCrypto.encryptNote({ + title, + content, + lat: existingNote ? existingNote.lat : 0, + lng: existingNote ? existingNote.lng : 0 + }, padKey); + bodyPayload = { title: "πŸ”’ Encrypted Note", content: encPayload }; + } else { + bodyPayload = { title, content }; + } + const res = await fetch(`/api/notes/${noteId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ title, content }) + body: JSON.stringify(bodyPayload) }); + if (res.ok) { document.getElementById('modal-edit-note').classList.add('hidden'); - // Refresh current view if (window.SpotNotes.activePadId) { window.SpotNotes.loadNotesForPad(window.SpotNotes.activePadId); } else { - // This was likely from a search result, which is harder to refresh. - // A full reload of notepads is a safe fallback. - alert('Note updated. Refreshing list.'); window.SpotNotes.loadNotepads(); } } else { @@ -110,12 +164,6 @@ document.addEventListener('DOMContentLoaded', () => { } }); - - - - - - window.SpotNotes.loadNotepads = async function(isInitialLoad = false) { // Reset state window.SpotNotes.activeNotes = []; @@ -128,6 +176,11 @@ window.SpotNotes.loadNotepads = async function(isInitialLoad = false) { const authData = await authRes.json(); window.SpotNotes.isLoggedIn = authData.authenticated; window.SpotNotes.currentUser = authData.user || null; + + // Try restoring user's private key from session storage + if (window.SpotNotes.isLoggedIn && window.SpotCrypto) { + await window.SpotCrypto.restoreUserPrivateKeyFromSession(); + } } catch (e) { console.error(e); } // Reset Sidebar Header @@ -158,7 +211,7 @@ window.SpotNotes.loadNotepads = async function(isInitialLoad = false) {

Loading...

-
+

Loading...

@@ -166,15 +219,15 @@ window.SpotNotes.loadNotepads = async function(isInitialLoad = false) {

Loading...

-
+
`; const listSection = document.querySelectorAll('.list-section-header'); - listSection.forEach(header => { - header.addEventListener('click',window.SpotNotes.toggleSection) - }) + listSection.forEach(h => { + h.addEventListener('click', window.SpotNotes.toggleSection); + }); window.SpotNotes.setupPublicToggle(); try { @@ -205,7 +258,7 @@ window.SpotNotes.loadNotepads = async function(isInitialLoad = false) { } } - // Initial / Default Load Logic: when no notepad is selected, show default notes on map + // Initial / Default Load Logic const params = new URLSearchParams(window.location.search); if (isInitialLoad === true && params.has('pad')) { const token = params.get('token'); @@ -216,29 +269,22 @@ window.SpotNotes.loadNotepads = async function(isInitialLoad = false) { } else { await window.SpotNotes.loadDefaultNotes(); } -} - - - - - +}; window.SpotNotes.toggleFavorite = async function(padId, action) { try { const res = await fetch(`/api/notepads/${padId}/${action}`, { method: 'POST' }); if (res.ok) { - // Refresh the lists window.SpotNotes.loadNotepadsOfType('favorites', 'favorite-notepads-list'); window.SpotNotes.loadNotepadsOfType('mine', 'my-notepads-list'); window.SpotNotes.loadNotepadsOfType('public', 'public-notepads-list'); - } else { alert(`Failed to ${action} notepad`); } } catch (err) { console.error(err); } -} +}; window.SpotNotes.loadNotepadsOfType = async function(type, containerId) { try { @@ -250,30 +296,52 @@ window.SpotNotes.loadNotepadsOfType = async function(type, containerId) { console.error(`Failed to load ${type} notepads`, err); document.getElementById(containerId).innerHTML = '

Error loading data.

'; } -} -window.SpotNotes.loadNotesForPad = async function(padId, padName, isPublic, ownerId, centerOnFirst = false, token = null, canWrite = undefined) { +}; + +window.SpotNotes.loadNotesForPad = async function(padId, padName, isPublic, ownerId, centerOnFirst = false, token = null, canWrite = undefined, encryptedKey = null) { window.SpotNotes.activePadId = padId; - // If metadata is missing (e.g. from URL share), fetch it - if (padName === undefined) { + // If metadata is missing, fetch it + if (padName === undefined || encryptedKey === undefined) { try { const res = await fetch(`/api/notepads/${padId}?token=${token || ''}`); const pad = await res.json(); - padName = pad.name; isPublic = pad.is_public; ownerId = pad.owner_id; window.SpotNotes.activePadCanWrite = pad.can_write; - } catch(e) { console.error(e); window.SpotNotes.activePadCanWrite = false; } + padName = pad.name; + isPublic = pad.is_public; + ownerId = pad.owner_id; + window.SpotNotes.activePadCanWrite = pad.can_write; + if (pad.encrypted_key) encryptedKey = pad.encrypted_key; + } catch(e) { + console.error(e); + window.SpotNotes.activePadCanWrite = false; + } } if (padName !== undefined) window.SpotNotes.activePadName = padName; if (isPublic !== undefined) window.SpotNotes.activePadIsPublic = isPublic; if (ownerId !== undefined) window.SpotNotes.activePadOwnerId = ownerId; + if (encryptedKey !== null) window.SpotNotes.activePadEncryptedKey = encryptedKey; if (canWrite !== undefined) { window.SpotNotes.activePadCanWrite = canWrite; } else if (padName !== undefined) { - // Fallback if not passed: assume true only if owner window.SpotNotes.activePadCanWrite = (window.SpotNotes.currentUser && ownerId === window.SpotNotes.currentUser.id); } + // Check if URL hash has #key=... for link-shared private notes/pads + if (window.location.hash.includes('key=')) { + const hashParams = new URLSearchParams(window.location.hash.replace(/^#/, '')); + const linkKey = hashParams.get('key'); + if (linkKey && window.SpotCrypto) { + await window.SpotCrypto.importPadKeyFromLink(padId, linkKey); + } + } + + // If private and encrypted_key present, unwrap with user's private key + if (!isPublic && window.SpotNotes.activePadEncryptedKey && window.SpotCrypto) { + await window.SpotCrypto.unwrapNotepadKey(padId, window.SpotNotes.activePadEncryptedKey); + } + const currentName = window.SpotNotes.activePadName; const isOwner = window.SpotNotes.currentUser && window.SpotNotes.activePadOwnerId === window.SpotNotes.currentUser.id; @@ -287,14 +355,15 @@ window.SpotNotes.loadNotesForPad = async function(padId, padName, isPublic, owne if (header) { const addNoteBtn = window.SpotNotes.activePadCanWrite ? `` : ''; const manageShareBtn = isOwner ? `` : ''; - const exportBtn = ``; + const exportBtn = ``; const shareBtn = ``; + const encryptionBadge = !isPublic ? 'πŸ”’' : '🌍'; header.innerHTML = ` + \ No newline at end of file diff --git a/update_map_colors.py b/update_map_colors.py deleted file mode 100644 index f6c1489..0000000 --- a/update_map_colors.py +++ /dev/null @@ -1,51 +0,0 @@ -import os -import re - -path = r'z:\source\forgejo\spotnotes.app\static\assets\js\map.js' -with open(path, 'r', encoding='utf-8') as f: - content = f.read() - -new_render = """ const shareToken = note.share_token || ''; - const shareBtn = ``; - - const dateStr = new Date(note.created_at).toLocaleString(); - - const padColor = (note.notepad && note.notepad.color) ? note.notepad.color : '#3388ff'; - - const directionsBtn = `πŸ—ΊοΈ Directions`; - - if (showFull) { - const icon = L.divIcon({ - className: '', - html: `
-
${window.SpotNotes.escapeHtml(note.title)}
-
${window.SpotNotes.escapeHtml(note.content)}
-
${dateStr}
- ${directionsBtn} ${shareBtn} ${actionBtns} -
`, - iconSize: [200, 'auto'], - iconAnchor: [100, 0] - }); - marker = L.marker([note.lat, note.lng], { icon: icon }); - } else { - const svgIcon = L.divIcon({ - className: 'custom-div-icon', - html: `
`, - iconSize: [20, 20], - iconAnchor: [10, 10], - popupAnchor: [0, -10] - }); - marker = L.marker([note.lat, note.lng], { icon: svgIcon }); - marker.bindPopup(`${window.SpotNotes.escapeHtml(note.title)}
${window.SpotNotes.escapeHtml(note.content)}
${dateStr}
${directionsBtn} ${shareBtn} ${actionBtns}`); - }""" - -# Extract old chunk to replace -old_chunk_start = "const shareToken = note.share_token || '';" -old_chunk_end = "marker.bindPopup(`${window.SpotNotes.escapeHtml(note.title)}
${window.SpotNotes.escapeHtml(note.content)}
${dateStr}
${directionsBtn} ${shareBtn} ${actionBtns}`);\n }" - -pattern = re.escape(old_chunk_start) + r'.*?' + re.escape(old_chunk_end) -content = re.sub(pattern, new_render, content, flags=re.DOTALL) - -with open(path, 'w', encoding='utf-8') as f: - f.write(content) -print('Marker logic updated with colors.')