camada 0.2.0__py3-none-any.whl
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.
- camada/__init__.py +47 -0
- camada/asgi.py +149 -0
- camada/beacon_js.py +6 -0
- camada/challenge/__init__.py +0 -0
- camada/challenge/format.py +113 -0
- camada/challenge/page.py +104 -0
- camada/challenge/verify.py +75 -0
- camada/config.py +59 -0
- camada/constants.py +19 -0
- camada/django.py +153 -0
- camada/engine.py +383 -0
- camada/env.py +46 -0
- camada/events/__init__.py +0 -0
- camada/events/build.py +89 -0
- camada/events/queue.py +136 -0
- camada/fastapi.py +38 -0
- camada/flask.py +54 -0
- camada/guarded.py +21 -0
- camada/ip.py +86 -0
- camada/ipparse.py +88 -0
- camada/redact.py +62 -0
- camada/snapshot/__init__.py +0 -0
- camada/snapshot/client.py +167 -0
- camada/snapshot/match.py +206 -0
- camada/snapshot/parse.py +352 -0
- camada/transport.py +57 -0
- camada/version.py +6 -0
- camada/wsgi.py +158 -0
- camada-0.2.0.dist-info/METADATA +215 -0
- camada-0.2.0.dist-info/RECORD +32 -0
- camada-0.2.0.dist-info/WHEEL +4 -0
- camada-0.2.0.dist-info/licenses/LICENSE +21 -0
camada/__init__.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""camada for Python: inline enforcement of your tenant snapshot (ordered custom rules, then
|
|
2
|
+
allow, block, challenge), a first-party proof-of-work challenge and beacon, app-context events,
|
|
3
|
+
and batched wire events shipped off the request path. Fails open by design.
|
|
4
|
+
|
|
5
|
+
Quickstart (env: CAMADA_KEY, plus CAMADA_INGEST_URL in dev):
|
|
6
|
+
|
|
7
|
+
# FastAPI / Starlette
|
|
8
|
+
from camada.fastapi import CamadaMiddleware
|
|
9
|
+
app.add_middleware(CamadaMiddleware)
|
|
10
|
+
|
|
11
|
+
# Django: MIDDLEWARE = ["camada.django.CamadaMiddleware", ...]
|
|
12
|
+
# Flask: camada.flask.init_app(app)
|
|
13
|
+
# Any ASGI / WSGI app: camada.asgi.CamadaASGI(app) / camada.wsgi.CamadaWSGI(app)
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from collections.abc import Mapping
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from .engine import Answer, Camada, Passed, Req, create_camada, engine_of
|
|
21
|
+
from .version import SDK_ID, __version__
|
|
22
|
+
|
|
23
|
+
__all__ = ["Answer", "Camada", "Passed", "Req", "SDK_ID", "__version__", "configure", "create_camada", "engine_for", "get_default"]
|
|
24
|
+
|
|
25
|
+
_default: Camada | None = None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def get_default(**opts: Any) -> Camada:
|
|
29
|
+
"""The lazy singleton wired from the environment on first use (what the integrations share)."""
|
|
30
|
+
global _default
|
|
31
|
+
if _default is None:
|
|
32
|
+
_default = create_camada(**opts)
|
|
33
|
+
return _default
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def engine_for(ctx: Mapping[str, Any] | None) -> Camada:
|
|
37
|
+
"""The engine that produced a request context, else the default: what the integrations resolve through."""
|
|
38
|
+
return engine_of(ctx) or get_default()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def configure(**opts: Any) -> Camada:
|
|
42
|
+
"""Replaces the default engine (stopping the old one) — for tests and explicit wiring."""
|
|
43
|
+
global _default
|
|
44
|
+
if _default is not None:
|
|
45
|
+
_default.stop()
|
|
46
|
+
_default = create_camada(**opts)
|
|
47
|
+
return _default
|
camada/asgi.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# ASGI3 middleware: the first-class tier (scope["headers"] keeps the wire's header order), and
|
|
2
|
+
# what FastAPI / Starlette sit on. A pure ASGI callable — never Starlette's BaseHTTPMiddleware,
|
|
3
|
+
# which buffers streaming responses. Only http scopes are touched; lifespan and websocket pass.
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from collections.abc import Awaitable, Callable, MutableMapping
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from .engine import Answer, Camada, Passed, Req
|
|
10
|
+
from .guarded import log_rate_limited
|
|
11
|
+
|
|
12
|
+
Scope = MutableMapping[str, Any]
|
|
13
|
+
Message = MutableMapping[str, Any]
|
|
14
|
+
Receive = Callable[[], Awaitable[Message]]
|
|
15
|
+
Send = Callable[[Message], Awaitable[None]]
|
|
16
|
+
ASGIApp = Callable[[Scope, Receive, Send], Awaitable[None]]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def req_from_scope(scope: Scope) -> Req:
|
|
20
|
+
headers = [(k.decode("latin-1").lower(), v.decode("latin-1")) for k, v in scope.get("headers") or ()]
|
|
21
|
+
query = (scope.get("query_string") or b"").decode("latin-1")
|
|
22
|
+
client, server = scope.get("client"), scope.get("server")
|
|
23
|
+
host = next((v for k, v in headers if k == "host"), "") or (str(server[0]) if server else "") # the host name, never the port
|
|
24
|
+
return Req(
|
|
25
|
+
method=str(scope.get("method") or "GET"),
|
|
26
|
+
path=str(scope.get("path") or "/"),
|
|
27
|
+
query="?" + query if query else "",
|
|
28
|
+
host=host,
|
|
29
|
+
http_version=scope.get("http_version"),
|
|
30
|
+
peer=client[0] if client else None,
|
|
31
|
+
https=scope.get("scheme") == "https",
|
|
32
|
+
headers=headers,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
async def read_body(scope: Scope, receive: Receive, limit: int) -> tuple[bytes | None, Receive]:
|
|
37
|
+
"""At most `limit` bytes (None when the declared or actual size exceeds it), plus a receive()
|
|
38
|
+
that replays what was consumed so an app the request falls through to still sees its body."""
|
|
39
|
+
try:
|
|
40
|
+
declared = int(next((v for k, v in scope.get("headers") or () if k == b"content-length"), b"0") or 0)
|
|
41
|
+
except ValueError:
|
|
42
|
+
declared = 0
|
|
43
|
+
chunks: list[Message] = []
|
|
44
|
+
if declared > limit:
|
|
45
|
+
return None, _replay(chunks, receive)
|
|
46
|
+
size = 0
|
|
47
|
+
over = False
|
|
48
|
+
while True:
|
|
49
|
+
m = await receive()
|
|
50
|
+
chunks.append(m)
|
|
51
|
+
if m["type"] != "http.request":
|
|
52
|
+
break
|
|
53
|
+
size += len(m.get("body") or b"")
|
|
54
|
+
if size > limit:
|
|
55
|
+
over = True
|
|
56
|
+
break
|
|
57
|
+
if not m.get("more_body"):
|
|
58
|
+
break
|
|
59
|
+
body = None if over else b"".join(bytes(m.get("body") or b"") for m in chunks if m["type"] == "http.request")
|
|
60
|
+
return body, _replay(chunks, receive)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _replay(chunks: list[Message], receive: Receive) -> Receive:
|
|
64
|
+
queued = list(chunks)
|
|
65
|
+
|
|
66
|
+
async def replay() -> Message:
|
|
67
|
+
return queued.pop(0) if queued else await receive()
|
|
68
|
+
|
|
69
|
+
return replay
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class CamadaASGI:
|
|
73
|
+
"""`app.add_middleware(CamadaASGI)` (Starlette/FastAPI) or `app = CamadaASGI(app)`. Without an
|
|
74
|
+
engine it wires the lazy default from the environment on first request."""
|
|
75
|
+
|
|
76
|
+
def __init__(self, app: ASGIApp, engine: Camada | None = None, **opts: Any) -> None:
|
|
77
|
+
self.app = app
|
|
78
|
+
self._engine = engine
|
|
79
|
+
self._opts = opts
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def engine(self) -> Camada:
|
|
83
|
+
if self._engine is None:
|
|
84
|
+
from . import get_default
|
|
85
|
+
|
|
86
|
+
self._engine = get_default(**self._opts)
|
|
87
|
+
return self._engine
|
|
88
|
+
|
|
89
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
90
|
+
if scope.get("type") != "http":
|
|
91
|
+
await self.app(scope, receive, send)
|
|
92
|
+
return
|
|
93
|
+
eng = self.engine
|
|
94
|
+
try:
|
|
95
|
+
req = req_from_scope(scope)
|
|
96
|
+
limit = eng.wants_body(req.method, req.path)
|
|
97
|
+
body = None
|
|
98
|
+
if limit is not None:
|
|
99
|
+
body, receive = await read_body(scope, receive, limit)
|
|
100
|
+
except Exception as err:
|
|
101
|
+
log_rate_limited(err)
|
|
102
|
+
await self.app(scope, receive, send)
|
|
103
|
+
return
|
|
104
|
+
result = eng.handle(req, body)
|
|
105
|
+
if isinstance(result, Answer):
|
|
106
|
+
headers = [(k.encode("latin-1"), v.encode("latin-1")) for k, v in result.headers]
|
|
107
|
+
headers.append((b"content-length", str(len(result.body)).encode()))
|
|
108
|
+
await send({"type": "http.response.start", "status": result.status, "headers": headers})
|
|
109
|
+
await send({"type": "http.response.body", "body": result.body})
|
|
110
|
+
return
|
|
111
|
+
await self._run(scope, receive, send, result)
|
|
112
|
+
|
|
113
|
+
async def _run(self, scope: Scope, receive: Receive, send: Send, p: Passed) -> None:
|
|
114
|
+
if p.ctx is not None:
|
|
115
|
+
scope["camada"] = p.ctx
|
|
116
|
+
state = {"status": 500, "fired": False}
|
|
117
|
+
|
|
118
|
+
def finish(status: int) -> None:
|
|
119
|
+
if state["fired"] or not p.on_finish:
|
|
120
|
+
return
|
|
121
|
+
state["fired"] = True
|
|
122
|
+
if p.ctx is not None and p.ctx.get("_req") is not None:
|
|
123
|
+
route = scope.get("route")
|
|
124
|
+
p.ctx["_req"].route = getattr(route, "path", None) if route is not None else None
|
|
125
|
+
p.on_finish(status)
|
|
126
|
+
|
|
127
|
+
async def send_wrapper(m: Message) -> None:
|
|
128
|
+
if m["type"] == "http.response.start":
|
|
129
|
+
try:
|
|
130
|
+
state["status"] = int(m.get("status", 200))
|
|
131
|
+
headers = list(m.get("headers") or [])
|
|
132
|
+
if p.rid:
|
|
133
|
+
headers.append((b"x-rid", p.rid.encode()))
|
|
134
|
+
if p.set_cookie:
|
|
135
|
+
headers.append((b"set-cookie", p.set_cookie.encode("latin-1")))
|
|
136
|
+
m["headers"] = headers
|
|
137
|
+
except Exception as err:
|
|
138
|
+
log_rate_limited(err)
|
|
139
|
+
await send(m)
|
|
140
|
+
if m["type"] == "http.response.body" and not m.get("more_body"):
|
|
141
|
+
finish(int(state["status"]))
|
|
142
|
+
|
|
143
|
+
try:
|
|
144
|
+
await self.app(scope, receive, send_wrapper)
|
|
145
|
+
except BaseException:
|
|
146
|
+
finish(500)
|
|
147
|
+
raise
|
|
148
|
+
finally:
|
|
149
|
+
finish(int(state["status"]))
|
camada/beacon_js.py
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
# GENERATED by scripts/sync_beacon.py — do not edit. The first-party beacon (@camada/browser's
|
|
2
|
+
# dist/auto.global.js) vendored as a string: served at /_cam/b.js with no runtime file read.
|
|
3
|
+
# tests/test_beacon_js.py pins it to the sibling build byte for byte.
|
|
4
|
+
BEACON_VERSION = '0.2.0'
|
|
5
|
+
BEACON_SHA256 = 'f0de88bef775d44b76e363d9cb6b87da7bb908172d686921326b635ff03f012b'
|
|
6
|
+
BEACON_JS = '"use strict";(()=>{var k="0.2.0";var _="@camada/browser";var b=`${_}/${k}`;function n(t){try{return t()}catch{return null}}function x(t){let e=2166136261;for(let i=0;i<t.length;i++)e^=t.charCodeAt(i),e=Math.imul(e,16777619);return(e>>>0).toString(16)}function M(){let t=document.createElement("canvas");t.width=240,t.height=60;let e=t.getContext("2d");return e?(e.textBaseline="alphabetic",e.fillStyle="#f60",e.fillRect(120,5,60,20),e.fillStyle="#069",e.font="15px Arial",e.fillText("Sfp\\u2603 1.0",2,15),e.fillStyle="rgba(102,204,0,0.7)",e.font="17px Times",e.fillText("Sfp\\u2603 1.0",4,45),x(t.toDataURL())):null}function R(){let t=document.createElement("canvas"),e=t.getContext("webgl")||t.getContext("experimental-webgl");if(!e)return null;let i=e.getExtension("WEBGL_debug_renderer_info");return{v:i?e.getParameter(i.UNMASKED_VENDOR_WEBGL):e.getParameter(e.VENDOR),r:i?e.getParameter(i.UNMASKED_RENDERER_WEBGL):e.getParameter(e.RENDERER)}}function S(){return x([Math.tan(-1e300),Math.sinh(1),Math.expm1(1),Math.cbrt(100),Math.log1p(10),Math.atanh(.5)].join("|"))}var N=/^\\$?cdc_|^__webdriver|^__selenium|^__nightmare|^__playwright|^callPhantom/,L=/^\\$?cdc_|^__webdriver|^__selenium|^__nightmare|^__playwright|^callPhantom|^_phantom/;function j(){let t=document,e=navigator,i=window,o=0;for(let s in t)N.test(s)&&o++;for(let s in i)L.test(s)&&o++;return{wd:e.webdriver===!0?1:0,cdc:o,chrome:typeof i.chrome<"u"?1:0,plugins:e.plugins?e.plugins.length:-1,langs:e.languages?e.languages.length:-1,outer:window.outerWidth===0&&window.outerHeight===0?1:0,perm:n(()=>Notification.permission),pdf:typeof e.pdfViewerEnabled=="boolean"?e.pdfViewerEnabled?1:0:-1,err:n(()=>{try{null.x}catch(s){let c=s.stack;return c?c.split(`\n`).length:0}})}}function T({endpoint:t,rid:e}){let i=e||null,o=performance.now(),s=0,c=0,m=0,h=0,f=0,u=null;function l(){u===null&&(u=Math.round(performance.now()-o))}addEventListener("mousemove",()=>{s++,l()},{passive:!0}),addEventListener("scroll",()=>{c++,l()},{passive:!0}),addEventListener("keydown",()=>{m++,l()},{passive:!0}),addEventListener("touchstart",()=>{h++,l()},{passive:!0}),addEventListener("click",()=>{f++,l()},{passive:!0});function w(){let r=navigator,a=n(()=>performance.getEntriesByType("navigation")[0]),D=n(()=>{let p=performance.getEntriesByType("paint");return p.length?Math.round(p[p.length-1].startTime):null}),d=n(()=>r.connection)||{};return{sdk:b,rid:i,ts:Date.now(),scr:n(()=>[screen.width,screen.height,screen.availWidth,screen.availHeight,screen.colorDepth]),win:n(()=>[window.innerWidth,window.innerHeight,window.outerWidth,window.outerHeight]),dpr:n(()=>window.devicePixelRatio),tz:n(()=>Intl.DateTimeFormat().resolvedOptions().timeZone),tzo:n(()=>new Date().getTimezoneOffset()),lang:n(()=>r.language),langs:n(()=>r.languages?Array.prototype.slice.call(r.languages,0,5):null),plat:n(()=>r.platform),cores:n(()=>r.hardwareConcurrency),mem:n(()=>r.deviceMemory),touch:n(()=>r.maxTouchPoints),ua:n(()=>r.userAgent),dnt:n(()=>r.doNotTrack),cookies:n(()=>r.cookieEnabled?1:0),conn:n(()=>[d.effectiveType,d.rtt,d.downlink,d.saveData?1:0]),gl:n(R),cv:n(M),mf:n(S),auto:n(j),vis:n(()=>document.visibilityState),focus:n(()=>document.hasFocus()?1:0),timing:a?[Math.round(a.domContentLoadedEventEnd),Math.round(a.loadEventEnd),Math.round(a.responseEnd-a.requestStart)]:null,paint:D,input:{moves:s,scrolls:c,keys:m,touches:h,clicks:f,first:u,at:Math.round(performance.now()-o)}}}function v(r){let a=JSON.stringify(r?Object.assign(w(),{hi:r}):w());navigator.sendBeacon&&navigator.sendBeacon(t,new Blob([a],{type:"application/json"}))||fetch(t,{method:"POST",body:a,keepalive:!0,headers:{"content-type":"application/json"}}).catch(()=>{})}let g=navigator.userAgentData,E=g&&g.getHighEntropyValues?g.getHighEntropyValues(["architecture","bitness","model","platformVersion","fullVersionList"]).catch(()=>null):Promise.resolve(null);setTimeout(()=>{E.then(v)},2e3),addEventListener("pagehide",()=>{E.then(r=>{v(r)})},{once:!0})}function O(t){let e=new URL(t,"http://_"),i=e.pathname.split("/");return i[i.length-1]="fp",{endpoint:i.join("/"),rid:e.searchParams.get("r")}}(function(){try{let t=document.currentScript,e=t&&t.src;if(!e)return;let{endpoint:i,rid:o}=O(e);T({endpoint:i,rid:o})}catch{}})();})();\n'
|
|
File without changes
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# Wire constants and pure helpers for the SDK-served challenge (contracts §D2), ported from
|
|
2
|
+
# @camada/core src/challenge/format.ts. Nothing here does crypto; verify.py supplies HMAC and
|
|
3
|
+
# SHA-256 from the stdlib, so the format has exactly one definition across the family.
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import hmac
|
|
7
|
+
import json
|
|
8
|
+
from urllib.parse import unquote
|
|
9
|
+
|
|
10
|
+
CHALLENGE_COOKIE = "_cch"
|
|
11
|
+
CHALLENGE_TTL_MS = 3_600_000 # 1 h (contract)
|
|
12
|
+
POW_BITS = 16 # leading zero bits of SHA-256(f"{nonce}.{solution}")
|
|
13
|
+
NONCE_HEX = 32 # the nonce is the first 32 hex chars of the HMAC
|
|
14
|
+
_DAY_MS = 86_400_000
|
|
15
|
+
_MAX_RETURN_TO = 2048
|
|
16
|
+
_MAX_SOLUTION = 32
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def utc_day(now_ms: int) -> int:
|
|
20
|
+
return now_ms // _DAY_MS
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# Domain-separated messages: a nonce HMAC can never be replayed as a cookie HMAC.
|
|
24
|
+
def nonce_message(ip: str | None, day: int) -> str:
|
|
25
|
+
return f"camada-challenge-nonce|{ip or ''}|{day}"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def token_message(ip: str | None, exp: int) -> str:
|
|
29
|
+
return f"camada-challenge-token|{ip or ''}|{exp}"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def split_token(value: str | None) -> tuple[int, str] | None:
|
|
33
|
+
if not value:
|
|
34
|
+
return None
|
|
35
|
+
dot = value.find(".")
|
|
36
|
+
if dot <= 0:
|
|
37
|
+
return None
|
|
38
|
+
try:
|
|
39
|
+
exp = int(value[:dot])
|
|
40
|
+
except ValueError:
|
|
41
|
+
return None
|
|
42
|
+
mac = value[dot + 1 :]
|
|
43
|
+
return (exp, mac) if mac else None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def safe_equal(a: str, b: str) -> bool:
|
|
47
|
+
"""Constant-time for equal-length strings; length itself is not a secret here."""
|
|
48
|
+
return len(a) == len(b) and hmac.compare_digest(a.encode(), b.encode())
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def pow_ok(hex_digest: str, bits: int = POW_BITS) -> bool:
|
|
52
|
+
"""True when the hex digest starts with `bits` zero bits."""
|
|
53
|
+
nibbles, rest = bits >> 2, bits & 3
|
|
54
|
+
if len(hex_digest) < nibbles + (1 if rest else 0):
|
|
55
|
+
return False
|
|
56
|
+
if any(hex_digest[i] != "0" for i in range(nibbles)):
|
|
57
|
+
return False
|
|
58
|
+
if rest == 0:
|
|
59
|
+
return True
|
|
60
|
+
try:
|
|
61
|
+
v = int(hex_digest[nibbles], 16)
|
|
62
|
+
except ValueError:
|
|
63
|
+
return False
|
|
64
|
+
return (v >> (4 - rest)) == 0
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def solution_shape_ok(solution: str | None) -> bool:
|
|
68
|
+
return bool(solution) and len(solution or "") <= _MAX_SOLUTION
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def challenge_cookie(value: str, secure: bool) -> str:
|
|
72
|
+
return f"{CHALLENGE_COOKIE}={value}; Path=/; Max-Age={CHALLENGE_TTL_MS // 1000}; HttpOnly; SameSite=Lax{'; Secure' if secure else ''}"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def safe_return_to(raw: str | None) -> str:
|
|
76
|
+
"""Only a printable-ASCII same-site absolute path survives: never an absolute URL, a
|
|
77
|
+
protocol-relative '//host' redirect, a control character, or something absurdly long."""
|
|
78
|
+
if not raw or len(raw) > _MAX_RETURN_TO:
|
|
79
|
+
return "/"
|
|
80
|
+
if raw[0] != "/" or (len(raw) > 1 and raw[1] in "/\\"):
|
|
81
|
+
return "/"
|
|
82
|
+
if any(not (0x21 <= ord(c) <= 0x7E) for c in raw):
|
|
83
|
+
return "/"
|
|
84
|
+
return raw
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def wants_html(accept: str | None, sec_fetch_dest: str | None) -> bool:
|
|
88
|
+
"""A challenge page is only worth serving to a top-level HTML navigation (contract §D2)."""
|
|
89
|
+
if not accept or "text/html" not in accept:
|
|
90
|
+
return False
|
|
91
|
+
return not sec_fetch_dest or sec_fetch_dest == "document"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def escape_attr(s: str) -> str:
|
|
95
|
+
return s.replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """).replace("'", "'")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def escape_script(s: str) -> str:
|
|
99
|
+
"""Safe to drop inside an inline <script>: `<` is escaped so no value can close the element early."""
|
|
100
|
+
return json.dumps(s).replace("<", "\\u003c")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def parse_form_body(body: str) -> dict[str, str]:
|
|
104
|
+
"""application/x-www-form-urlencoded, last value wins. Never raises on junk."""
|
|
105
|
+
out: dict[str, str] = {}
|
|
106
|
+
for pair in body.split("&"):
|
|
107
|
+
if not pair:
|
|
108
|
+
continue
|
|
109
|
+
eq = pair.find("=")
|
|
110
|
+
k = pair if eq == -1 else pair[:eq]
|
|
111
|
+
v = "" if eq == -1 else pair[eq + 1 :]
|
|
112
|
+
out[unquote(k.replace("+", " "))] = unquote(v.replace("+", " "))
|
|
113
|
+
return out
|
camada/challenge/page.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# The 403 challenge page: one self-contained HTML document, no external assets, no-store —
|
|
2
|
+
# byte-for-byte the page @camada/core serves (src/challenge/page.ts). The inline solver hunts a
|
|
3
|
+
# counter whose SHA-256(`${nonce}.${counter}`) starts with 16 zero bits (~65k hashes, tens of
|
|
4
|
+
# milliseconds), fills the hidden form and submits it. The form POST means the browser follows
|
|
5
|
+
# the verify endpoint's 302 natively, so the cookie is set and the original URL is re-fetched
|
|
6
|
+
# without any fetch/CORS/cookie subtleties.
|
|
7
|
+
#
|
|
8
|
+
# The page carries its own SHA-256 rather than calling crypto.subtle: subtle is undefined on
|
|
9
|
+
# non-secure origins (plain http on a LAN host), and 65k awaited digests would be slow anyway.
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from .format import POW_BITS, escape_attr, escape_script
|
|
13
|
+
|
|
14
|
+
# FIPS 180-4 round constants (cube roots of the first 64 primes).
|
|
15
|
+
_K = (
|
|
16
|
+
"0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,"
|
|
17
|
+
"0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,"
|
|
18
|
+
"0xe49b69c1,0xefbe4786,0xfc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,"
|
|
19
|
+
"0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x6ca6351,0x14292967,"
|
|
20
|
+
"0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,"
|
|
21
|
+
"0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070,"
|
|
22
|
+
"0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,"
|
|
23
|
+
"0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2"
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
# One SHA-256 over an ASCII string. Returns the 8 state words; the solver reads H[0] only
|
|
27
|
+
# (16 leading zero bits === H[0] >>> 16 === 0, which is exactly the server's pow_ok(hex, 16)).
|
|
28
|
+
_SOLVER = (
|
|
29
|
+
"\nvar __camadaK=[" + _K + "];\n"
|
|
30
|
+
"function __camadaRr(x,n){return (x>>>n)|(x<<(32-n))}\n"
|
|
31
|
+
"function __camadaSha256Words(msg){\n"
|
|
32
|
+
" var K=__camadaK,rr=__camadaRr,l=msg.length,wl=(((l+9+63)>>6)<<4),M=new Uint32Array(wl),i;\n"
|
|
33
|
+
" for(i=0;i<l;i++)M[i>>2]|=(msg.charCodeAt(i)&255)<<(24-(i%4)*8);\n"
|
|
34
|
+
" M[l>>2]|=0x80<<(24-(l%4)*8);\n"
|
|
35
|
+
" M[wl-1]=l*8;\n"
|
|
36
|
+
" var H=[0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19];\n"
|
|
37
|
+
" var W=new Uint32Array(64),bi,t;\n"
|
|
38
|
+
" for(bi=0;bi<wl;bi+=16){\n"
|
|
39
|
+
" for(t=0;t<16;t++)W[t]=M[bi+t];\n"
|
|
40
|
+
" for(t=16;t<64;t++){var x=W[t-15],y=W[t-2];\n"
|
|
41
|
+
" W[t]=(W[t-16]+(rr(x,7)^rr(x,18)^(x>>>3))+W[t-7]+(rr(y,17)^rr(y,19)^(y>>>10)))>>>0}\n"
|
|
42
|
+
" var a=H[0],b=H[1],c=H[2],d=H[3],e=H[4],f=H[5],g=H[6],h=H[7];\n"
|
|
43
|
+
" for(t=0;t<64;t++){\n"
|
|
44
|
+
" var t1=(h+(rr(e,6)^rr(e,11)^rr(e,25))+((e&f)^(~e&g))+K[t]+W[t])>>>0;\n"
|
|
45
|
+
" var t2=((rr(a,2)^rr(a,13)^rr(a,22))+((a&b)^(a&c)^(b&c)))>>>0;\n"
|
|
46
|
+
" h=g;g=f;f=e;e=(d+t1)>>>0;d=c;c=b;b=a;a=(t1+t2)>>>0}\n"
|
|
47
|
+
" H[0]=(H[0]+a)>>>0;H[1]=(H[1]+b)>>>0;H[2]=(H[2]+c)>>>0;H[3]=(H[3]+d)>>>0;\n"
|
|
48
|
+
" H[4]=(H[4]+e)>>>0;H[5]=(H[5]+f)>>>0;H[6]=(H[6]+g)>>>0;H[7]=(H[7]+h)>>>0}\n"
|
|
49
|
+
" return H}\n"
|
|
50
|
+
"function __camadaSha256Hex(msg){\n"
|
|
51
|
+
" var H=__camadaSha256Words(msg),s='',i;\n"
|
|
52
|
+
" for(i=0;i<8;i++)s+=('00000000'+H[i].toString(16)).slice(-8);\n"
|
|
53
|
+
" return s}\n"
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
_STYLE = (
|
|
57
|
+
":root{color-scheme:light}\n"
|
|
58
|
+
"body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;background:#fafafa;color:#1a1a1a;"
|
|
59
|
+
"font:16px/1.5 system-ui,-apple-system,Segoe UI,Roboto,sans-serif}\n"
|
|
60
|
+
"main{max-width:28rem;padding:2rem;text-align:center}\n"
|
|
61
|
+
"h1{font-size:1.25rem;margin:0 0 .5rem}\n"
|
|
62
|
+
"p{margin:.25rem 0;color:#555}\n"
|
|
63
|
+
".bar{margin:1.5rem auto 0;width:12rem;height:4px;border-radius:2px;background:#e5e5e5;overflow:hidden}\n"
|
|
64
|
+
".bar i{display:block;height:100%;width:30%;background:#1a1a1a;animation:camada-slide 1.1s ease-in-out infinite}\n"
|
|
65
|
+
"@keyframes camada-slide{0%{transform:translateX(-120%)}100%{transform:translateX(400%)}}\n"
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def challenge_page(*, nonce: str, action: str, to: str, bits: int = POW_BITS) -> str:
|
|
70
|
+
"""`action` is the verify endpoint path; `to` has already been through safe_return_to."""
|
|
71
|
+
bits = min(32, max(1, bits)) # shift 32-bits must stay in range
|
|
72
|
+
return (
|
|
73
|
+
"<!doctype html>\n"
|
|
74
|
+
'<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">\n'
|
|
75
|
+
"<title>Checking your browser</title>\n"
|
|
76
|
+
"<style>\n" + _STYLE + "</style></head>\n"
|
|
77
|
+
"<body>\n"
|
|
78
|
+
"<main>\n"
|
|
79
|
+
"<h1>Checking your browser</h1>\n"
|
|
80
|
+
'<p id="camada-msg">This takes a moment. It runs entirely in your browser.</p>\n'
|
|
81
|
+
"<noscript><p>JavaScript is required to continue.</p></noscript>\n"
|
|
82
|
+
'<div class="bar"><i></i></div>\n'
|
|
83
|
+
"</main>\n"
|
|
84
|
+
f'<form id="camada-f" method="POST" action="{escape_attr(action)}">\n'
|
|
85
|
+
f'<input type="hidden" name="nonce" value="{escape_attr(nonce)}">\n'
|
|
86
|
+
'<input type="hidden" name="solution" id="camada-s">\n'
|
|
87
|
+
f'<input type="hidden" name="to" value="{escape_attr(to)}">\n'
|
|
88
|
+
"</form>\n"
|
|
89
|
+
"<script>" + _SOLVER + "\n"
|
|
90
|
+
"(function(){\n"
|
|
91
|
+
" if(typeof window==='undefined'||window.__camadaAutostart===false)return;\n"
|
|
92
|
+
f" var nonce={escape_script(nonce)},shift={32 - bits};\n"
|
|
93
|
+
" function go(){\n"
|
|
94
|
+
" for(var n=0;n<5000000;n++){\n"
|
|
95
|
+
" if((__camadaSha256Words(nonce+'.'+n)[0]>>>shift)===0){\n"
|
|
96
|
+
" document.getElementById('camada-s').value=String(n);\n"
|
|
97
|
+
" document.getElementById('camada-f').submit();\n"
|
|
98
|
+
" return}}\n"
|
|
99
|
+
" document.getElementById('camada-msg').textContent='Could not complete the check. Please reload.'}\n"
|
|
100
|
+
" setTimeout(go,30);\n"
|
|
101
|
+
"})();\n"
|
|
102
|
+
"</script>\n"
|
|
103
|
+
"</body></html>"
|
|
104
|
+
)
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# The challenge kit over the stdlib's HMAC-SHA256 and SHA-256, ported from @camada/core
|
|
2
|
+
# src/challenge/verify.ts. Synchronous, so the engine's handle() stays a plain function.
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import hmac
|
|
7
|
+
|
|
8
|
+
from .format import (
|
|
9
|
+
CHALLENGE_TTL_MS,
|
|
10
|
+
NONCE_HEX,
|
|
11
|
+
nonce_message,
|
|
12
|
+
pow_ok,
|
|
13
|
+
safe_equal,
|
|
14
|
+
solution_shape_ok,
|
|
15
|
+
split_token,
|
|
16
|
+
token_message,
|
|
17
|
+
utc_day,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ChallengeKit:
|
|
22
|
+
__slots__ = ("_secret",)
|
|
23
|
+
|
|
24
|
+
def __init__(self, secret: str) -> None:
|
|
25
|
+
self._secret = secret.encode()
|
|
26
|
+
|
|
27
|
+
def _hmac(self, msg: str) -> str:
|
|
28
|
+
return hmac.new(self._secret, msg.encode(), hashlib.sha256).hexdigest()
|
|
29
|
+
|
|
30
|
+
def _at(self, ip: str, day: int) -> str:
|
|
31
|
+
return self._hmac(nonce_message(ip, day))[:NONCE_HEX]
|
|
32
|
+
|
|
33
|
+
def nonce(self, ip: str, now_ms: int) -> str:
|
|
34
|
+
"""Stateless per-(ip, UTC day) nonce; the verify endpoint recomputes it, nothing is stored."""
|
|
35
|
+
return self._at(ip, utc_day(now_ms))
|
|
36
|
+
|
|
37
|
+
# Yesterday still passes: a solve started before midnight UTC must not be thrown away.
|
|
38
|
+
# So one solved (nonce, solution) pair is replayable from its own IP for up to ~48 h,
|
|
39
|
+
# minting a fresh 1 h cookie each time. That is the price of a stateless nonce (§D2) and
|
|
40
|
+
# it is deliberate — do not "fix" it into something that needs shared server state.
|
|
41
|
+
def nonce_valid(self, ip: str | None, now_ms: int, nonce: str | None) -> bool:
|
|
42
|
+
if not ip or not nonce or len(nonce) != NONCE_HEX:
|
|
43
|
+
return False
|
|
44
|
+
day = utc_day(now_ms)
|
|
45
|
+
return safe_equal(nonce, self._at(ip, day)) or safe_equal(nonce, self._at(ip, day - 1))
|
|
46
|
+
|
|
47
|
+
def issue(self, ip: str, now_ms: int) -> str:
|
|
48
|
+
exp = now_ms + CHALLENGE_TTL_MS
|
|
49
|
+
return f"{exp}.{self._hmac(token_message(ip, exp))}"
|
|
50
|
+
|
|
51
|
+
# A null ip is refused outright: without one the token is bound to nothing, so a single
|
|
52
|
+
# solve would mint a cookie every other unidentified client could present. Adapters must
|
|
53
|
+
# fail open (serve no challenge) rather than challenge a client they cannot identify.
|
|
54
|
+
def token_valid(self, ip: str | None, now_ms: int, cookie_value: str | None) -> bool:
|
|
55
|
+
if not ip:
|
|
56
|
+
return False
|
|
57
|
+
t = split_token(cookie_value)
|
|
58
|
+
if t is None:
|
|
59
|
+
return False
|
|
60
|
+
exp, mac = t
|
|
61
|
+
if exp <= now_ms or exp > now_ms + CHALLENGE_TTL_MS:
|
|
62
|
+
return False
|
|
63
|
+
return safe_equal(mac, self._hmac(token_message(ip, exp)))
|
|
64
|
+
|
|
65
|
+
def solution_ok(self, nonce: str, solution: str | None) -> bool:
|
|
66
|
+
"""Proof of work ONLY. Never call it without a passing nonce_valid() for the same nonce."""
|
|
67
|
+
return solution_shape_ok(solution) and pow_ok(hashlib.sha256(f"{nonce}.{solution}".encode()).hexdigest())
|
|
68
|
+
|
|
69
|
+
def verify(self, ip: str | None, now_ms: int, nonce: str | None, solution: str | None) -> bool:
|
|
70
|
+
"""The whole submission: the nonce is ours and unexpired, and the work is done."""
|
|
71
|
+
return self.nonce_valid(ip, now_ms, nonce) and self.solution_ok(nonce or "", solution)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def create_challenge(secret: str) -> ChallengeKit:
|
|
75
|
+
return ChallengeKit(secret)
|
camada/config.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# Configuration shapes shared by the client and the engine. `parse_key` splits CAMADA_KEY, and
|
|
2
|
+
# `RemoteConfig` is what GET /snapshot hands back in x-camada-config (whitelisted server-side).
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, TypedDict, cast
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TrustedProxy(TypedDict, total=False):
|
|
9
|
+
"""Mirrors the server-validated tenant config (edge-analyst src/tenant-config.js):
|
|
10
|
+
{mode: none} | {mode: hops, hops: N} | {mode: cidrs, cidrs: [...]} | {mode: vercel}."""
|
|
11
|
+
|
|
12
|
+
mode: str
|
|
13
|
+
hops: int
|
|
14
|
+
cidrs: list[str]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class RemoteConfig(TypedDict, total=False):
|
|
18
|
+
tenant: str
|
|
19
|
+
beacon: bool
|
|
20
|
+
sample: float
|
|
21
|
+
exclude: list[str]
|
|
22
|
+
trusted_proxy: TrustedProxy
|
|
23
|
+
poll_seconds: float
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def parse_key(key: str | None) -> tuple[str, str] | None:
|
|
27
|
+
"""CAMADA_KEY is `<ingest_token>.<snap_token>` (printed by reconcile instructions and seed)."""
|
|
28
|
+
if not key:
|
|
29
|
+
return None
|
|
30
|
+
dot = key.find(".")
|
|
31
|
+
if dot <= 0 or dot == len(key) - 1:
|
|
32
|
+
return None
|
|
33
|
+
return key[:dot], key[dot + 1 :]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def parse_trusted_proxy_env(v: str | None) -> TrustedProxy | None:
|
|
37
|
+
"""CAMADA_TRUSTED_PROXY: none | vercel | hops:N | cidrs:a,b. Unset or malformed returns None,
|
|
38
|
+
which callers treat as "defer to the server-delivered tenant config", never as trust."""
|
|
39
|
+
if not v:
|
|
40
|
+
return None
|
|
41
|
+
if v == "none":
|
|
42
|
+
return {"mode": "none"}
|
|
43
|
+
if v == "vercel":
|
|
44
|
+
return {"mode": "vercel"}
|
|
45
|
+
if v.startswith("hops:"):
|
|
46
|
+
try:
|
|
47
|
+
hops = int(v[5:])
|
|
48
|
+
except ValueError:
|
|
49
|
+
return None
|
|
50
|
+
return {"mode": "hops", "hops": hops} if hops >= 1 else None
|
|
51
|
+
if v.startswith("cidrs:"):
|
|
52
|
+
cidrs = [c.strip() for c in v[6:].split(",") if c.strip()]
|
|
53
|
+
return {"mode": "cidrs", "cidrs": cidrs} if cidrs else None
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def remote_config(raw: Any) -> RemoteConfig | None:
|
|
58
|
+
"""The parsed x-camada-config header; anything but a JSON object is ignored (previous kept)."""
|
|
59
|
+
return cast(RemoteConfig, raw) if isinstance(raw, dict) else None
|
camada/constants.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Tap identifier this SDK claims on the wire. The server validates against its own enum and
|
|
2
|
+
# derives the capability mask itself (edge-analyst src/capabilities.js): an SDK can never grant
|
|
3
|
+
# itself capability bits, only name its position — and an unknown name is silently read as a
|
|
4
|
+
# proxy, so this literal is load-bearing.
|
|
5
|
+
TAP = "sdk-python"
|
|
6
|
+
|
|
7
|
+
DEFAULT_REFRESH_S = 30.0
|
|
8
|
+
# 5 carries the tenant's ordered custom rules (§D3); a tenant without one is answered with the next container down.
|
|
9
|
+
DEFAULT_SNAPSHOT_VERSION = 5
|
|
10
|
+
KILL_SWITCH_ENV = "CAMADA_DISABLED"
|
|
11
|
+
|
|
12
|
+
SCRIPT_PATH = "/_cam/b.js"
|
|
13
|
+
FP_PATH = "/_cam/fp"
|
|
14
|
+
FP_MAX = 32 * 1024 # matches the server's /fp cap: never accept what ingest will 413
|
|
15
|
+
CHALLENGE_PATH = "/__camada/challenge"
|
|
16
|
+
BODY_MAX = 4 * 1024 # the verify form is ~120 bytes; anything larger is not ours
|
|
17
|
+
|
|
18
|
+
SESSION_COOKIE = "_sfp" # same cookie as the edge collector: sid/ns comparable across taps
|
|
19
|
+
SESSION_MAX_AGE = 2592000 # 30 days
|