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 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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;").replace("'", "&#39;")
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
@@ -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