nightpay 0.1.1 → 0.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nightpay",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Anonymous community bounties for AI agents. Midnight ZK proofs + Masumi settlement + Cardano finality.",
5
5
  "keywords": [
6
6
  "bounties",
@@ -1,17 +1,10 @@
1
1
  ---
2
2
  name: nightpay
3
- description: Anonymous community bounty board — many funders pool shielded NIGHT into bounties, AI agents complete the work via Masumi, ZK receipts prove completion without revealing who funded it.
3
+ description: Anonymous community bounty board — post a bounty, fund privately, crowdfund with nightpay. Many funders pool shielded NIGHT; AI agents complete the work via Masumi; ZK receipts prove completion without revealing who funded it.
4
4
  license: MIT
5
- compatibility:
6
- - openclaw
7
- - claude-code
8
- - cursor
9
- - copilot
10
- metadata:
11
- category: payments
12
- blockchain: midnight, cardano
13
- agent-layer: masumi
14
- version: 0.1.0
5
+ compatibility: "openclaw, claude-code, cursor, copilot"
6
+ allowed-tools: Bash
7
+ metadata: {"openclaw":{"requires":{"bins":["bash","curl","openssl","sqlite3","sha256sum"],"env":["MASUMI_API_KEY","OPERATOR_ADDRESS"]},"primaryEnv":"MASUMI_API_KEY","os":["darwin","linux"]},"category":"payments","blockchain":"midnight, cardano","agent-layer":"masumi","version":"0.1.2"}
15
8
  ---
16
9
 
17
10
  # nightpay
@@ -1,38 +1,20 @@
1
1
  {
2
- "$comment": "Merge into your openclaw.json under 'skills' to enable nightpay bounty board",
2
+ "$comment": "Merge this into your ~/.openclaw/openclaw.json under 'skills.entries' to enable the nightpay skill. OpenClaw discovers the skill automatically once it is installed into ./skills/nightpay — this fragment just supplies the required env vars.",
3
3
  "skills": {
4
- "nightpay": {
5
- "path": "./skills/nightpay",
6
- "activation": ["bounty", "community bounty", "anonymous bounty", "crowdfund", "nightpay", "bounty board", "post a bounty", "fund this privately"],
7
- "config": {
8
- "midnightNetwork": "testnet",
9
- "masumiPaymentUrl": "http://localhost:3001/api/v1",
10
- "masumiRegistryUrl": "http://localhost:3000/api/v1",
11
- "receiptContractAddress": null,
12
- "operatorAddress": null,
13
- "operatorFeeBps": 200,
14
- "maxBountySpecks": 500000000,
15
- "minBountySpecks": 1000,
16
- "escrowTimeoutMinutes": 60,
17
- "contentSafetyUrl": null,
18
- "complaintFreezeThreshold": 3
19
- },
20
- "tools": {
21
- "allow": ["curl", "openssl", "python3", "sha256sum", "sqlite3"],
22
- "deny": ["browser", "file_edit"]
23
- },
24
- "env": [
25
- "MASUMI_API_KEY",
26
- "MIDNIGHT_NETWORK",
27
- "OPERATOR_ADDRESS",
28
- "OPERATOR_FEE_BPS",
29
- "RECEIPT_CONTRACT_ADDRESS",
30
- "OPERATOR_SECRET_KEY",
31
- "CONTENT_SAFETY_URL",
32
- "SAFETY_RULES_FILE",
33
- "COMPLAINT_FREEZE_THRESHOLD",
34
- "BOARD_DIR"
35
- ]
4
+ "entries": {
5
+ "nightpay": {
6
+ "enabled": true,
7
+ "env": {
8
+ "MASUMI_API_KEY": "MASUMI_API_KEY",
9
+ "OPERATOR_ADDRESS": "OPERATOR_ADDRESS",
10
+ "MIDNIGHT_NETWORK": "MIDNIGHT_NETWORK",
11
+ "OPERATOR_FEE_BPS": "OPERATOR_FEE_BPS",
12
+ "RECEIPT_CONTRACT_ADDRESS": "RECEIPT_CONTRACT_ADDRESS",
13
+ "OPERATOR_SECRET_KEY": "OPERATOR_SECRET_KEY",
14
+ "CONTENT_SAFETY_URL": "CONTENT_SAFETY_URL",
15
+ "BRIDGE_URL": "BRIDGE_URL"
16
+ }
17
+ }
36
18
  }
37
19
  }
38
20
  }
@@ -117,6 +117,13 @@ These are attacks that pass all basic validation but exploit deeper system prope
117
117
  | **DNS rebinding SSRF** | Attacker controls DNS → startup URL check passes (public IP) → A-record flips to 169.254.169.254 → all curl calls hit cloud metadata | `_ssrf_safe_curl()` re-resolves and re-validates every hostname on every request, not just at startup |
118
118
  | **Shell word splitting** | `openssl rand` or `sha256sum` output contains whitespace → variable splits into multiple tokens → hash computation silently corrupted | `generate_nonce()` pipes through `tr -d '[:space:]'`; `domain_hash()` uses `printf` instead of `echo -n` and strips whitespace from output |
119
119
  | **reporter_hash rainbow table** | `sha256(username)` is reversible for low-entropy inputs → reporters de-anonymised | `sha256(REPORTER_PEPPER + ":" + reporter_id)` — server-side pepper makes preimage attacks infeasible |
120
+ | **Fake work submission** | Anyone knowing a `job_id` calls `POST /provide_input/<id>` with fabricated results | `job_token = HMAC-SHA256("nightpay-job-token-v1:{job_id}", JOB_TOKEN_SECRET)` — only the agent that called `start_job` holds the token; 401/403 on missing/invalid token |
121
+ | **Result-swap after commit** | Agent waits to see funder's expected output, then constructs a matching `work_nonce` to pass reveal check | `work_commit = sha256("nightpay-work-reveal-v1:{work}:{nonce}")` committed at `start_job` before work begins — SHA-256 preimage resistance makes post-hoc matching infeasible |
122
+ | **Multisig double-counting** | One approver submits two signatures with different nonces → counted as two votes | `used_keys` set in verifier tracks which key index already matched — each entry in `APPROVER_KEYS` counts at most once regardless of how many valid blobs it signs |
123
+ | **Stale approval replay** | Attacker captures a valid M-of-N approval blob and reuses it months later on a different job | Approval payload includes `job_id + output_hash + ts + nonce`; verifier rejects if `age > 86400s`; `job_id` binding makes blobs non-transferable |
124
+ | **Clock skew abuse** | Approver pre-signs with a timestamp 25h in the future to extend the 24h expiry window | `age < -300s` check rejects approvals more than 5 minutes in the future |
125
+ | **Optimistic double-complete** | `optimistic-sweep` cron and operator manually run `complete` concurrently on same job | Midnight nullifier set is canonical — second `completeAndReceipt` circuit call is rejected on-chain regardless of race condition off-chain |
126
+ | **Job status filter injection** | `GET /jobs?status='; DROP TABLE jobs--` sent to MIP-003 server | `KNOWN_STATUSES` whitelist check before any DB query — unknown values return 400, never reach SQLite |
120
127
  | **Auto-freeze weaponisation** | Attacker creates N cheap reporter IDs → files N reports → any legitimate bounty silently frozen | Rate limit per reporter: max `REPORT_RATE_LIMIT` distinct bounties per `REPORT_WINDOW_HOURS`; freeze counts DISTINCT reporter hashes, not total complaint rows |
121
128
  | **Atomic write race (Windows)** | Two concurrent freeze events both write `.tmp` then `os.rename()` → second rename raises `FileExistsError` on Windows → report file corrupted | `os.replace()` instead of `os.rename()` — atomic on both POSIX and Windows; tmp file uses `secrets.token_hex(8)` suffix to prevent collision |
122
129
 
@@ -13,14 +13,16 @@
13
13
  # Usage: ./gateway.sh <command> [args...]
14
14
  #
15
15
  # Commands:
16
- # post-bounty <job_description> <amount_night_specks>
17
- # find-agent <capability_query>
18
- # hire-and-pay <agent_id> <job_description> <commitment_hash>
19
- # check-job <job_id>
20
- # complete <job_id> <commitment_hash>
21
- # refund <job_id> <commitment_hash>
22
- # withdraw-fees [amount_specks] # operator-only: requires OPERATOR_SECRET_KEY
23
- # stats # public contract stats
16
+ # post-bounty <job_description> <amount_night_specks>
17
+ # find-agent <capability_query>
18
+ # hire-and-pay <agent_id> <job_description> <commitment_hash>
19
+ # check-job <job_id>
20
+ # complete <job_id> <commitment_hash> [--approvals sig1:ts1:nonce1,...]
21
+ # refund <job_id> <commitment_hash>
22
+ # withdraw-fees [amount_specks] # operator-only: requires OPERATOR_SECRET_KEY
23
+ # stats # public contract stats
24
+ # approve-multisig <job_id> <output_hash> <approver_key> # per-approver signature
25
+ # optimistic-sweep [--dry-run] # auto-complete expired optimistic windows
24
26
 
25
27
  set -euo pipefail
26
28
 
@@ -358,6 +360,16 @@ print(json.dumps({
358
360
  fi
359
361
  }
360
362
 
363
+ # ─── Optimistic delivery & multisig env vars ──────────────────────────────────
364
+ OPTIMISTIC_WINDOW_HOURS="${OPTIMISTIC_WINDOW_HOURS:-48}"
365
+ MULTISIG_THRESHOLD_SPECKS="${MULTISIG_THRESHOLD_SPECKS:-1000000}"
366
+ MULTISIG_M="${MULTISIG_M:-2}"
367
+ MULTISIG_N="${MULTISIG_N:-3}"
368
+ # APPROVER_KEYS: comma-separated HMAC secrets, one per approver
369
+ # e.g. APPROVER_KEYS="key1secret,key2secret,key3secret"
370
+ APPROVER_KEYS="${APPROVER_KEYS:-}"
371
+ MIP003_PORT="${MIP003_PORT:-8090}"
372
+
361
373
  # ─── Commands ─────────────────────────────────────────────────────────────────
362
374
 
363
375
  case "$COMMAND" in
@@ -447,13 +459,129 @@ print(json.dumps({
447
459
  masumi_get "/purchases/$JOB_ID/status"
448
460
  ;;
449
461
 
462
+ approve-multisig)
463
+ # Each approver runs this with their own key.
464
+ # Collect M blobs and pass all sigs to: gateway.sh complete <id> <commit> --approvals sig1:ts1:nonce1,...
465
+ JOB_ID="${1:?Usage: approve-multisig <job_id> <output_hash> <approver_key>}"
466
+ OUTPUT_HASH="${2:?Usage: approve-multisig <job_id> <output_hash> <approver_key>}"
467
+ APPROVER_KEY="${3:?Usage: approve-multisig <job_id> <output_hash> <approver_key>}"
468
+
469
+ validate_job_id "$JOB_ID"
470
+ validate_commitment "$OUTPUT_HASH" # reuse 64-hex validator
471
+
472
+ TS=$(date +%s)
473
+ NONCE=$(generate_nonce)
474
+ # SECURITY: timestamp + nonce in payload — each approval is unique, stale replays rejected by complete
475
+ SIG_PAYLOAD="${JOB_ID}:${OUTPUT_HASH}:${TS}:${NONCE}"
476
+ SIG=$(echo -n "$SIG_PAYLOAD" | openssl dgst -sha256 -hmac "$APPROVER_KEY" | awk '{print $2}')
477
+
478
+ python3 -c "
479
+ import sys, json
480
+ print(json.dumps({
481
+ 'job_id': sys.argv[1],
482
+ 'output_hash': sys.argv[2],
483
+ 'sig': sys.argv[3],
484
+ 'ts': int(sys.argv[4]),
485
+ 'nonce': sys.argv[5],
486
+ 'approval_blob': f'{sys.argv[3]}:{sys.argv[4]}:{sys.argv[5]}',
487
+ 'note': 'Pass all collected approval_blobs to: gateway.sh complete <job_id> <commitment> --approvals blob1,blob2'
488
+ }, indent=2))
489
+ " "$JOB_ID" "$OUTPUT_HASH" "$SIG" "$TS" "$NONCE"
490
+ ;;
491
+
450
492
  complete)
451
- JOB_ID="${1:?Usage: complete <job_id> <commitment_hash>}"
493
+ JOB_ID="${1:?Usage: complete <job_id> <commitment_hash> [--approvals sig1:ts1:nonce1,...]}"
452
494
  COMMITMENT="${2:?Usage: complete <job_id> <commitment_hash>}"
495
+ # Optional: $3 = "--approvals", $4 = comma-separated sig:ts:nonce blobs
496
+ APPROVALS_FLAG="${3:-}"
497
+ APPROVALS_RAW="${4:-}"
453
498
 
454
499
  validate_job_id "$JOB_ID" # SECURITY: prevent path traversal
455
500
  validate_commitment "$COMMITMENT"
456
501
 
502
+ # ── Multisig verification (for high-value bounties) ────────────────────
503
+ # Query the MIP-003 server for job amount to decide if multisig required
504
+ JOB_AMOUNT=0
505
+ if command -v curl >/dev/null 2>&1; then
506
+ _JOB_INFO=$(curl -sf --max-time 5 "http://localhost:${MIP003_PORT}/status/${JOB_ID}" 2>/dev/null || echo '{}')
507
+ JOB_AMOUNT=$(echo "$_JOB_INFO" | python3 -c "
508
+ import sys, json
509
+ try:
510
+ d = json.load(sys.stdin)
511
+ print(d.get('amount_specks') or 0)
512
+ except: print(0)
513
+ " 2>/dev/null || echo 0)
514
+ fi
515
+
516
+ if (( JOB_AMOUNT >= MULTISIG_THRESHOLD_SPECKS )); then
517
+ if [[ -z "$APPROVALS_RAW" || "$APPROVALS_FLAG" != "--approvals" ]]; then
518
+ echo "ERROR: job amount ${JOB_AMOUNT} specks >= threshold ${MULTISIG_THRESHOLD_SPECKS}" >&2
519
+ echo " Multisig required. Each approver runs:" >&2
520
+ echo " gateway.sh approve-multisig $JOB_ID <output_hash> <approver_key>" >&2
521
+ echo " Then collect M approval_blobs and run:" >&2
522
+ echo " gateway.sh complete $JOB_ID $COMMITMENT --approvals blob1,blob2" >&2
523
+ exit 1
524
+ fi
525
+
526
+ # Verify M-of-N approvals using Python stdlib only (no new deps)
527
+ VERIFY_OK=$(python3 -c "
528
+ import sys, json, hmac, hashlib, time
529
+
530
+ job_id = sys.argv[1]
531
+ output_hash = sys.argv[2]
532
+ approvals_raw = sys.argv[3]
533
+ approver_keys = [k for k in sys.argv[4].split(',') if k] if sys.argv[4] else []
534
+ required_m = int(sys.argv[5])
535
+ max_age_secs = 86400 # approvals expire after 24h — prevents replay attacks
536
+
537
+ # Parse: each entry is sig:ts:nonce
538
+ approvals = []
539
+ for entry in approvals_raw.split(','):
540
+ parts = entry.split(':')
541
+ if len(parts) != 3:
542
+ print(f'ERROR: malformed approval blob (expected sig:ts:nonce): {entry}', file=sys.stderr)
543
+ sys.exit(1)
544
+ try:
545
+ approvals.append({'sig': parts[0], 'ts': int(parts[1]), 'nonce': parts[2]})
546
+ except ValueError:
547
+ print(f'ERROR: non-integer timestamp in approval: {entry}', file=sys.stderr)
548
+ sys.exit(1)
549
+
550
+ now = int(time.time())
551
+ valid_count = 0
552
+ used_keys = set() # SECURITY: each key index counts once — no double-counting
553
+
554
+ for approval in approvals:
555
+ age = now - approval['ts']
556
+ if age > max_age_secs:
557
+ print(f'WARN: approval ts={approval[\"ts\"]} is too old (age={age}s > {max_age_secs}s)', file=sys.stderr)
558
+ continue
559
+ if age < -300: # 5-min future clock skew tolerance
560
+ print(f'WARN: approval ts={approval[\"ts\"]} is too far in future (age={age}s)', file=sys.stderr)
561
+ continue
562
+
563
+ payload = f'{job_id}:{output_hash}:{approval[\"ts\"]}:{approval[\"nonce\"]}'
564
+ for i, key in enumerate(approver_keys):
565
+ if i in used_keys:
566
+ continue
567
+ expected = hmac.new(key.encode(), payload.encode(), hashlib.sha256).hexdigest()
568
+ if hmac.compare_digest(expected, approval['sig']):
569
+ used_keys.add(i)
570
+ valid_count += 1
571
+ break
572
+
573
+ if valid_count >= required_m:
574
+ print(f'ok:{valid_count}')
575
+ else:
576
+ print(f'ERROR: only {valid_count} valid approvals, need {required_m}', file=sys.stderr)
577
+ sys.exit(1)
578
+ " "$JOB_ID" "$COMMITMENT" "$APPROVALS_RAW" "$APPROVER_KEYS" "$MULTISIG_M" 2>&1) || {
579
+ echo "SECURITY ERROR: multisig verification failed — $VERIFY_OK" >&2
580
+ exit 1
581
+ }
582
+ echo " Multisig: $VERIFY_OK approvals verified" >&2
583
+ fi
584
+
457
585
  RESULT_DATA=$(masumi_get "/purchases/$JOB_ID/result")
458
586
 
459
587
  # SECURITY: canonical JSON (sorted keys, no whitespace) prevents hash
@@ -571,8 +699,80 @@ print(json.dumps({
571
699
  " "$RECEIPT_CONTRACT" "$MIDNIGHT_NETWORK"
572
700
  ;;
573
701
 
702
+ optimistic-sweep)
703
+ # Scan for jobs whose optimistic window has expired and auto-complete them.
704
+ # Run on a cron: */30 * * * * bash gateway.sh optimistic-sweep
705
+ # Dry-run: gateway.sh optimistic-sweep --dry-run
706
+ DRY_RUN=0
707
+ [[ "${1:-}" == "--dry-run" ]] && DRY_RUN=1
708
+
709
+ MIP003_URL="http://localhost:${MIP003_PORT}"
710
+ echo "Scanning for auto-approvable jobs (window=${OPTIMISTIC_WINDOW_HOURS}h, url=${MIP003_URL})..." >&2
711
+
712
+ # Fetch jobs in awaiting_approval state
713
+ JOBS_JSON=$(curl -sf --max-time 10 "${MIP003_URL}/jobs?status=awaiting_approval" 2>/dev/null || echo '{"jobs":[]}')
714
+
715
+ # Filter for expired windows and auto-complete each
716
+ python3 -c "
717
+ import sys, json, subprocess, os
718
+ from datetime import datetime, timezone
719
+
720
+ jobs_json = sys.argv[1]
721
+ gateway = sys.argv[2]
722
+ dry_run = sys.argv[3] == '1'
723
+ env = os.environ.copy()
724
+
725
+ try:
726
+ data = json.loads(jobs_json)
727
+ except Exception as e:
728
+ print(f'ERROR: could not parse /jobs response: {e}', file=sys.stderr)
729
+ sys.exit(1)
730
+
731
+ now = datetime.now(timezone.utc).isoformat()
732
+ jobs = data.get('jobs', [])
733
+ done = 0
734
+ errors = 0
735
+
736
+ for job in jobs:
737
+ jid = job.get('job_id', '')
738
+ approved_at = job.get('approved_at')
739
+ input_data = job.get('input_data') or {}
740
+
741
+ if not approved_at or approved_at > now:
742
+ continue # window not yet expired
743
+
744
+ # Extract commitmentHash from input_data (set by hire-and-pay)
745
+ if isinstance(input_data, str):
746
+ try: input_data = json.loads(input_data)
747
+ except: input_data = {}
748
+ commit = input_data.get('commitmentHash', '')
749
+
750
+ if not commit:
751
+ print(f'SKIP {jid}: no commitmentHash in input_data')
752
+ errors += 1
753
+ continue
754
+
755
+ if dry_run:
756
+ print(f'DRY-RUN: would complete job_id={jid} commitment={commit[:16]}...')
757
+ done += 1
758
+ else:
759
+ result = subprocess.run(
760
+ ['/usr/bin/env', 'bash', gateway, 'complete', jid, commit],
761
+ env=env, capture_output=True, text=True
762
+ )
763
+ if result.returncode == 0:
764
+ print(f'AUTO-COMPLETE OK: {jid}')
765
+ done += 1
766
+ else:
767
+ print(f'AUTO-COMPLETE FAILED: {jid} — {result.stderr.strip()}')
768
+ errors += 1
769
+
770
+ print(f'Sweep complete: {done} completed, {errors} errors.', file=sys.stderr)
771
+ " "$JOBS_JSON" "$0" "$DRY_RUN"
772
+ ;;
773
+
574
774
  *)
575
- echo "Commands: post-bounty, find-agent, hire-and-pay, check-job, complete, refund, withdraw-fees, stats"
775
+ echo "Commands: post-bounty, find-agent, hire-and-pay, check-job, complete, refund, withdraw-fees, stats, approve-multisig, optimistic-sweep"
576
776
  exit 1
577
777
  ;;
578
778
  esac
@@ -7,6 +7,14 @@
7
7
  # Usage: ./mip003-server.sh [port]
8
8
  # Default port: 8090
9
9
  #
10
+ # Required env vars:
11
+ # JOB_TOKEN_SECRET — HMAC secret for job_token generation (never stored)
12
+ # OPERATOR_SECRET_KEY — HMAC secret for operator dispute auth
13
+ #
14
+ # Optional env vars:
15
+ # OPTIMISTIC_WINDOW_HOURS — hours before auto-complete fires (default: 48)
16
+ # MULTISIG_THRESHOLD_SPECKS — above this value, multisig required (default: 1000000)
17
+ #
10
18
  # Register with Masumi after starting:
11
19
  # curl -X POST http://localhost:3001/api/v1/registry \
12
20
  # -H "token: $MASUMI_API_KEY" \
@@ -16,7 +24,7 @@
16
24
  # "description": "Anonymous community bounty board — pool shielded NIGHT, hire AI agents, get ZK receipts",
17
25
  # "apiBaseUrl": "http://your-server:8090",
18
26
  # "capabilityName": "nightpay-bounties",
19
- # "capabilityVersion": "0.1.0",
27
+ # "capabilityVersion": "0.2.0",
20
28
  # "pricingUnit": "lovelace",
21
29
  # "pricingQuantity": "0",
22
30
  # "network": "Preprod",
@@ -31,6 +39,11 @@ PORT="${1:-8090}"
31
39
  DATA_DIR="${DATA_DIR:-${HOME}/.nightpay}"
32
40
  DB_PATH="${DATA_DIR}/jobs.db"
33
41
 
42
+ JOB_TOKEN_SECRET="${JOB_TOKEN_SECRET:?SECURITY: Set JOB_TOKEN_SECRET env var}"
43
+ OPERATOR_SECRET_KEY="${OPERATOR_SECRET_KEY:?SECURITY: Set OPERATOR_SECRET_KEY env var}"
44
+ OPTIMISTIC_WINDOW_HOURS="${OPTIMISTIC_WINDOW_HOURS:-48}"
45
+ MULTISIG_THRESHOLD_SPECKS="${MULTISIG_THRESHOLD_SPECKS:-1000000}"
46
+
34
47
  mkdir -p "$DATA_DIR"
35
48
  chmod 700 "$DATA_DIR"
36
49
 
@@ -39,13 +52,42 @@ command -v python3 >/dev/null 2>&1 || { echo "python3 required"; exit 1; }
39
52
  echo "nightpay MIP-003 service starting on port $PORT..."
40
53
 
41
54
  python3 -c "
42
- import http.server, json, uuid, sys, sqlite3, threading
43
- from datetime import datetime, timezone
55
+ import http.server, json, uuid, sys, sqlite3, threading, hmac, hashlib, re, os
56
+ from datetime import datetime, timezone, timedelta
57
+ from urllib.parse import urlparse, parse_qs
58
+
59
+ PORT = int(sys.argv[1])
60
+ DB_PATH = sys.argv[2]
61
+ JOB_TOKEN_SECRET = sys.argv[3]
62
+ OPERATOR_SECRET_KEY = sys.argv[4]
63
+ OPTIMISTIC_WINDOW_HOURS = int(sys.argv[5])
64
+ MULTISIG_THRESHOLD_SPECKS = int(sys.argv[6])
65
+
66
+ KNOWN_STATUSES = ('running', 'awaiting_approval', 'multisig_pending', 'disputed', 'completed')
67
+
68
+ # ─── Security helpers ─────────────────────────────────────────────────────────
69
+
70
+ def make_job_token(job_id):
71
+ # domain-separated — cannot be reused as any other HMAC in the system
72
+ msg = f'nightpay-job-token-v1:{job_id}'
73
+ return hmac.new(JOB_TOKEN_SECRET.encode(), msg.encode(), hashlib.sha256).hexdigest()
74
+
75
+ def verify_job_token(job_id, token):
76
+ return hmac.compare_digest(make_job_token(job_id), token)
44
77
 
45
- PORT = int(sys.argv[1])
46
- DB_PATH = sys.argv[2]
78
+ def verify_work_reveal(work_commit, work, nonce):
79
+ # SECURITY: domain-separated reveal hash — matches gateway.sh convention
80
+ revealed = hashlib.sha256(f'nightpay-work-reveal-v1:{work}:{nonce}'.encode()).hexdigest()
81
+ return hmac.compare_digest(revealed, work_commit)
82
+
83
+ def verify_operator_sig(job_id, reason, sig):
84
+ # Operator signs: HMAC(OPERATOR_SECRET_KEY, 'dispute:{job_id}:{reason}')
85
+ msg = f'dispute:{job_id}:{reason}'
86
+ expected = hmac.new(OPERATOR_SECRET_KEY.encode(), msg.encode(), hashlib.sha256).hexdigest()
87
+ return hmac.compare_digest(expected, sig)
88
+
89
+ # ─── SQLite ───────────────────────────────────────────────────────────────────
47
90
 
48
- # Thread-local connections for SQLite (one per request handler thread)
49
91
  local = threading.local()
50
92
 
51
93
  def get_db():
@@ -61,18 +103,40 @@ conn = sqlite3.connect(DB_PATH)
61
103
  conn.execute('PRAGMA journal_mode=WAL')
62
104
  conn.executescript('''
63
105
  CREATE TABLE IF NOT EXISTS jobs (
64
- job_id TEXT PRIMARY KEY,
65
- status TEXT NOT NULL DEFAULT \"running\",
66
- input_data TEXT,
67
- extra_input TEXT,
68
- result TEXT,
69
- started_at TEXT NOT NULL,
70
- updated_at TEXT NOT NULL
106
+ job_id TEXT PRIMARY KEY,
107
+ status TEXT NOT NULL DEFAULT \"running\",
108
+ input_data TEXT,
109
+ extra_input TEXT,
110
+ result TEXT,
111
+ started_at TEXT NOT NULL,
112
+ updated_at TEXT NOT NULL,
113
+ work_commit TEXT,
114
+ amount_specks INTEGER,
115
+ approved_at TEXT,
116
+ dispute_reason TEXT,
117
+ approvals TEXT
71
118
  );
72
- CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs(status);
119
+ CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs(status);
120
+ CREATE INDEX IF NOT EXISTS idx_jobs_approved ON jobs(approved_at)
121
+ WHERE approved_at IS NOT NULL;
73
122
  ''')
123
+ # Idempotent migration for existing DBs (ALTER TABLE is safe — ignores dup column)
124
+ for col_def in [
125
+ 'ALTER TABLE jobs ADD COLUMN work_commit TEXT',
126
+ 'ALTER TABLE jobs ADD COLUMN amount_specks INTEGER',
127
+ 'ALTER TABLE jobs ADD COLUMN approved_at TEXT',
128
+ 'ALTER TABLE jobs ADD COLUMN dispute_reason TEXT',
129
+ 'ALTER TABLE jobs ADD COLUMN approvals TEXT',
130
+ ]:
131
+ try:
132
+ conn.execute(col_def)
133
+ except Exception:
134
+ pass # column already exists — idempotent
135
+ conn.commit()
74
136
  conn.close()
75
137
 
138
+ # ─── HTTP handler ─────────────────────────────────────────────────────────────
139
+
76
140
  class MIP003Handler(http.server.BaseHTTPRequestHandler):
77
141
  def log_message(self, fmt, *args):
78
142
  print(f'[nightpay] {args[0]}')
@@ -85,10 +149,20 @@ class MIP003Handler(http.server.BaseHTTPRequestHandler):
85
149
  self.end_headers()
86
150
  self.wfile.write(body)
87
151
 
152
+ def _read_body(self):
153
+ length = int(self.headers.get('Content-Length', 0))
154
+ return json.loads(self.rfile.read(length)) if length else {}
155
+
156
+ def _validate_job_id(self, job_id):
157
+ # Mirrors validate_job_id in gateway.sh — prevents path traversal
158
+ return bool(re.match(r'^[a-zA-Z0-9_-]{1,128}$', job_id))
159
+
160
+ # ── GET ──────────────────────────────────────────────────────────────────
161
+
88
162
  def do_GET(self):
89
163
  if self.path == '/availability':
90
164
  db = get_db()
91
- total = db.execute('SELECT COUNT(*) FROM jobs').fetchone()[0]
165
+ total = db.execute('SELECT COUNT(*) FROM jobs').fetchone()[0]
92
166
  active = db.execute('SELECT COUNT(*) FROM jobs WHERE status = ?', ('running',)).fetchone()[0]
93
167
  self.respond(200, {
94
168
  'status': 'available',
@@ -107,6 +181,10 @@ class MIP003Handler(http.server.BaseHTTPRequestHandler):
107
181
  'amount_specks': {
108
182
  'type': 'integer',
109
183
  'description': 'Bounty amount in NIGHT specks'
184
+ },
185
+ 'work_commit': {
186
+ 'type': 'string',
187
+ 'description': 'sha256(nightpay-work-reveal-v1:{work}:{nonce}) — commit before reveal'
110
188
  }
111
189
  },
112
190
  'required': ['description', 'amount_specks']
@@ -114,6 +192,9 @@ class MIP003Handler(http.server.BaseHTTPRequestHandler):
114
192
 
115
193
  elif self.path.startswith('/status/'):
116
194
  job_id = self.path.split('/')[-1]
195
+ if not self._validate_job_id(job_id):
196
+ self.respond(400, {'error': 'invalid job_id format'})
197
+ return
117
198
  db = get_db()
118
199
  row = db.execute('SELECT * FROM jobs WHERE job_id = ?', (job_id,)).fetchone()
119
200
  if not row:
@@ -128,37 +209,201 @@ class MIP003Handler(http.server.BaseHTTPRequestHandler):
128
209
  job['result'] = json.loads(job['result'])
129
210
  self.respond(200, job)
130
211
 
212
+ elif self.path.startswith('/jobs'):
213
+ # GET /jobs?status=<value> — used by optimistic-sweep in gateway.sh
214
+ parsed = urlparse(self.path)
215
+ params = parse_qs(parsed.query)
216
+ status_filter = params.get('status', [None])[0]
217
+
218
+ # SECURITY: whitelist status values — prevents SQL injection via status param
219
+ if status_filter and status_filter not in KNOWN_STATUSES:
220
+ self.respond(400, {'error': f'unknown status filter: {status_filter}'})
221
+ return
222
+
223
+ db = get_db()
224
+ if status_filter:
225
+ rows = db.execute(
226
+ 'SELECT job_id, status, approved_at, amount_specks, input_data FROM jobs WHERE status = ? LIMIT 500',
227
+ (status_filter,)
228
+ ).fetchall()
229
+ else:
230
+ rows = db.execute(
231
+ 'SELECT job_id, status, approved_at, amount_specks, input_data FROM jobs LIMIT 500'
232
+ ).fetchall()
233
+
234
+ jobs = []
235
+ for r in rows:
236
+ j = dict(r)
237
+ if j.get('input_data'):
238
+ try:
239
+ j['input_data'] = json.loads(j['input_data'])
240
+ except Exception:
241
+ pass
242
+ jobs.append(j)
243
+
244
+ self.respond(200, {'jobs': jobs})
245
+
131
246
  else:
132
247
  self.respond(404, {'error': 'not found'})
133
248
 
249
+ # ── POST ─────────────────────────────────────────────────────────────────
250
+
134
251
  def do_POST(self):
135
- length = int(self.headers.get('Content-Length', 0))
136
- body = json.loads(self.rfile.read(length)) if length else {}
252
+ body = self._read_body()
137
253
 
138
254
  if self.path == '/start_job':
255
+ # ── Validate optional work_commit ─────────────────────────────
256
+ work_commit = body.get('work_commit')
257
+ if work_commit is not None:
258
+ if not re.match(r'^[0-9a-f]{64}$', str(work_commit)):
259
+ self.respond(400, {'error': 'work_commit must be 64-char lowercase hex sha256'})
260
+ return
261
+
262
+ # ── Validate optional amount_specks ───────────────────────────
263
+ amount_specks = body.get('amount_specks')
264
+ if amount_specks is not None:
265
+ try:
266
+ amount_specks = int(amount_specks)
267
+ if amount_specks < 0:
268
+ raise ValueError
269
+ except (ValueError, TypeError):
270
+ self.respond(400, {'error': 'amount_specks must be a non-negative integer'})
271
+ return
272
+
139
273
  job_id = str(uuid.uuid4())
140
- now = datetime.now(timezone.utc).isoformat()
141
- db = get_db()
274
+ now = datetime.now(timezone.utc).isoformat()
275
+ db = get_db()
142
276
  db.execute(
143
- 'INSERT INTO jobs(job_id, status, input_data, started_at, updated_at) VALUES (?, ?, ?, ?, ?)',
144
- (job_id, 'running', json.dumps(body.get('input_data', {})), now, now)
277
+ '''INSERT INTO jobs(job_id, status, input_data, work_commit, amount_specks, started_at, updated_at)
278
+ VALUES (?, ?, ?, ?, ?, ?, ?)''',
279
+ (job_id, 'running', json.dumps(body.get('input_data', {})),
280
+ work_commit, amount_specks, now, now)
145
281
  )
146
282
  db.commit()
147
- self.respond(200, {'job_id': job_id, 'status': 'running'})
283
+
284
+ # SECURITY: job_token is ephemeral — derived on demand, never stored
285
+ job_token = make_job_token(job_id)
286
+
287
+ self.respond(200, {
288
+ 'job_id': job_id,
289
+ 'job_token': job_token,
290
+ 'status': 'running'
291
+ })
148
292
 
149
293
  elif self.path.startswith('/provide_input/'):
150
294
  job_id = self.path.split('/')[-1]
151
- db = get_db()
295
+
296
+ # SECURITY: validate job_id format
297
+ if not self._validate_job_id(job_id):
298
+ self.respond(400, {'error': 'invalid job_id format'})
299
+ return
300
+
301
+ # SECURITY: require valid job_token — hard cutover, no legacy fallback
302
+ auth_header = self.headers.get('Authorization', '')
303
+ if not auth_header.startswith('Bearer '):
304
+ self.respond(401, {'error': 'Authorization: Bearer <job_token> required'})
305
+ return
306
+ provided_token = auth_header[len('Bearer '):]
307
+ if not verify_job_token(job_id, provided_token):
308
+ self.respond(403, {'error': 'invalid job_token'})
309
+ return
310
+
311
+ db = get_db()
312
+ row = db.execute(
313
+ 'SELECT work_commit, amount_specks, status FROM jobs WHERE job_id = ?',
314
+ (job_id,)
315
+ ).fetchone()
316
+ if not row:
317
+ self.respond(404, {'error': 'job not found'})
318
+ return
319
+
320
+ # SECURITY: only accept input while job is still running
321
+ if row['status'] != 'running':
322
+ self.respond(409, {'error': f'job is not running (status: {row[\"status\"]})'})
323
+ return
324
+
325
+ # SECURITY: commit-reveal verification (skipped if no work_commit — backward compat)
326
+ work_commit = row['work_commit']
327
+ if work_commit is not None:
328
+ work = body.get('work')
329
+ nonce = body.get('work_nonce')
330
+ if not work or not nonce:
331
+ self.respond(400, {'error': 'work and work_nonce required for commit-reveal jobs'})
332
+ return
333
+ if not verify_work_reveal(work_commit, str(work), str(nonce)):
334
+ self.respond(400, {'error': 'commit-reveal mismatch: sha256(nightpay-work-reveal-v1:work:nonce) != work_commit'})
335
+ return
336
+
337
+ now = datetime.now(timezone.utc)
338
+ amount_specks = row['amount_specks'] or 0
339
+
340
+ # Determine delivery path
341
+ if amount_specks >= MULTISIG_THRESHOLD_SPECKS:
342
+ next_status = 'multisig_pending'
343
+ approved_at = None # no auto-approve until M-of-N collected
344
+ else:
345
+ next_status = 'awaiting_approval'
346
+ approved_at = (now + timedelta(hours=OPTIMISTIC_WINDOW_HOURS)).isoformat()
347
+
348
+ db.execute(
349
+ '''UPDATE jobs
350
+ SET extra_input = ?, status = ?, approved_at = ?, updated_at = ?
351
+ WHERE job_id = ?''',
352
+ (json.dumps(body), next_status, approved_at, now.isoformat(), job_id)
353
+ )
354
+ db.commit()
355
+ self.respond(200, {
356
+ 'status': next_status,
357
+ 'approved_at': approved_at,
358
+ 'message': 'work accepted, optimistic window started'
359
+ if next_status == 'awaiting_approval'
360
+ else 'work accepted, awaiting multisig approval'
361
+ })
362
+
363
+ elif self.path.startswith('/dispute/'):
364
+ job_id = self.path.split('/')[-1]
365
+
366
+ if not self._validate_job_id(job_id):
367
+ self.respond(400, {'error': 'invalid job_id format'})
368
+ return
369
+
370
+ reason = str(body.get('reason', 'no reason given'))[:500]
371
+
372
+ # SECURITY: either the job_token holder OR the operator can dispute
373
+ auth_header = self.headers.get('Authorization', '')
374
+ op_sig_header = self.headers.get('X-Operator-Sig', '')
375
+ authorized = False
376
+
377
+ if auth_header.startswith('Bearer '):
378
+ token = auth_header[len('Bearer '):]
379
+ if verify_job_token(job_id, token):
380
+ authorized = True
381
+
382
+ if not authorized and op_sig_header:
383
+ if verify_operator_sig(job_id, reason, op_sig_header):
384
+ authorized = True
385
+
386
+ if not authorized:
387
+ self.respond(403, {'error': 'dispute requires valid job_token or X-Operator-Sig'})
388
+ return
389
+
390
+ db = get_db()
152
391
  now = datetime.now(timezone.utc).isoformat()
153
392
  cur = db.execute(
154
- 'UPDATE jobs SET extra_input = ?, updated_at = ? WHERE job_id = ?',
155
- (json.dumps(body), now, job_id)
393
+ '''UPDATE jobs SET status = 'disputed', dispute_reason = ?, updated_at = ?
394
+ WHERE job_id = ? AND status = 'awaiting_approval' ''',
395
+ (reason, now, job_id)
156
396
  )
157
397
  if cur.rowcount == 0:
158
- self.respond(404, {'error': 'job not found'})
398
+ # Check if job exists at all
399
+ exists = db.execute('SELECT status FROM jobs WHERE job_id = ?', (job_id,)).fetchone()
400
+ if not exists:
401
+ self.respond(404, {'error': 'job not found'})
402
+ else:
403
+ self.respond(409, {'error': f'job cannot be disputed in current state (status: {exists[\"status\"]})'})
159
404
  else:
160
405
  db.commit()
161
- self.respond(200, {'status': 'input_received'})
406
+ self.respond(200, {'status': 'disputed', 'reason': reason})
162
407
 
163
408
  else:
164
409
  self.respond(404, {'error': 'not found'})
@@ -169,6 +414,7 @@ class ThreadedHTTPServer(http.server.ThreadingHTTPServer):
169
414
  httpd = ThreadedHTTPServer(('0.0.0.0', PORT), MIP003Handler)
170
415
  print(f'[nightpay] MIP-003 threaded service ready on port {PORT}')
171
416
  print(f'[nightpay] DB: {DB_PATH}')
172
- print(f'[nightpay] Endpoints: /availability, /input_schema, /start_job, /status/<id>, /provide_input/<id>')
417
+ print(f'[nightpay] Optimistic window: {OPTIMISTIC_WINDOW_HOURS}h | Multisig threshold: {MULTISIG_THRESHOLD_SPECKS} specks')
418
+ print(f'[nightpay] Endpoints: /availability /input_schema /start_job /status/<id> /provide_input/<id> /dispute/<id> /jobs')
173
419
  httpd.serve_forever()
174
- " "$PORT" "$DB_PATH"
420
+ " "$PORT" "$DB_PATH" "$JOB_TOKEN_SECRET" "$OPERATOR_SECRET_KEY" "$OPTIMISTIC_WINDOW_HOURS" "$MULTISIG_THRESHOLD_SPECKS"