Files
archy/docker/justworks/business/business_security.py
T
yaya 94899e69d3 feat: integrate Just Works app and native business setup
Preserve hosted website UX inside Archipelago, add isolated Business and owner-authorized native wallet setup, and include deployment handoff. Based on main with native signer import already accepted.
2026-10-08 19:39:38 +01:00

227 lines
10 KiB
Python

"""Additive Business authorization; never an identity provider or key store."""
import hashlib
import ipaddress
import os
import re
import secrets
import time
from http.cookies import SimpleCookie, CookieError
from collections import OrderedDict
COOKIE = "jwb_session"
PRIVILEGED_GET = {"/api/dashboard", "/api/pos"}
PRIVILEGED_POST = {"/api/config", "/api/business-config", "/api/business-profile"}
RATE_BUCKETS = OrderedDict()
def canonical_npub(value):
"""Validate NIP-19 bech32 checksum and the 32-byte public key length."""
alphabet = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
if not isinstance(value, str) or len(value) != 63 or not value.startswith("npub1"):
raise ValueError("invalid npub")
try:
data = [alphabet.index(char) for char in value[5:]]
except ValueError:
raise ValueError("invalid npub") from None
checksum = 1
expanded = [ord(c) >> 5 for c in "npub"] + [0] + [ord(c) & 31 for c in "npub"]
for item in expanded + data:
high = checksum >> 25
checksum = ((checksum & 0x1ffffff) << 5) ^ item
for bit, generator in enumerate((0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3)):
if (high >> bit) & 1: checksum ^= generator
if checksum != 1 or data[-7] & 15:
raise ValueError("invalid npub")
return value
def client_ip(remote, headers):
"""Trust one Nginx-overwritten X-Real-IP only from explicitly enabled loopback."""
def normalize(value):
if not isinstance(value, str) or '%' in value:
raise ValueError("invalid address")
address = ipaddress.ip_address(value.strip())
if isinstance(address, ipaddress.IPv6Address) and address.ipv4_mapped:
return address.ipv4_mapped
return address
try:
peer = normalize(remote)
except ValueError:
return "unknown"
if os.environ.get("JWB_TRUST_PROXY", "0") != "1" or not peer.is_loopback:
return str(peer)
# HTTPMessage permits duplicate headers; a normal dict is used by unit tests.
values = headers.get_all("X-Real-IP", []) if hasattr(headers, "get_all") else [headers.get("X-Real-IP", "")]
if len(values) == 1:
try:
return str(normalize(values[0]))
except ValueError:
pass
return str(peer)
def rate_scope(path, values):
# Table flows ignore caller-supplied npub; opaque tokens never enter bucket keys.
if path in {"/api/orders", "/api/payments"}:
for field in ("token", "order_id", "payment_id"):
value = values.get(field)
if isinstance(value, str) and value:
return field + ":" + token_hash(value)
return "unscoped"
try:
return "merchant:" + canonical_npub(values.get("npub", ""))
except ValueError:
return "unscoped"
def rate_allowed(remote, path, values=None, headers=None):
# Caller holds the process lock. Keep only a digest of the normalized peer.
timestamp = int(time.time()) // 60
peer = client_ip(remote, headers or {})
key = (token_hash(peer), path, rate_scope(path, values or {}))
prior_window, count = RATE_BUCKETS.pop(key, (timestamp, 0))
count = count + 1 if prior_window == timestamp else 1
RATE_BUCKETS[key] = (timestamp, count)
while len(RATE_BUCKETS) > 2048: RATE_BUCKETS.popitem(last=False)
return count <= (20 if path == "/api/business-owner-login" else 120)
def mode():
value = os.environ.get("JWB_AUTH_MODE", "shadow")
if value not in {"off", "shadow", "enforce"}:
raise RuntimeError("JWB_AUTH_MODE must be off, shadow or enforce")
return value
def migrate(conn):
conn.executescript("""
CREATE TABLE IF NOT EXISTS business_sessions (
token_hash TEXT PRIMARY KEY, npub TEXT NOT NULL, created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL);
CREATE INDEX IF NOT EXISTS business_sessions_expiry ON business_sessions(expires_at);
CREATE TABLE IF NOT EXISTS business_audit (
id INTEGER PRIMARY KEY AUTOINCREMENT, occurred_at INTEGER NOT NULL,
event TEXT NOT NULL, route TEXT NOT NULL, merchant TEXT NOT NULL,
actor TEXT NOT NULL, reason TEXT NOT NULL);
""")
import business_rollout
business_rollout.migrate(conn)
def audit(conn, event, route="", merchant="", actor="", reason=""):
# Only fixed event/reason codes and public identities; never bodies, cookies or exceptions.
timestamp = int(time.time())
route = re.sub(r"/api/(orders|payments|bookings)/[^/]+/", r"/api/\1/:id/", route)
merchant = merchant if re.fullmatch(r"npub1[023456789acdefghjklmnpqrstuvwxyz]{58}|localpub-demo", merchant) else ""
actor = actor if re.fullmatch(r"npub1[023456789acdefghjklmnpqrstuvwxyz]{58}", actor) else ""
if event == "would_deny" and conn.execute(
"SELECT 1 FROM business_audit WHERE event=? AND route=? AND merchant=? AND reason=? AND occurred_at>? LIMIT 1",
(event, route, merchant, reason, timestamp - 60)).fetchone():
return
conn.execute("INSERT INTO business_audit(occurred_at,event,route,merchant,actor,reason) VALUES (?,?,?,?,?,?)",
(timestamp, event, route, merchant, actor, reason))
conn.execute("DELETE FROM business_audit WHERE id NOT IN (SELECT id FROM business_audit ORDER BY id DESC LIMIT 10000)")
conn.execute("DELETE FROM business_sessions WHERE expires_at<=?", (timestamp,))
conn.execute("DELETE FROM business_session_audiences WHERE token_hash NOT IN (SELECT token_hash FROM business_sessions)")
conn.commit()
def token_hash(token):
return hashlib.sha256(token.encode()).hexdigest()
def session(conn, headers):
try:
cookie = SimpleCookie()
cookie.load(headers.get("Cookie", ""))
token = cookie[COOKIE].value
if not re.fullmatch(r"[A-Za-z0-9_-]{43}", token):
return None
row = conn.execute("SELECT s.npub,s.expires_at FROM business_sessions s LEFT JOIN business_session_audiences a ON a.token_hash=s.token_hash WHERE s.token_hash=? AND s.expires_at>? AND (a.audience=? OR a.audience IS NULL)",
(token_hash(token), int(time.time()), headers.get("Host", "").lower())).fetchone()
return dict(row) if row else None
except (KeyError, ValueError, CookieError):
return None
def revoke(conn, headers, all_sessions=False):
current = session(conn, headers)
if all_sessions and current:
conn.execute("DELETE FROM business_sessions WHERE npub=?", (current["npub"],))
else:
cookie = SimpleCookie()
try:
cookie.load(headers.get("Cookie", ""))
conn.execute("DELETE FROM business_sessions WHERE token_hash=?", (token_hash(cookie[COOKIE].value),))
except (KeyError, ValueError, CookieError):
pass
conn.execute("DELETE FROM business_session_audiences WHERE token_hash NOT IN (SELECT token_hash FROM business_sessions)")
conn.commit()
return current
def cookie_header(token="", max_age=0):
# Local HTTP opt-out is operator configuration, never inferred from attacker-controlled Host.
secure = "" if os.environ.get("JWB_COOKIE_SECURE", "1") == "0" else "; Secure"
return f"{COOKIE}={token}; Path=/; HttpOnly; SameSite=Lax; Max-Age={max_age}{secure}"
def issue(conn, npub, headers):
npub = canonical_npub(npub)
revoke(conn, headers)
token = secrets.token_urlsafe(32)
ttl = max(60, min(86400, int(os.environ.get("JWB_SESSION_TTL", "28800"))))
expires = int(time.time()) + ttl
conn.execute("INSERT INTO business_sessions(token_hash,npub,created_at,expires_at) VALUES (?,?,?,?)", (token_hash(token), npub, int(time.time()), expires))
conn.execute("INSERT INTO business_session_audiences VALUES (?,?)", (token_hash(token), headers.get("Host", "").lower()))
conn.execute("DELETE FROM business_sessions WHERE npub=? AND token_hash NOT IN (SELECT token_hash FROM business_sessions WHERE npub=? ORDER BY created_at DESC,rowid DESC LIMIT 10)", (npub, npub))
conn.execute("DELETE FROM business_session_audiences WHERE token_hash NOT IN (SELECT token_hash FROM business_sessions)")
conn.commit()
return cookie_header(token, ttl), expires
def target(conn, method, path, values):
from business_rollout import legacy_merchant
if method == "GET" and path in PRIVILEGED_GET:
return legacy_merchant(conn) if path == "/api/dashboard" else str(values.get("npub", "")).strip()
if method == "POST" and path in PRIVILEGED_POST:
return legacy_merchant(conn) if path == "/api/config" else str(values.get("npub", "")).strip()
match = re.fullmatch(r"/api/(orders|payments|bookings)/([^/]+)/(status|acknowledge)", path)
if method == "POST" and match:
table, identity, _ = match.groups()
row = conn.execute(f"SELECT merchant_id FROM {table} WHERE id=?", (identity,)).fetchone()
return row[0] if row else ""
return None
def same_origin(headers):
from urllib.parse import urlparse
origin = headers.get("Origin")
if headers.get("Sec-Fetch-Site") == "cross-site":
return False
if not origin:
return True # non-browser clients; browser JSON requests require CORS preflight
parsed = urlparse(origin)
return parsed.scheme in {"http", "https"} and parsed.netloc.lower() == headers.get("Host", "").lower()
def authorize(conn, method, path, values, headers):
merchant = target(conn, method, path, values)
if merchant is None:
return True, None
current = session(conn, headers)
reason = ""
if not current:
reason = "missing_session"
elif current["npub"] != merchant:
reason = "tenant_mismatch"
elif method != "GET" and not same_origin(headers):
reason = "cross_origin"
from business_rollout import effective_mode
selected = effective_mode(conn, method, path, merchant)
if reason and selected != "off":
audit(conn, "denied" if selected == "enforce" else "would_deny", path, merchant,
current["npub"] if current else "", reason)
return not reason or selected != "enforce", (merchant, current["npub"] if current else "")