Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from flask import Flask
app = Flask(__name__)

@app.route('/')
def index():
return 'SAS server is running'

if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000)
31 changes: 31 additions & 0 deletions client-web/js/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,15 @@
* - Stored in sessionStorage so it clears when the tab is closed.
* - JWT expires in 15 min server-side; the server returns 401 when
* it does, which this module surfaces as a thrown Error.
*
* Private key lifecycle:
* - Unwrapped from wrapped_private_key at login time using the user's passphrase.
* - Held in a module-level variable only — never written to sessionStorage or localStorage.
* - Cleared on logout.
*/

import { decryptPrivateKey, EncryptedPrivateKey } from '../crypto/keyStorage.js';

const BASE_URL = window.location.origin;
const TOKEN_KEY = 'whatsas_token';

Expand All @@ -17,6 +24,13 @@ function clearToken() { sessionStorage.removeItem(TOKEN_KEY); }

export function isAuthenticated() { return !!getToken(); }

// ── Private key store (in-memory only, never persisted) ──────────────────
let _sessionPrivateKey = null;

export function setPrivateKey(key) { _sessionPrivateKey = key; }
export function getPrivateKey() { return _sessionPrivateKey; }
export function clearPrivateKey() { _sessionPrivateKey = null; }

// ── Core fetch wrapper ────────────────────────────────────────────────────
async function request(method, path, { body = null, auth = false } = {}) {
const headers = { 'Content-Type': 'application/json' };
Expand Down Expand Up @@ -52,6 +66,22 @@ export async function login(username, password) {
});
if (!data.token) throw new Error('Login succeeded but no token was returned');
setToken(data.token);

if (data.wrapped_private_key) {
try {
const parsed = JSON.parse(atob(data.wrapped_private_key));
const encrypted = EncryptedPrivateKey.fromJSON(parsed);
const privBytes = await decryptPrivateKey(encrypted, password);
const privKey = await crypto.subtle.importKey(
'raw', privBytes, { name: 'X25519' }, false, ['deriveKey', 'deriveBits'],
);
setPrivateKey(privKey);
} catch {
// Placeholder key material (test users) or corrupted key — login still succeeds,
// inbox will see getPrivateKey() === null and skip decryption.
}
}

// Return full payload — caller may need key material for E2E crypto
return data;
}
Expand All @@ -75,6 +105,7 @@ export function logout() {
}).catch(() => {});
}
clearToken();
clearPrivateKey();
}

// ── Messages ──────────────────────────────────────────────────────────────
Expand Down
34 changes: 33 additions & 1 deletion client-web/js/views.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
*/

import * as api from './api.js';
import { decryptMessage } from '../crypto/messageEncryption.js';

// ── Helpers ───────────────────────────────────────────────────────────────

Expand All @@ -27,6 +28,16 @@ function formatDate(ts) {
return isNaN(d) ? '' : d.toLocaleString();
}

// ── Helpers (crypto) ─────────────────────────────────────────────────────
function hexToBytes(hex) {
if (hex.length % 2 !== 0) throw new Error('hexToBytes: odd-length hex string');
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < bytes.length; i++) {
bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return bytes;
}

// ── Crypto ────────────────────────────────────────────────────────────────

// TODO: fetch recipient's x25519_public_key and encrypt with HPKE.
Expand Down Expand Up @@ -174,9 +185,30 @@ export async function renderInbox(container, navigate) {
return;
}

async function tryDecrypt(msg) {
const privKey = api.getPrivateKey();
if (!privKey || !msg.ciphertext || !msg.nonce || !msg.ephemeral_public_key) {
return '(encrypted)';
}
try {
const ciphertext = hexToBytes(msg.ciphertext);
const nonce = hexToBytes(msg.nonce);
const ephPubKey = await crypto.subtle.importKey(
'raw', hexToBytes(msg.ephemeral_public_key), { name: 'X25519' }, false, ['deriveKey', 'deriveBits'],
);
return await decryptMessage(ciphertext, nonce, ephPubKey, privKey);
} catch {
return '(encrypted)';
}
}

