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.
- package/LICENSE +21 -0
- package/README.md +210 -0
- package/bin/cli.js +56 -0
- package/package.json +39 -0
- package/skills/nightpay/SKILL.md +112 -0
- package/skills/nightpay/contracts/receipt.compact +195 -0
- package/skills/nightpay/openclaw-fragment.json +38 -0
- package/skills/nightpay/rules/content-safety.md +187 -0
- package/skills/nightpay/rules/escrow-safety.md +132 -0
- package/skills/nightpay/rules/privacy-first.md +30 -0
- package/skills/nightpay/rules/receipt-format.md +45 -0
- package/skills/nightpay/scripts/bounty-board.sh +325 -0
- package/skills/nightpay/scripts/gateway.sh +578 -0
- package/skills/nightpay/scripts/mip003-server.sh +174 -0
- package/skills/nightpay/scripts/update-blocklist.sh +194 -0
|
@@ -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" "$@"
|