Skip to content

Commit 73461ca

Browse files
authored
Backend blockchain (#17)
* initial for server test * fix * finally working * coderabbit reviews * more coderabbit * more coderabbit * code rabbit again
1 parent a404348 commit 73461ca

7 files changed

Lines changed: 548 additions & 23 deletions

File tree

client-web/js/api.js

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,16 +57,25 @@ export async function login(username, password) {
5757
}
5858

5959
export async function register(username, email, password, cryptoPayload) {
60-
const { x25519_public_key, hpke_wrapped_private_key, argon2id_kek_salt } = cryptoPayload ?? {};
61-
if (!x25519_public_key || !hpke_wrapped_private_key || !argon2id_kek_salt) {
60+
const { x25519_public_key, wrapped_private_key, kek_salt } = cryptoPayload ?? {};
61+
if (!x25519_public_key || !wrapped_private_key || !kek_salt) {
6262
throw new Error('Registration blocked: E2E crypto material is not yet implemented');
6363
}
6464
return request('POST', '/auth/register', {
6565
body: { username, email, password, ...cryptoPayload }
6666
});
6767
}
6868

69-
export function logout() { clearToken(); }
69+
export function logout() {
70+
const token = getToken();
71+
if (token) {
72+
fetch(`${BASE_URL}/auth/logout`, {
73+
method: 'POST',
74+
headers: { 'Authorization': `Bearer ${token}` },
75+
}).catch(() => {});
76+
}
77+
clearToken();
78+
}
7079

7180
// ── Messages ──────────────────────────────────────────────────────────────
7281
export function getMessages() {

docs/transcript_sarah.md

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -331,3 +331,91 @@ For your app specifically: your auth routes will handle credentials and tokens,
331331
---
332332

333333
**Sarah:** update the transcript in docs
334+
335+
**Claude:** Updated `docs/transcript_sarah.md` with all exchanges from the session to that point.
336+
337+
**Sarah:** [APPROVED]
338+
339+
---
340+
341+
**Sarah:** implement send_message in messages/routes.py. accept recipient_id, ciphertext, nonce, validate all three, insert into messages table with a uuid, sender_id from JWT and created_at then return the new message id. same patterns as auth/routes.py
342+
343+
**Claude:** Read `auth/routes.py` and `messages/routes.py`. Added imports (`re`, `threading`, `uuid`, `datetime`, `mysql.connector`, `get_db`, `request`), a `_invalid_fields` helper matching the auth pattern, and a `SEND_FIELDS` list. Implemented `send_message`: validates all three fields, generates UUID, pulls `sender_id` from `get_jwt_identity()`, inserts into `messages` with `created_at`, catches FK violation (errno 1452) → 404, returns `{'id': message_id}` with 201.
344+
345+
**Sarah:** [APPROVED]
346+
347+
---
348+
349+
**Sarah:** what are the tradeoffs of recording a blockchain entry per message vs batching
350+
351+
**Claude:** Per-message recording gives an immediate tamper-evident audit trail with simple querying but costs scale linearly and chain latency blocks the send flow. Batching (Merkle root of N messages) dramatically reduces chain writes and decouples throughput from chain speed, but requires storing the tree, introduces an anchoring delay, and adds complexity around partial batches and crash recovery. Recommended batching with a fixed interval — accumulate a Merkle tree of message IDs/hashes on a short interval (e.g. every 30s or 1000 messages), anchor the root, and use the existing UUID per row as the leaf set. The tradeoff is that messages are not provably on-chain until the next anchor.
352+
353+
---
354+
355+
**Sarah:** if we batch on the server side, how does the verification page work independently of the server?
356+
357+
**Claude:** It doesn't — that's the core tension. The verification page can read the chain root independently, but to verify a specific message it needs the Merkle proof, which can only come from whoever built the tree (the server). A compromised server could omit a message from the tree and never provide a proof. Outlined three approaches to close the gap: client-assisted batching (clients submit signed hashes the server can't produce), a public batch log (server publishes the full leaf list to an independently readable store before anchoring, so the verification page can reconstruct proofs itself), or per-message recording (fully trustless but expensive). Recommended the public batch log as the realistic middle ground.
358+
359+
---
360+
361+
**Sarah:** what if the client builds the merkle tree locally from its own ciphertexts and sends the root to the server instead
362+
363+
**Claude:** Explained that this gives a cleaner trust model — the server can't tamper with tree construction — and fits the existing design since ciphertexts never leave the client decrypted. However the chain entry only commits to one client's outbox at a point in time and says nothing about delivery or receipt. Also, each client anchoring its own root doesn't solve the volume problem. Recommended a hybrid: clients send `hash(ciphertext)` alongside each message; server batches those client-originated hashes into the Merkle tree and anchors the root; clients store their leaf hash locally. Server still builds the aggregate tree but the leaves are client commitments — a server that drops a message produces a leaf that doesn't match the client's stored hash, detectable without trusting the server's proof.
364+
365+
---
366+
367+
**Sarah:** what are the tradeoffs of triggering the batch on a fixed timer vs on session end? beyond data loss if a session is interrupted
368+
369+
**Claude:** Fixed timer gives predictable anchoring latency, works for background/server-to-server messages with no session concept, may waste writes on idle periods, and requires a scheduler. Session end produces semantically coherent batches with no wasted writes, but is ambiguous in multi-device contexts, creates unpredictable chain write timing (potential gas spikes at end of day), and leaks conversation-boundary metadata to the chain. Fixed timer is the safer default for reliability and metadata privacy. Recommended using session end as a supplementary trigger — flush pending messages on a clean logout but rely on the timer as the primary mechanism.
370+
371+
---
372+
373+
**Sarah:** in send_message, store the client-supplied content_hash field (keccak256 of ciphertext, computed by client) alongside the messages table. then add a separate function that runs every 5 minutes: fetch all unanchored content_hashes for each conversation, build a merkle root, call DataStore.storeData via web3.py and store the tx_hash against those messages. also trigger this flush on clean session end
374+
375+
**Claude:** Read `contracts/DataStore.sol` and `contracts/abi.json`. Noted discrepancy: `storeData` takes `bytes32` in the `.sol` but `string` in the ABI. Proposed implementation across four files — `anchor.py` (new), `messages/routes.py`, `__init__.py`, `auth/routes.py` — with `_connect()` reading from `os.getenv()` directly.
376+
377+
**Sarah:** [REJECTED]`_connect()` is reading from `os.getenv` directly, bypassing the validated `app.config`. fix it to use `current_app.config` the same way `get_db()` does in `__init__.py`.
378+
379+
**Claude:** Explained that `current_app` requires an active Flask application context, which background threads don't get automatically. Proposed using `current_app.config` in `_connect()`, and having all callers outside a request context push one via `with app.app_context()` — the scheduler job via a closure over `app`, and the logout flush thread via `current_app._get_current_object()` passed into the thread. Request-context callers (flush route) already have an app context and need no change.
380+
381+
**Sarah:** the ABI is correct, it reflects the deployed contract on Sepolia. treat the ABI as the source of truth and proceed.
382+
383+
**Claude:** Confirmed — `storeData` takes `string`, so the Merkle root is passed as a hex string (e.g. `"0xabcd..."`). Created `server/app/messages/anchor.py` with `_connect()` using `current_app.config`, `_merkle_root()` building a keccak256 binary tree (odd leaves duplicated), and `anchor_pending(user_id=None)` that fetches unanchored rows, groups by normalised conversation key (`LEAST`/`GREATEST` on IDs), submits one `storeData(root_hex)` tx per conversation with nonce incremented locally, and writes `anchor_tx_hash` back to the rows. A `threading.Lock` prevents concurrent runs. Updated `messages/routes.py` to add `content_hash` to `SEND_FIELDS`, validate it as a 64-char hex string, normalise to `0x` prefix, and store it in the INSERT. Added `POST /messages/flush` endpoint. Updated `__init__.py` to load `WEB3_RPC_URL`, `CONTRACT_ADDRESS`, `WALLET_PRIVATE_KEY` into `app.config` (optional — logs a warning and skips the scheduler if missing), and start an APScheduler `BackgroundScheduler` with a 5-minute interval guarded against double-start in Flask debug/reloader mode. Updated `auth/routes.py` to add `POST /auth/logout` which fires `anchor_pending(user_id)` in a daemon thread within a pushed app context.
384+
385+
**Sarah:** [APPROVED]
386+
387+
---
388+
389+
**Sarah:** update all column name references across the codebase to match the new schema. old names: argon2id_server_hash, argon2id_server_salt, aes_gcm_ciphertext, aes_gcm_nonce_96bit, hpke_kem_output, hpke_sender_ephemeral_pk, blockchain_tx_id. new names: password_hash, password_salt, ciphertext, nonce, kem_output, ephemeral_pk, blockchain_record_id
390+
391+
**Claude:** Searched all non-venv files for the seven old names. Found hits only in `server/app/auth/routes.py` (the other five names had no code references yet). Updated `auth/routes.py`: renamed local variable `argon2id_salt``password_salt`, updated INSERT column names `argon2id_server_hash``password_hash` and `argon2id_server_salt``password_salt`, updated SELECT column name, replaced both `user['argon2id_server_hash']` dict accesses, and updated the rehash UPDATE SET clause. Left `docs/transcript_sarah.md` unchanged as it is historical record.
392+
393+
**Sarah:** [APPROVED]
394+
395+
---
396+
397+
**Sarah:** update anchor.py to write to the blockchain_records table instead of storing anchor_tx_hash directly on messages. after a successful chain tx, insert a row into blockchain_records with merkle_root, conv_a, conv_b, tx_hash, block_number, block_timestamp, then update blockchain_record_id on the anchored message rows to point to it
398+
399+
**Claude:** Updated `anchor.py`: added `uuid` and `datetime`/`timezone` imports; replaced `anchor_tx_hash IS NULL` filter with `blockchain_record_id IS NULL` in both SELECT queries; after sending the tx, now calls `wait_for_transaction_receipt(timeout=120)` and `get_block()` to obtain `block_number` and `block_timestamp` before touching the DB; inserts a `blockchain_records` row with all seven fields, then updates `messages.blockchain_record_id` to point at it — both inside a single `db.commit()` so they succeed or fail atomically. Nonce is incremented immediately after `send_raw_transaction` (before the receipt wait) so subsequent conversations can be submitted without waiting for each confirmation.
400+
401+
**Sarah:** [APPROVED]
402+
403+
---
404+
405+
**Sarah:** update docs/transcript with this conversation
406+
407+
**Claude:** Updated `docs/transcript_sarah.md` with all exchanges from the session to that point.
408+
409+
**Sarah:** [APPROVED]
410+
411+
---
412+
413+
**Sarah:** in anchor.py, after send_raw_transaction store the tx hash and mark messages as anchored immediately without waiting for receipt. add a second scheduled job that runs every 2 minutes to find blockchain_records where block_number is NULL, fetch the receipt, and update block_number and block_timestamp if confirmed.
414+
415+
**Claude:** Split anchoring into two phases. In `_run`: removed `wait_for_transaction_receipt` and `get_block` calls; the `blockchain_records` INSERT now writes `NULL, NULL` for `block_number`/`block_timestamp`; messages are linked to the record immediately after `send_raw_transaction`. Added `_confirm_lock` and a new `confirm_pending`/`_confirm_run` pair: `_confirm_run` queries `blockchain_records WHERE block_number IS NULL`, calls `get_transaction_receipt` (non-blocking — returns `None` if still pending), and on a confirmed receipt fetches the block and updates `block_number` and `block_timestamp` in a separate commit. Updated `__init__.py` to import `confirm_pending` and register `_scheduled_confirm` as a second APScheduler job on a 2-minute interval alongside the existing 5-minute anchor job.
416+
417+
**Sarah:** [APPROVED]
418+
419+
---
420+
421+
**Sarah:** update transcript in docs

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,4 @@ mysql-connector-python>=9.7.0
77
web3>=7.16.0
88
python-dotenv>=1.2.2
99
gunicorn>=26.0.0
10+
apscheduler>=3.11.0

server/app/__init__.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
1+
import logging
12
import os
23
import mysql.connector
34
from datetime import timedelta
45
from flask import Flask, g, current_app
56
from flask_jwt_extended import JWTManager
7+
from apscheduler.schedulers.background import BackgroundScheduler
8+
9+
logger = logging.getLogger(__name__)
610

711
def get_db():
812
if 'db' not in g:
@@ -40,6 +44,17 @@ def create_app():
4044
# would remove that UX penalty, but adds scope — chosen not to implement for now.
4145
app.config['JWT_ACCESS_TOKEN_EXPIRES'] = timedelta(minutes=15)
4246

47+
web3_vars = ['WEB3_RPC_URL', 'CONTRACT_ADDRESS', 'WALLET_PRIVATE_KEY']
48+
missing_web3 = [v for v in web3_vars if not os.getenv(v)]
49+
if missing_web3:
50+
logger.warning('Blockchain anchoring disabled — missing env vars: %s', ', '.join(missing_web3))
51+
app.config['ANCHORING_ENABLED'] = False
52+
else:
53+
app.config['ANCHORING_ENABLED'] = True
54+
app.config['WEB3_RPC_URL'] = os.getenv('WEB3_RPC_URL')
55+
app.config['CONTRACT_ADDRESS'] = os.getenv('CONTRACT_ADDRESS')
56+
app.config['WALLET_PRIVATE_KEY'] = os.getenv('WALLET_PRIVATE_KEY')
57+
4358
JWTManager(app)
4459

4560
@app.teardown_appcontext
@@ -53,4 +68,24 @@ def close_db(e=None):
5368
app.register_blueprint(auth_bp, url_prefix='/auth')
5469
app.register_blueprint(messages_bp, url_prefix='/messages')
5570

71+
if app.config['ANCHORING_ENABLED']:
72+
# In debug mode the reloader forks a child process; only start the scheduler
73+
# in the child (WERKZEUG_RUN_MAIN=true) to avoid two schedulers running.
74+
if not app.debug or os.environ.get('WERKZEUG_RUN_MAIN') == 'true':
75+
from .messages.anchor import anchor_pending, confirm_pending
76+
77+
def _scheduled_anchor():
78+
with app.app_context():
79+
anchor_pending()
80+
81+
def _scheduled_confirm():
82+
with app.app_context():
83+
confirm_pending()
84+
85+
scheduler = BackgroundScheduler()
86+
scheduler.add_job(_scheduled_anchor, 'interval', minutes=5)
87+
scheduler.add_job(_scheduled_confirm, 'interval', minutes=2)
88+
scheduler.start()
89+
app.scheduler = scheduler
90+
5691
return app

server/app/auth/routes.py

Lines changed: 40 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
1-
from flask import Blueprint, request, jsonify, current_app
2-
from argon2 import PasswordHasher
3-
from argon2.exceptions import VerifyMismatchError
4-
from flask_jwt_extended import create_access_token
1+
import threading
52
import uuid
63
from datetime import datetime, timezone
4+
75
import mysql.connector
6+
from argon2 import PasswordHasher
7+
from argon2.exceptions import VerifyMismatchError
8+
from flask import Blueprint, request, jsonify, current_app
9+
from flask_jwt_extended import create_access_token, jwt_required, get_jwt_identity
10+
811
from .. import get_db
912

1013
# Argon2id params (explicit to ensure stability across library versions):
@@ -21,7 +24,7 @@
2124

2225
REQUIRED_FIELDS = [
2326
'username', 'email', 'password',
24-
'x25519_public_key', 'hpke_wrapped_private_key', 'argon2id_kek_salt',
27+
'x25519_public_key', 'wrapped_private_key', 'kek_salt',
2528
]
2629

2730
def _invalid_fields(data, fields):
@@ -44,7 +47,7 @@ def register():
4447

4548
password_hash = ph.hash(data['password'])
4649
# Hash format: $argon2id$v=19$m=...,t=...,p=...$<base64-salt>$<base64-hash>
47-
argon2id_salt = password_hash.split('$')[4]
50+
password_salt = password_hash.split('$')[4]
4851

4952
user_id = str(uuid.uuid4())
5053
now = datetime.now(timezone.utc)
@@ -55,16 +58,16 @@ def register():
5558
cursor.execute(
5659
"""
5760
INSERT INTO users
58-
(id, username, email, argon2id_server_hash, argon2id_server_salt,
59-
x25519_public_key, hpke_wrapped_private_key, argon2id_kek_salt,
61+
(id, username, email, password_hash, password_salt,
62+
x25519_public_key, wrapped_private_key, kek_salt,
6063
tofu_key_pinned_at)
6164
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
6265
""",
6366
(
6467
user_id, data['username'], data['email'],
65-
password_hash, argon2id_salt,
66-
data['x25519_public_key'], data['hpke_wrapped_private_key'],
67-
data['argon2id_kek_salt'], now,
68+
password_hash, password_salt,
69+
data['x25519_public_key'], data['wrapped_private_key'],
70+
data['kek_salt'], now,
6871
),
6972
)
7073
db.commit()
@@ -98,8 +101,8 @@ def login():
98101
try:
99102
cursor.execute(
100103
"""
101-
SELECT id, username, argon2id_server_hash,
102-
hpke_wrapped_private_key, argon2id_kek_salt, x25519_public_key
104+
SELECT id, username, password_hash,
105+
wrapped_private_key, kek_salt, x25519_public_key
103106
FROM users
104107
WHERE username = %s
105108
""",
@@ -119,19 +122,19 @@ def login():
119122
return jsonify({'error': 'Invalid credentials'}), 401
120123

121124
try:
122-
ph.verify(user['argon2id_server_hash'], data['password'])
125+
ph.verify(user['password_hash'], data['password'])
123126
except VerifyMismatchError:
124127
return jsonify({'error': 'Invalid credentials'}), 401
125128

126-
if ph.check_needs_rehash(user['argon2id_server_hash']):
129+
if ph.check_needs_rehash(user['password_hash']):
127130
new_hash = ph.hash(data['password'])
128131
new_salt = new_hash.split('$')[4]
129132
cursor = db.cursor()
130133
try:
131134
cursor.execute(
132135
"""
133136
UPDATE users
134-
SET argon2id_server_hash = %s, argon2id_server_salt = %s
137+
SET password_hash = %s, password_salt = %s
135138
WHERE id = %s
136139
""",
137140
(new_hash, new_salt, user['id']),
@@ -150,7 +153,26 @@ def login():
150153

151154
return jsonify({
152155
'token': token,
153-
'hpke_wrapped_private_key': user['hpke_wrapped_private_key'],
154-
'argon2id_kek_salt': user['argon2id_kek_salt'],
156+
'wrapped_private_key': user['wrapped_private_key'],
157+
'kek_salt': user['kek_salt'],
155158
'x25519_public_key': user['x25519_public_key'],
156159
}), 200
160+
161+
162+
@auth_bp.route('/logout', methods=['POST'])
163+
@jwt_required()
164+
def logout():
165+
if current_app.config.get('ANCHORING_ENABLED'):
166+
user_id = get_jwt_identity()
167+
app = current_app._get_current_object()
168+
threading.Thread(
169+
target=lambda: _anchor_in_context(app, user_id),
170+
daemon=True,
171+
).start()
172+
return jsonify({'message': 'Logged out'}), 200
173+
174+
175+
def _anchor_in_context(app, user_id):
176+
from ..messages.anchor import anchor_pending
177+
with app.app_context():
178+
anchor_pending(user_id)

0 commit comments

Comments
 (0)