nightpay 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,325 @@
1
+ #!/usr/bin/env bash
2
+ # nightpay bounty board — public board backed by SQLite
3
+ #
4
+ # Handles 100K+ bounties with indexed queries, concurrent reads,
5
+ # and WAL mode for write performance. Single file, no external DB.
6
+ #
7
+ # SECURITY MODEL:
8
+ # - Board stored in a persistent, operator-controlled directory (not /tmp)
9
+ # - All inputs validated as 64-char hex before touching the database
10
+ # - Only known statuses accepted for remove (completed/refunded/expired)
11
+ # - SQLite WAL mode for safe concurrent access
12
+ # - Directory permissions restricted to owner (chmod 700)
13
+ #
14
+ # Usage: ./bounty-board.sh <command> [args...]
15
+ # Commands: list [limit] [offset], add <commit>, remove <commit> [status], stats,
16
+ # search <prefix>, report <commit> <category> [reason], reports [commit]
17
+
18
+ set -euo pipefail
19
+
20
+ BOARD_DIR="${BOARD_DIR:-${HOME}/.nightpay}"
21
+ BOARD_DB="${BOARD_DIR}/board.db"
22
+ COMPLAINT_FREEZE_THRESHOLD="${COMPLAINT_FREEZE_THRESHOLD:-3}"
23
+ SAFETY_REPORTS_FILE="${HOME}/.nightpay/safety/community-reports.json"
24
+
25
+ COMMAND="${1:?Usage: bounty-board.sh <command> [args...]}"
26
+ shift
27
+
28
+ validate_commitment() {
29
+ if ! [[ "$1" =~ ^[0-9a-f]{64}$ ]]; then
30
+ echo "ERROR: commitment must be a 64-character lowercase hex string"; exit 1
31
+ fi
32
+ }
33
+
34
+ mkdir -p "$BOARD_DIR"
35
+ chmod 700 "$BOARD_DIR"
36
+
37
+ # SECURITY: validate commitment hex format before any database or Python invocation.
38
+ # 'reports' without args lists all flagged — no commitment to validate.
39
+ if [[ "$COMMAND" == "add" || "$COMMAND" == "remove" || "$COMMAND" == "report" ]]; then
40
+ validate_commitment "${1:?Usage: bounty-board.sh $COMMAND <commitment>}"
41
+ elif [[ "$COMMAND" == "reports" && "${1:-}" != "" ]]; then
42
+ validate_commitment "$1"
43
+ fi
44
+
45
+ export COMPLAINT_FREEZE_THRESHOLD SAFETY_REPORTS_FILE
46
+
47
+ python3 -c "
48
+ import sqlite3, sys, json
49
+
50
+ db_path = sys.argv[1]
51
+ command = sys.argv[2]
52
+ args = sys.argv[3:]
53
+
54
+ conn = sqlite3.connect(db_path)
55
+ conn.execute('PRAGMA journal_mode=WAL')
56
+ conn.execute('PRAGMA synchronous=NORMAL')
57
+
58
+ conn.executescript('''
59
+ CREATE TABLE IF NOT EXISTS bounties (
60
+ commitment TEXT PRIMARY KEY,
61
+ timestamp TEXT NOT NULL,
62
+ status TEXT NOT NULL DEFAULT 'active'
63
+ );
64
+ CREATE INDEX IF NOT EXISTS idx_bounties_status ON bounties(status);
65
+ CREATE INDEX IF NOT EXISTS idx_bounties_ts ON bounties(timestamp);
66
+
67
+ CREATE TABLE IF NOT EXISTS stats (
68
+ key TEXT PRIMARY KEY,
69
+ value INTEGER NOT NULL DEFAULT 0
70
+ );
71
+ INSERT OR IGNORE INTO stats(key, value) VALUES ('posted', 0);
72
+ INSERT OR IGNORE INTO stats(key, value) VALUES ('completed', 0);
73
+ INSERT OR IGNORE INTO stats(key, value) VALUES ('refunded', 0);
74
+ INSERT OR IGNORE INTO stats(key, value) VALUES ('expired', 0);
75
+ INSERT OR IGNORE INTO stats(key, value) VALUES ('flagged', 0);
76
+
77
+ -- SAFETY: community complaint tracking.
78
+ -- reporter_hash is sha256(reporter_id) — preserves reporter privacy
79
+ -- while preventing duplicate reports from the same reporter.
80
+ CREATE TABLE IF NOT EXISTS complaints (
81
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
82
+ commitment TEXT NOT NULL,
83
+ category TEXT NOT NULL,
84
+ reason TEXT NOT NULL DEFAULT '',
85
+ reporter_hash TEXT NOT NULL,
86
+ timestamp TEXT NOT NULL,
87
+ UNIQUE(commitment, reporter_hash)
88
+ );
89
+ CREATE INDEX IF NOT EXISTS idx_complaints_commitment ON complaints(commitment);
90
+ ''')
91
+
92
+ if command == 'list':
93
+ # SECURITY: clamp limit and offset — prevent memory exhaustion from huge integers
94
+ MAX_LIMIT = 200
95
+ MAX_OFFSET = 10_000_000
96
+ try:
97
+ limit = min(int(args[0]), MAX_LIMIT) if len(args) > 0 else 50
98
+ offset = min(int(args[1]), MAX_OFFSET) if len(args) > 1 else 0
99
+ except ValueError:
100
+ print('ERROR: limit and offset must be integers'); sys.exit(1)
101
+ cur = conn.execute(
102
+ 'SELECT commitment, timestamp FROM bounties WHERE status = ? ORDER BY timestamp DESC LIMIT ? OFFSET ?',
103
+ ('active', limit, offset)
104
+ )
105
+ rows = cur.fetchall()
106
+ active_count = conn.execute('SELECT COUNT(*) FROM bounties WHERE status = ?', ('active',)).fetchone()[0]
107
+ print(f'Active bounties: {active_count} (showing {offset+1}-{offset+len(rows)})')
108
+ for commitment, ts in rows:
109
+ print(f' {commitment[:16]}... posted {ts}')
110
+
111
+ elif command == 'add':
112
+ commitment = args[0]
113
+ from datetime import datetime, timezone
114
+ ts = datetime.now(timezone.utc).isoformat()
115
+ try:
116
+ conn.execute('INSERT INTO bounties(commitment, timestamp) VALUES (?, ?)', (commitment, ts))
117
+ conn.execute('UPDATE stats SET value = value + 1 WHERE key = ?', ('posted',))
118
+ conn.commit()
119
+ print('Bounty added')
120
+ except sqlite3.IntegrityError:
121
+ print('ERROR: Bounty with this commitment already exists')
122
+ sys.exit(1)
123
+
124
+ elif command == 'remove':
125
+ commitment = args[0]
126
+ status = args[1] if len(args) > 1 else 'completed'
127
+ if status not in ('completed', 'refunded', 'expired'):
128
+ print('ERROR: status must be completed, refunded, or expired')
129
+ sys.exit(1)
130
+ cur = conn.execute('UPDATE bounties SET status = ? WHERE commitment = ? AND status = ?', (status, commitment, 'active'))
131
+ if cur.rowcount == 0:
132
+ print('ERROR: No active bounty with that commitment')
133
+ sys.exit(1)
134
+ conn.execute('UPDATE stats SET value = value + 1 WHERE key = ?', (status,))
135
+ conn.commit()
136
+ print(f'Bounty removed ({status})')
137
+
138
+ elif command == 'stats':
139
+ rows = conn.execute('SELECT key, value FROM stats').fetchall()
140
+ s = dict(rows)
141
+ active = conn.execute('SELECT COUNT(*) FROM bounties WHERE status = ?', ('active',)).fetchone()[0]
142
+ flagged = conn.execute('SELECT COUNT(*) FROM bounties WHERE status = ?', ('flagged',)).fetchone()[0]
143
+ complaints_total = conn.execute('SELECT COUNT(*) FROM complaints').fetchone()[0]
144
+ print(f'Posted: {s.get(\"posted\",0)} | Completed: {s.get(\"completed\",0)} | Refunded: {s.get(\"refunded\",0)} | Expired: {s.get(\"expired\",0)}')
145
+ print(f'Active: {active} | Flagged: {flagged} | Complaints: {complaints_total}')
146
+
147
+ elif command == 'search':
148
+ prefix = args[0] if args else ''
149
+ # SECURITY: prefix must be a hex string — no wildcards, no SQL special chars.
150
+ # Prevents LIKE pattern DoS (e.g. '%a%b%c%' causes catastrophic backtracking)
151
+ # and ensures search is only ever a left-anchored prefix scan on the index.
152
+ import re
153
+ if not re.match(r'^[0-9a-f]{0,64}$', prefix):
154
+ print('ERROR: search prefix must be a lowercase hex string (0-64 chars)'); sys.exit(1)
155
+ cur = conn.execute(
156
+ 'SELECT commitment, timestamp, status FROM bounties WHERE commitment LIKE ? LIMIT 20',
157
+ (prefix + '%',)
158
+ )
159
+ for commitment, ts, status in cur.fetchall():
160
+ print(f' {commitment[:16]}... {status} {ts}')
161
+
162
+ elif command == 'report':
163
+ # SAFETY: community complaint — anyone can report a bounty commitment
164
+ # Usage: report <commitment> <category> [reason]
165
+ import re, hashlib, os, secrets
166
+ from datetime import datetime, timezone
167
+
168
+ if len(args) < 2:
169
+ print('Usage: report <commitment> <category> [reason]'); sys.exit(1)
170
+
171
+ commitment = args[0]
172
+ category = args[1]
173
+ reason = args[2] if len(args) > 2 else ''
174
+
175
+ # SECURITY: truncate reason to 500 chars — prevent DB bloat via huge strings
176
+ reason = reason[:500]
177
+
178
+ VALID_CATEGORIES = (
179
+ 'csam', 'violence', 'weapons_of_mass_destruction', 'human_trafficking',
180
+ 'terrorism', 'ncii', 'financial_fraud', 'infrastructure_attack',
181
+ 'doxxing', 'drug_manufacturing', 'other'
182
+ )
183
+ if category not in VALID_CATEGORIES:
184
+ sep = ', '
185
+ print(f'ERROR: category must be one of: {sep.join(VALID_CATEGORIES)}')
186
+ sys.exit(1)
187
+
188
+ # DARK ENERGY: reporter_hash preimage leak fix.
189
+ # Old: sha256(REPORTER_ID env var) — low-entropy input like a username is
190
+ # reversible via rainbow table, de-anonymising reporters.
191
+ # Fix: sha256(PEPPER + REPORTER_ID) where PEPPER is a 32-byte server secret.
192
+ # Without the pepper, no rainbow table attack is possible.
193
+ pepper = os.environ.get('REPORTER_PEPPER', '')
194
+ if not pepper:
195
+ # DARK ENERGY: no pepper = no privacy. Fail loudly rather than silently
196
+ # hashing a low-entropy ID and believing it's private.
197
+ print('ERROR: REPORTER_PEPPER env var required for reporter privacy'); sys.exit(1)
198
+ reporter_id = os.environ.get('REPORTER_ID', '')
199
+ if not reporter_id:
200
+ print('ERROR: REPORTER_ID env var required to file a report'); sys.exit(1)
201
+ reporter_hash = hashlib.sha256(f'{pepper}:{reporter_id}'.encode()).hexdigest()
202
+
203
+ ts = datetime.now(timezone.utc).isoformat()
204
+
205
+ # Check bounty exists
206
+ exists = conn.execute('SELECT status FROM bounties WHERE commitment = ?', (commitment,)).fetchone()
207
+ if not exists:
208
+ print('ERROR: No bounty with that commitment on the board')
209
+ sys.exit(1)
210
+
211
+ # DARK ENERGY: auto-freeze manipulation fix.
212
+ # Old: anyone with 3 different REPORTER_ID values could freeze any bounty.
213
+ # Fix: rate-limit reports per reporter_hash — one reporter can flag at most
214
+ # REPORT_RATE_LIMIT distinct bounties per REPORT_WINDOW_HOURS. Stored in DB.
215
+ REPORT_RATE_LIMIT = int(os.environ.get('REPORT_RATE_LIMIT', '10'))
216
+ REPORT_WINDOW_HOURS = int(os.environ.get('REPORT_WINDOW_HOURS', '24'))
217
+ from datetime import timedelta
218
+ window_start = (datetime.now(timezone.utc) - timedelta(hours=REPORT_WINDOW_HOURS)).isoformat()
219
+ recent_reports = conn.execute(
220
+ 'SELECT COUNT(DISTINCT commitment) FROM complaints WHERE reporter_hash = ? AND timestamp > ?',
221
+ (reporter_hash, window_start)
222
+ ).fetchone()[0]
223
+ if recent_reports >= REPORT_RATE_LIMIT:
224
+ print(f'ERROR: Rate limit — you have filed {recent_reports} reports in the last {REPORT_WINDOW_HOURS}h (max {REPORT_RATE_LIMIT})')
225
+ sys.exit(1)
226
+
227
+ try:
228
+ conn.execute(
229
+ 'INSERT INTO complaints(commitment, category, reason, reporter_hash, timestamp) VALUES (?, ?, ?, ?, ?)',
230
+ (commitment, category, reason, reporter_hash, ts)
231
+ )
232
+ conn.commit()
233
+ except sqlite3.IntegrityError:
234
+ print('ERROR: You have already reported this bounty')
235
+ sys.exit(1)
236
+
237
+ # Count DISTINCT reporter hashes — one reporter spamming multiple reports
238
+ # counts as 1 voice, not N voices
239
+ count = conn.execute(
240
+ 'SELECT COUNT(DISTINCT reporter_hash) FROM complaints WHERE commitment = ?', (commitment,)
241
+ ).fetchone()[0]
242
+
243
+ threshold = int(os.environ.get('COMPLAINT_FREEZE_THRESHOLD', '3'))
244
+ print(f'Report filed ({count} distinct reporter(s) for this bounty)')
245
+
246
+ # SAFETY: auto-freeze — if DISTINCT reporters >= threshold, flag bounty
247
+ if count >= threshold and exists[0] == 'active':
248
+ conn.execute(
249
+ 'UPDATE bounties SET status = ? WHERE commitment = ? AND status = ?',
250
+ ('flagged', commitment, 'active')
251
+ )
252
+ conn.execute('UPDATE stats SET value = value + 1 WHERE key = ?', ('flagged',))
253
+ conn.commit()
254
+ print(f'AUTO-FREEZE: Bounty {commitment[:16]}... flagged by {count} distinct reporters (threshold: {threshold})')
255
+
256
+ # Export to community-reports.json for operator review
257
+ reports_file = os.environ.get('SAFETY_REPORTS_FILE', '')
258
+ if reports_file:
259
+ os.makedirs(os.path.dirname(reports_file), exist_ok=True)
260
+ cats = conn.execute(
261
+ 'SELECT category, COUNT(*) FROM complaints WHERE commitment = ? GROUP BY category',
262
+ (commitment,)
263
+ ).fetchall()
264
+ new_entry = {
265
+ 'commitment': commitment,
266
+ 'categories': {cat: cnt for cat, cnt in cats},
267
+ 'distinct_reporters': count,
268
+ 'flagged_at': ts
269
+ }
270
+ # DARK ENERGY: atomic write race fix for Windows.
271
+ # os.rename() is NOT atomic on Windows if the target file exists —
272
+ # it raises FileExistsError. Use os.replace() which IS atomic on
273
+ # both POSIX (rename syscall) and Windows (MoveFileEx MOVEFILE_REPLACE_EXISTING).
274
+ existing = {'reports': []}
275
+ if os.path.exists(reports_file):
276
+ try:
277
+ with open(reports_file) as f:
278
+ existing = json.load(f)
279
+ except (json.JSONDecodeError, KeyError):
280
+ pass
281
+ existing['reports'].append(new_entry)
282
+ tmp = reports_file + f'.tmp.{secrets.token_hex(8)}'
283
+ with open(tmp, 'w') as f:
284
+ json.dump(existing, f, indent=2)
285
+ os.replace(tmp, reports_file) # atomic on both POSIX and Windows
286
+
287
+ elif command == 'reports':
288
+ # View complaints for a specific commitment or all flagged bounties
289
+ if args:
290
+ commitment = args[0]
291
+ cur = conn.execute(
292
+ 'SELECT category, reason, timestamp FROM complaints WHERE commitment = ? ORDER BY timestamp DESC',
293
+ (commitment,)
294
+ )
295
+ rows = cur.fetchall()
296
+ if not rows:
297
+ print(f'No complaints for {commitment[:16]}...')
298
+ else:
299
+ print(f'Complaints for {commitment[:16]}... ({len(rows)} total):')
300
+ for cat, reason, ts in rows:
301
+ reason_str = f' — {reason}' if reason else ''
302
+ print(f' [{cat}] {ts}{reason_str}')
303
+ else:
304
+ # Show all flagged bounties
305
+ cur = conn.execute(
306
+ '''SELECT b.commitment, b.timestamp, COUNT(c.id) as complaint_count
307
+ FROM bounties b
308
+ JOIN complaints c ON b.commitment = c.commitment
309
+ WHERE b.status = 'flagged'
310
+ GROUP BY b.commitment
311
+ ORDER BY complaint_count DESC
312
+ LIMIT 50''',
313
+ )
314
+ rows = cur.fetchall()
315
+ flagged_count = conn.execute('SELECT COUNT(*) FROM bounties WHERE status = ?', ('flagged',)).fetchone()[0]
316
+ print(f'Flagged bounties: {flagged_count}')
317
+ for commitment, ts, cnt in rows:
318
+ print(f' {commitment[:16]}... posted {ts} ({cnt} complaints)')
319
+
320
+ else:
321
+ print('Commands: list, add, remove, stats, search, report, reports')
322
+ sys.exit(1)
323
+
324
+ conn.close()
325
+ " "$BOARD_DB" "$COMMAND" "$@"