const decrypted = await Promise.all(
messages.map(async msg => ({ ...msg, content: await tryDecrypt(msg) }))
);

body.innerHTML = `
<div class="message-list">
${messages.map(buildMessageCard).join('')}
${decrypted.map(buildMessageCard).join('')}
</div>`;

body.querySelectorAll('[data-action]').forEach((btn) => {
Expand Down
20 changes: 20 additions & 0 deletions docs/transcript_sarah.md
Original file line number Diff line number Diff line change
Expand Up @@ -558,3 +558,23 @@ For your app specifically: your auth routes will handle credentials and tokens,
**Claude:** Created `whatsas.service` at project root with `User=student`, `WorkingDirectory=/home/student/WhatSaS`, `EnvironmentFile=/home/student/WhatSaS/.env`, gunicorn binding to `127.0.0.1:5000` with 2 workers, logs redirected to stdout/stderr (captured by journald), `Restart=on-failure`, `RestartSec=5`.

**Sarah:** [APPROVED]

---

**Sarah:** the get_messages route in server/app/messages/routes.py returns an empty list. implement it to query the database for all messages where recipient_id equals the current user's JWT identity, returning id, sender_id, ciphertext, nonce, ephemeral_public_key and created_at for each message

**Claude:** Read `messages/routes.py` and identified the stub. Proposed querying `SELECT id, sender_id, ciphertext, nonce, ephemeral_public_key, created_at FROM messages WHERE recipient_id = %s` using a `dictionary=True` cursor, following the pattern from `get_message`.

**Sarah:** will created_at from MySQL serialise correctly to JSON with jsonify or do we need to convert it to an ISO string first

**Claude:** Flask 3.x uses `DefaultJSONProvider` which calls `.isoformat()` on `datetime` objects — no manual conversion needed. mysql-connector-python returns `datetime.datetime` for DATETIME/TIMESTAMP columns, which Flask serialises cleanly to ISO 8601.

**Sarah:** you didnt answer. does flask's jsonify handle Python datetime objects from MySQL or will it throw a TypeError at runtime?

**Claude:** Confirmed it will not throw. Flask 3.x's `DefaultJSONProvider.default()` explicitly handles `datetime` instances via `.isoformat()`. The MySQL connector returns `datetime.datetime` for those columns, so serialisation is automatic and correct.

**Sarah:** yes

**Claude:** Implemented `get_messages`: queries DB with `dictionary=True` cursor, `WHERE recipient_id = %s` parameterised with JWT identity, fetches all rows, returns `{'messages': rows}` 200.

**Sarah:** [APPROVED]
16 changes: 15 additions & 1 deletion server/app/messages/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,21 @@ def _invalid_fields(data, fields):
@jwt_required()
def get_messages():
current_user_id = get_jwt_identity()
return jsonify({'user_id': current_user_id, 'messages': []}), 200
db = get_db()
cursor = db.cursor(dictionary=True)
try:
cursor.execute(
"""
SELECT id, sender_id, ciphertext, nonce, ephemeral_pk, created_at
FROM messages
Comment thread
sarahmc253 marked this conversation as resolved.
WHERE recipient_id = %s
""",
(current_user_id,),
)
rows = cursor.fetchall()
finally:
cursor.close()
return jsonify({'messages': rows}), 200

@messages_bp.route('/messages', methods=['POST'])
@jwt_required()
Expand Down
4 changes: 2 additions & 2 deletions whatsas.service
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ After=network.target

[Service]
User=student
WorkingDirectory=/home/student/WhatSaS
WorkingDirectory=/home/student/WhatSaS/server
EnvironmentFile=/home/student/WhatSaS/.env
ExecStart=/home/student/WhatSaS/venv/bin/gunicorn server.run:app \
ExecStart=/home/student/WhatSaS/venv/bin/gunicorn run:app \
--bind 127.0.0.1:5000 \
--workers 2 \
--access-logfile - \
Expand Down