camada 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 628f9e7c2f9d061a56e7627695b0fccb7e7d3416d284878e9b931b3be4dc82bf
4
+ data.tar.gz: 735de6952ab6f22dc6e5c5c72773458d3ec9d165c6db9cb71dbc933a0e8fe401
5
+ SHA512:
6
+ metadata.gz: 4d14f47f17124f90ebca0bcfba5cb314bec5f17d4807d2e2adcd0ba9ac58ba8bd5277983732c07ba06778222b69882ee46c7b2049439f7f58e4c180a7e961e1c
7
+ data.tar.gz: 76a85c3d6d4720d79c9f0c1ba223e7dfdc5109edeb16b9659ce876eccb19923e8ff4d3ff1a2c5df94260e52a41d4266c694fbd65ec99dbcf34e9fdfba0f18d30
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 camada
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,199 @@
1
+ # camada
2
+
3
+ camada for Ruby: enforces the tenant snapshot inline (your ordered custom rules, then allow,
4
+ block, challenge), serves a first-party proof-of-work challenge page and beacon, records the
5
+ outcomes your handlers know (`Camada.track`), and ships wire events in batches off the request
6
+ path. One gem with a Rack middleware that any Rack app mounts (Sinatra, Hanami, plain Rack) and
7
+ a Railtie that mounts it for Rails — the `sentry-ruby` model. Fails open by design: a camada
8
+ outage or bug never 5xxes your app.
9
+
10
+ Not yet on RubyGems — install it from a sibling checkout: `gem "camada", path: "../camada-ruby"`
11
+ in your Gemfile (as [`camada-ruby-example`](../camada-ruby-example) does); publishing is one
12
+ decision with the npm packages (SDK-G01). Ruby 3.1 or newer, no runtime dependencies (stdlib
13
+ only: `net/http`, `openssl`, `digest`, `json`, `securerandom`, `zlib`, `stringio`).
14
+
15
+ ## Quickstart
16
+
17
+ ```ruby
18
+ # config.ru — Sinatra, Hanami, or any Rack app
19
+ require "camada"
20
+ use Camada::Rack # first, so camada answers before routing
21
+ run App
22
+
23
+ # Rails — nothing to add: the Railtie inserts Camada::Rack at the top of the middleware stack
24
+ ```
25
+
26
+ Env (printed by camada onboarding / `npm run seed` in dev):
27
+
28
+ ```
29
+ CAMADA_KEY=<ingest_token>.<snap_token>
30
+ CAMADA_INGEST_URL=http://localhost:8787 # dev only; defaults to production ingest
31
+ ```
32
+
33
+ The middleware shares one lazy engine built from the environment on the first request. That
34
+ build starts the snapshot poll on a thread and never blocks, so the request that triggered it is
35
+ answered cold: it passes (fail open), and so does anything else that arrives before that first
36
+ poll lands (a few hundred milliseconds against a local analyst; snapshot-size and network bound).
37
+ To enforce from request 1, warm the engine at boot (an initializer, or `config.ru` before `run`)
38
+ by waiting for the boot poll:
39
+
40
+ ```ruby
41
+ engine = Camada.default # builds the engine; the boot poll is already running on its thread
42
+ if engine.snap # nil when CAMADA_KEY is unset or CAMADA_DISABLED=1
43
+ probe = Camada::Snapshot::MatchInput.new(ip: "0.0.0.0")
44
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 5
45
+ while engine.snap.verdict(probe).reason == "cold" && Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline
46
+ sleep 0.01 # bounded: an unreachable analyst leaves it cold, and the app still fails open
47
+ end
48
+ end
49
+ ```
50
+
51
+ `snap.refresh` is not the warm-up: the boot poll holds the single-in-flight lock, so a
52
+ synchronous `refresh` called right after `Camada.default` returns at once and the engine is
53
+ still cold.
54
+
55
+ Without `CAMADA_KEY` the engine is inert (one log line, no requests, no enforcement). An app that
56
+ reads its own config builds the engine itself and hands it in:
57
+
58
+ ```ruby
59
+ engine = Camada::Engine.new(env: { "CAMADA_KEY" => my_key, "CAMADA_INGEST_URL" => my_ingest })
60
+ use Camada::Rack, engine
61
+ ```
62
+
63
+ ## What it does per request
64
+
65
+ 1. Keeps the snapshot fresh. A Ruby server is a long-lived process, so the default is a poll
66
+ thread at the cadence your tenant config sets (`poll_seconds`), with ETag/304 and gzip on the
67
+ wire. `CAMADA_SERVERLESS=1` switches to a per-request staleness check with no poll thread. Every
68
+ poll and event batch carries `x-camada-sdk: @camada/ruby/<version>`, and polls ask for
69
+ snapshot v5 (`x-camada-snapshot: 5`) — the container that carries your ordered custom rules.
70
+ 2. Resolves the client from the socket peer (`REMOTE_ADDR`), combined with `X-Forwarded-For`
71
+ only under your tenant's trusted-proxy config (or `CAMADA_TRUSTED_PROXY` locally). A forwarded
72
+ header on its own is never the ip: any caller can set it. **Never `request.ip`**:
73
+ `Rack::Request#ip` trusts `X-Forwarded-For` from anyone, which is exactly the spoof camada
74
+ refuses; the middleware reads `env["REMOTE_ADDR"]` and applies your trusted-proxy rules itself.
75
+ 3. Enforces before anything else, beacon endpoints included: your ordered custom rules first (first
76
+ match wins; they read ip, path, user-agent and request headers), then allow → block → challenge.
77
+ A block answers `403 Forbidden` with `x-block-reason`, `x-block-version` and, when a rule
78
+ decided, `x-block-rule`; its event ships with `blk` (and `rl`). A `warn` rule passes and stamps
79
+ `wrn`; a `skip` rule passes with nothing stamped. Cold (no snapshot yet) passes: fail open.
80
+ 4. Challenge: a `challenge` verdict gets the self-contained proof-of-work page (or 403 JSON for a
81
+ non-HTML request); `POST /__camada/challenge` verifies the solution, sets `_cch` (bound to the
82
+ ip, one hour) and 302s back. A request whose ip cannot be resolved is never challenged.
83
+ 5. Serves the beacon: `GET /_cam/b.js` (the `@camada/browser` build, vendored) and `POST /_cam/fp`
84
+ (≤ 32 KB, relayed onto the event batch as a `sig: 1` row with the ip camada resolved). Both
85
+ fall through to your app when the tenant switched the beacon off.
86
+ 6. Runs your app with `x-rid` and the `_sfp` session cookie on its response, and when the server
87
+ closes the response body ships one redacted event: method, host, path, scrubbed query, status,
88
+ latency, header names/sizes/order, the auth scheme (never the credential), cookie count (never
89
+ values), the matched route pattern when the framework names it (`sinatra.route`, Rails'
90
+ `route_uri_pattern`). An exception in your app ships as `st: 500` and propagates unchanged.
91
+
92
+ ## Options
93
+
94
+ `Camada::Engine.new(...)` keyword arguments (also accepted by `Camada.default(...)` and
95
+ `Camada::Rack.new(app, nil, ...)`); everything credential-shaped comes from the environment.
96
+
97
+ | option | default | meaning |
98
+ |---|---|---|
99
+ | `env` | `ENV` | where `CAMADA_*` are read from (a Hash works too) |
100
+ | `transport` | `Net::HTTP` | anything responding to `#call(HttpRequest) -> HttpResponse` (tests inject a fake) |
101
+ | `refresh_s` | server-steered | poll cadence; set, it is pinned |
102
+ | `challenge` | `true` | serve the proof-of-work page for challenge verdicts (`CAMADA_CHALLENGE=0` too) |
103
+ | `challenge_path` | `/__camada/challenge` | where the page posts its solution |
104
+ | `snapshot_version` | `5` | 4 drops your custom rules; 3 the allow/challenge sides too |
105
+ | `script_path` / `fp_path` | `/_cam/b.js` / `/_cam/fp` | the beacon endpoints; keep them in one directory |
106
+
107
+ Env: `CAMADA_KEY` (or `CAMADA_TOKEN` + `CAMADA_SNAPSHOT_TOKEN`), `CAMADA_INGEST_URL`,
108
+ `CAMADA_SNAPSHOT_URL`, `CAMADA_TRUSTED_PROXY` (`none | vercel | hops:N | cidrs:a,b`),
109
+ `CAMADA_SERVERLESS=1`, `CAMADA_CHALLENGE=0`, and the kill switch `CAMADA_DISABLED=1` (checked per
110
+ request; set at boot, no threads start at all).
111
+
112
+ ## The first-party beacon
113
+
114
+ ```ruby
115
+ # Sinatra: `env` is the Rack env; Rails: request.env
116
+ "<html><head>#{Camada.script_tag(env)}</head>…"
117
+ ```
118
+
119
+ The tag is `<script src="/_cam/b.js?r=<rid>" async>`, so the beacon joins the page view that
120
+ served it. Move both paths with `script_path` / `fp_path` when `/_cam/` is not yours; the script
121
+ derives the post path from its own URL, so the two must share a directory.
122
+
123
+ ## App-context events
124
+
125
+ ```ruby
126
+ Camada.track(env, "login_failed", user: email)
127
+ ```
128
+
129
+ The identifier is HMAC-hashed in-process with your ingest token; the raw value never reaches the
130
+ queue. `Camada.track` never raises and is a no-op on a request the middleware did not run for. The
131
+ event name is free-form; the analyst's app-context rules read this vocabulary:
132
+
133
+ | event | when |
134
+ |---|---|
135
+ | `login_failed` / `login_succeeded` | a login attempt settled; pass `user:` so attempts per account can be counted |
136
+ | `signup` | an account was created |
137
+ | `password_reset` | a reset was requested |
138
+ | `mfa_failed` | a second factor was rejected |
139
+ | `payment_failed` / `payment_succeeded` | a payment authorisation settled |
140
+ | `coupon_failed` | a promo/voucher code was rejected |
141
+
142
+ A route you gate yourself: `Camada.serve_challenge(env)` returns a Rack triple
143
+ `[status, headers, [body]]` to answer with (Sinatra: `halt(*answer)`) until the browser holds a
144
+ valid `_cch`, then `nil`.
145
+
146
+ ## What this tap can see
147
+
148
+ `sdk-ruby` is an in-app tap: status, latency, session, the beacon's browser signals and your
149
+ outcomes. Puma hands the env to Rack as binary strings; the middleware reads them as UTF-8 with
150
+ invalid bytes replaced (U+FFFD) before matching and shipping, so a stray high byte in a header
151
+ neither escapes a rule nor costs the batch it rides in. The Rack env carries no wire header order (`hord` is the env's order), so the analyst
152
+ reads no HEADER_ORDER signal from this tap, and it never scores the absence of header order, ASN,
153
+ country or a TLS fingerprint against a request; ASN and country it resolves itself. Enforcement
154
+ at this position covers ip, path, user-agent and header conditions — ASN, country and TLS entries
155
+ fail open in-app. `matches` patterns are JS regexes read by Ruby's Onigmo: named groups are
156
+ native, `[^]` and `\cX` are translated, `^`/`$` become `\A`/`\z` (Ruby's are line anchors,
157
+ JS's are not), `\d`/`\w`/`\b` are ASCII as in JS; a spelling Ruby still rejects never matches
158
+ here (and never raises), while it does at the edge.
159
+
160
+ ## Deploying it
161
+
162
+ - Every worker process polls its own snapshot (about 5 MB resident, read a word at a time — never
163
+ expanded into Ruby Integers) and flushes its own batches; the tenant's `poll_seconds` keeps the
164
+ cadence honest across a fleet.
165
+ - Threads do not survive a fork. Ruby has no fork hook, so the SDK compares `Process.pid` on every
166
+ call and starts over in a forked worker: new locks, an empty queue, its poll and flush threads
167
+ restarted. Puma cluster mode with `preload_app!`, Unicorn and Passenger need nothing added.
168
+ - Pending events drain at interpreter exit within half a second (`at_exit`). No signal handlers
169
+ are installed — an app owns its own shutdown — so a worker killed by SIGKILL, or by SIGTERM
170
+ without a handler, may drop its last batch.
171
+ - Serverless: `CAMADA_SERVERLESS=1`. A cold invocation fails open and catches up on the next one.
172
+ - The middleware writes lower-case header names; it appends `set-cookie` as an Array under Rack 3
173
+ and joins cookies with `"\n"` under Rack 2. It never requires `rack` itself.
174
+
175
+ ## Fail open
176
+
177
+ Every entry point runs inside the fail-open envelope: a dead ingest drops telemetry (logged at
178
+ most once a minute, one line, no backtrace), a corrupt snapshot keeps the previous one, a bug in
179
+ the gem costs the request its join, never its response. `CAMADA_DISABLED=1` bypasses everything.
180
+
181
+ ## Development
182
+
183
+ ```
184
+ bundle install && bundle exec rubocop && bundle exec rspec
185
+ ```
186
+
187
+ No type checker: the gem has no runtime dependencies and a Sorbet or Steep setup would add a
188
+ toolchain (and RBS signatures for every stdlib seam) for a 2.5k-line port whose behaviour is
189
+ pinned by the golden fixtures instead; it is deferred, not refused. A Rails example app is
190
+ deferred the same way — the Railtie ships in the gem, the example repo is Sinatra.
191
+
192
+ The suite reads the golden snapshot fixtures from the `camada-core` sibling checkout and pins the
193
+ vendored beacon to `camada-browser/dist/auto.global.js` (`npm run build` there first, then
194
+ `ruby scripts/sync_beacon.rb` after a beacon release). Both fail by name when the checkout is
195
+ missing rather than skipping (`CAMADA_FIXTURES_DIR`, `CAMADA_BROWSER_DIST` override the paths).
196
+
197
+ [`camada-ruby-example`](../camada-ruby-example) is the hand-test bench (Sinatra under Puma on
198
+ :3004), and `node scripts/e2e-sdk-ruby.mjs` in `camada/edge-analyst` drives it against a seeded
199
+ local analyst over real HTTP, cold first request included.
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ # GENERATED by scripts/sync_beacon.rb — do not edit. The first-party beacon (@camada/browser's
4
+ # dist/auto.global.js) vendored as a String literal: served at /_cam/b.js with no runtime file
5
+ # read. spec/beacon_js_spec.rb pins it to the sibling build byte for byte.
6
+ module Camada
7
+ BEACON_VERSION = "0.2.0"
8
+ BEACON_SHA256 = "f0de88bef775d44b76e363d9cb6b87da7bb908172d686921326b635ff03f012b"
9
+ 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"
10
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Camada
4
+ # Wraps the app's Rack body so the block runs exactly once, when the server closes the body
5
+ # (after the last byte, PEP 3333's close() in Rack terms). Nothing here requires "rack": the
6
+ # gem stays dependency-free, so this mirrors Rack::BodyProxy's shape instead: everything but
7
+ # close is delegated, and the proxy responds to a body method only when the inner body does.
8
+ # That last part is load-bearing — a Body that answers `each` is an Enumerable Body to every
9
+ # server (Rack SPEC), so a proxy with its own `each` would turn a Rack 3 streaming body
10
+ # (call-only) into a NoMethodError at the socket.
11
+ class BodyProxy
12
+ def initialize(body, &block)
13
+ @body = body
14
+ @block = block
15
+ @closed = false
16
+ end
17
+
18
+ def close
19
+ return if @closed
20
+
21
+ @closed = true
22
+ begin
23
+ @body.close if @body.respond_to?(:close)
24
+ ensure
25
+ @block.call
26
+ end
27
+ end
28
+
29
+ def closed? = @closed
30
+
31
+ def respond_to_missing?(name, include_all = false)
32
+ name != :to_str && @body.respond_to?(name, include_all)
33
+ end
34
+
35
+ # each, call (a streaming body), to_path (a file the server may sendfile) and to_ary pass
36
+ # through. A body consumed via to_ary must close itself (SPEC), so the proxy does it there.
37
+ def method_missing(name, ...)
38
+ return super if name == :to_str
39
+ return @body.__send__(name, ...) unless name == :to_ary
40
+
41
+ begin
42
+ @body.__send__(name, ...)
43
+ ensure
44
+ close
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "openssl"
5
+
6
+ module Camada
7
+ # Wire constants and pure helpers for the SDK-served challenge (contracts §D2), ported from
8
+ # @camada/core src/challenge/format.ts. Nothing here does crypto; verify.rb supplies HMAC and
9
+ # SHA-256 from the stdlib, so the format has exactly one definition across the family.
10
+ module Challenge
11
+ CHALLENGE_COOKIE = "_cch"
12
+ CHALLENGE_TTL_MS = 3_600_000 # 1 h (contract)
13
+ POW_BITS = 16 # leading zero bits of SHA-256("<nonce>.<solution>")
14
+ NONCE_HEX = 32 # the nonce is the first 32 hex chars of the HMAC
15
+ DAY_MS = 86_400_000
16
+ MAX_RETURN_TO = 2048
17
+ MAX_SOLUTION = 32
18
+
19
+ def self.utc_day(now_ms) = now_ms / DAY_MS
20
+
21
+ # Domain-separated messages: a nonce HMAC can never be replayed as a cookie HMAC.
22
+ def self.nonce_message(ip, day) = "camada-challenge-nonce|#{ip}|#{day}"
23
+ def self.token_message(ip, exp) = "camada-challenge-token|#{ip}|#{exp}"
24
+
25
+ def self.split_token(value)
26
+ return nil if value.nil? || value.empty?
27
+
28
+ dot = value.index(".")
29
+ return nil if dot.nil? || dot <= 0
30
+
31
+ exp = Integer(value[0, dot], 10, exception: false)
32
+ return nil if exp.nil?
33
+
34
+ mac = value[(dot + 1)..]
35
+ mac.empty? ? nil : [exp, mac]
36
+ end
37
+
38
+ # Constant-time for equal-length strings; length itself is not a secret here.
39
+ def self.safe_equal?(a, b)
40
+ a.bytesize == b.bytesize && OpenSSL.fixed_length_secure_compare(a, b)
41
+ end
42
+
43
+ # True when the hex digest starts with `bits` zero bits.
44
+ def self.pow_ok?(hex_digest, bits = POW_BITS)
45
+ nibbles = bits >> 2
46
+ rest = bits & 3
47
+ return false if hex_digest.length < nibbles + (rest == 0 ? 0 : 1)
48
+ return false if nibbles.times.any? { |i| hex_digest[i] != "0" }
49
+ return true if rest == 0
50
+
51
+ v = Integer(hex_digest[nibbles], 16, exception: false)
52
+ return false if v.nil?
53
+
54
+ (v >> (4 - rest)) == 0
55
+ end
56
+
57
+ def self.solution_shape_ok?(solution)
58
+ !solution.nil? && !solution.empty? && solution.length <= MAX_SOLUTION
59
+ end
60
+
61
+ def self.challenge_cookie(value, secure)
62
+ "#{CHALLENGE_COOKIE}=#{value}; Path=/; Max-Age=#{CHALLENGE_TTL_MS / 1000}; HttpOnly; SameSite=Lax#{"; Secure" if secure}"
63
+ end
64
+
65
+ # Only a printable-ASCII same-site absolute path survives: never an absolute URL, a
66
+ # protocol-relative '//host' redirect, a control character, or something absurdly long.
67
+ def self.safe_return_to(raw)
68
+ return "/" if raw.nil? || raw.empty? || raw.length > MAX_RETURN_TO
69
+ return "/" if raw[0] != "/" || (raw.length > 1 && "/\\".include?(raw[1]))
70
+ return "/" unless raw.match?(/\A[\x21-\x7e]+\z/)
71
+
72
+ raw
73
+ end
74
+
75
+ # A challenge page is only worth serving to a top-level HTML navigation (contract §D2).
76
+ def self.wants_html?(accept, sec_fetch_dest)
77
+ return false if accept.nil? || !accept.include?("text/html")
78
+
79
+ sec_fetch_dest.nil? || sec_fetch_dest.empty? || sec_fetch_dest == "document"
80
+ end
81
+
82
+ def self.escape_attr(s)
83
+ s.gsub("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;").gsub('"', "&quot;").gsub("'", "&#39;")
84
+ end
85
+
86
+ # Safe to drop inside an inline <script>: `<` is escaped so no value can close the element early.
87
+ def self.escape_script(s) = JSON.generate(s).gsub("<", "\\u003c")
88
+
89
+ # application/x-www-form-urlencoded, last value wins. Never raises on junk: an invalid
90
+ # %-escape is kept as is, invalid UTF-8 is replaced.
91
+ def self.parse_form_body(body)
92
+ out = {}
93
+ body.split("&").each do |pair|
94
+ next if pair.empty?
95
+
96
+ eq = pair.index("=")
97
+ k = eq.nil? ? pair : pair[0, eq]
98
+ v = eq.nil? ? "" : pair[(eq + 1)..]
99
+ out[unquote(k)] = unquote(v)
100
+ end
101
+ out
102
+ end
103
+
104
+ def self.unquote(s)
105
+ s.tr("+", " ").b.gsub(/%[0-9a-fA-F]{2}/) { |m| [m[1, 2].hex].pack("C") }.force_encoding("UTF-8").scrub
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "format"
4
+
5
+ module Camada
6
+ module Challenge
7
+ # The 403 challenge page: one self-contained HTML document, no external assets, no-store —
8
+ # byte-for-byte the page @camada/core serves (src/challenge/page.ts). The inline solver hunts a
9
+ # counter whose SHA-256(`${nonce}.${counter}`) starts with 16 zero bits (~65k hashes, tens of
10
+ # milliseconds), fills the hidden form and submits it. The form POST means the browser follows
11
+ # the verify endpoint's 302 natively, so the cookie is set and the original URL is re-fetched
12
+ # without any fetch/CORS/cookie subtleties.
13
+ #
14
+ # The page carries its own SHA-256 rather than calling crypto.subtle: subtle is undefined on
15
+ # non-secure origins (plain http on a LAN host), and 65k awaited digests would be slow anyway.
16
+
17
+ # FIPS 180-4 round constants (cube roots of the first 64 primes).
18
+ K = "0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5," \
19
+ "0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174," \
20
+ "0xe49b69c1,0xefbe4786,0xfc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da," \
21
+ "0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x6ca6351,0x14292967," \
22
+ "0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85," \
23
+ "0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070," \
24
+ "0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3," \
25
+ "0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2"
26
+
27
+ # One SHA-256 over an ASCII string. Returns the 8 state words; the solver reads H[0] only
28
+ # (16 leading zero bits === H[0] >>> 16 === 0, which is exactly the server's pow_ok?(hex, 16)).
29
+ SOLVER = [
30
+ "\nvar __camadaK=[#{K}];\n",
31
+ "function __camadaRr(x,n){return (x>>>n)|(x<<(32-n))}\n",
32
+ "function __camadaSha256Words(msg){\n",
33
+ " var K=__camadaK,rr=__camadaRr,l=msg.length,wl=(((l+9+63)>>6)<<4),M=new Uint32Array(wl),i;\n",
34
+ " for(i=0;i<l;i++)M[i>>2]|=(msg.charCodeAt(i)&255)<<(24-(i%4)*8);\n",
35
+ " M[l>>2]|=0x80<<(24-(l%4)*8);\n",
36
+ " M[wl-1]=l*8;\n",
37
+ " var H=[0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19];\n",
38
+ " var W=new Uint32Array(64),bi,t;\n",
39
+ " for(bi=0;bi<wl;bi+=16){\n",
40
+ " for(t=0;t<16;t++)W[t]=M[bi+t];\n",
41
+ " for(t=16;t<64;t++){var x=W[t-15],y=W[t-2];\n",
42
+ " 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",
43
+ " 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",
44
+ " for(t=0;t<64;t++){\n",
45
+ " var t1=(h+(rr(e,6)^rr(e,11)^rr(e,25))+((e&f)^(~e&g))+K[t]+W[t])>>>0;\n",
46
+ " var t2=((rr(a,2)^rr(a,13)^rr(a,22))+((a&b)^(a&c)^(b&c)))>>>0;\n",
47
+ " h=g;g=f;f=e;e=(d+t1)>>>0;d=c;c=b;b=a;a=(t1+t2)>>>0}\n",
48
+ " 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",
49
+ " 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",
50
+ " return H}\n",
51
+ "function __camadaSha256Hex(msg){\n",
52
+ " var H=__camadaSha256Words(msg),s='',i;\n",
53
+ " for(i=0;i<8;i++)s+=('00000000'+H[i].toString(16)).slice(-8);\n",
54
+ " return s}\n"
55
+ ].join.freeze
56
+
57
+ STYLE = [
58
+ ":root{color-scheme:light}\n",
59
+ "body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;background:#fafafa;color:#1a1a1a;",
60
+ "font:16px/1.5 system-ui,-apple-system,Segoe UI,Roboto,sans-serif}\n",
61
+ "main{max-width:28rem;padding:2rem;text-align:center}\n",
62
+ "h1{font-size:1.25rem;margin:0 0 .5rem}\n",
63
+ "p{margin:.25rem 0;color:#555}\n",
64
+ ".bar{margin:1.5rem auto 0;width:12rem;height:4px;border-radius:2px;background:#e5e5e5;overflow:hidden}\n",
65
+ ".bar i{display:block;height:100%;width:30%;background:#1a1a1a;animation:camada-slide 1.1s ease-in-out infinite}\n",
66
+ "@keyframes camada-slide{0%{transform:translateX(-120%)}100%{transform:translateX(400%)}}\n"
67
+ ].join.freeze
68
+
69
+ # `action` is the verify endpoint path; `to` has already been through safe_return_to.
70
+ def self.challenge_page(nonce:, action:, to:, bits: POW_BITS)
71
+ bits = bits.clamp(1, 32) # shift 32-bits must stay in range
72
+ [
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
+ "<form id=\"camada-f\" method=\"POST\" action=\"#{escape_attr(action)}\">\n",
85
+ "<input type=\"hidden\" name=\"nonce\" value=\"#{escape_attr(nonce)}\">\n",
86
+ "<input type=\"hidden\" name=\"solution\" id=\"camada-s\">\n",
87
+ "<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
+ " 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
+ ].join
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "openssl"
5
+ require_relative "format"
6
+
7
+ module Camada
8
+ module Challenge
9
+ # The challenge kit over OpenSSL's HMAC-SHA256 and Digest's SHA-256, ported from
10
+ # @camada/core src/challenge/verify.ts. Synchronous, so the engine's handle stays a plain method.
11
+ class Kit
12
+ def initialize(secret)
13
+ @secret = secret
14
+ end
15
+
16
+ # Stateless per-(ip, UTC day) nonce; the verify endpoint recomputes it, nothing is stored.
17
+ def nonce(ip, now_ms) = at(ip, Challenge.utc_day(now_ms))
18
+
19
+ # Yesterday still passes: a solve started before midnight UTC must not be thrown away.
20
+ # So one solved (nonce, solution) pair is replayable from its own IP for up to ~48 h,
21
+ # minting a fresh 1 h cookie each time. That is the price of a stateless nonce (§D2) and
22
+ # it is deliberate — do not "fix" it into something that needs shared server state.
23
+ def nonce_valid?(ip, now_ms, nonce)
24
+ return false if ip.nil? || ip.empty? || nonce.nil? || nonce.length != NONCE_HEX
25
+
26
+ day = Challenge.utc_day(now_ms)
27
+ Challenge.safe_equal?(nonce, at(ip, day)) || Challenge.safe_equal?(nonce, at(ip, day - 1))
28
+ end
29
+
30
+ def issue(ip, now_ms)
31
+ exp = now_ms + CHALLENGE_TTL_MS
32
+ "#{exp}.#{hmac(Challenge.token_message(ip, exp))}"
33
+ end
34
+
35
+ # A nil ip is refused outright: without one the token is bound to nothing, so a single
36
+ # solve would mint a cookie every other unidentified client could present. Adapters must
37
+ # fail open (serve no challenge) rather than challenge a client they cannot identify.
38
+ def token_valid?(ip, now_ms, cookie_value)
39
+ return false if ip.nil? || ip.empty?
40
+
41
+ t = Challenge.split_token(cookie_value)
42
+ return false if t.nil?
43
+
44
+ exp, mac = t
45
+ return false if exp <= now_ms || exp > now_ms + CHALLENGE_TTL_MS
46
+
47
+ Challenge.safe_equal?(mac, hmac(Challenge.token_message(ip, exp)))
48
+ end
49
+
50
+ # Proof of work ONLY. Never call it without a passing nonce_valid? for the same nonce.
51
+ def solution_ok?(nonce, solution)
52
+ Challenge.solution_shape_ok?(solution) && Challenge.pow_ok?(Digest::SHA256.hexdigest("#{nonce}.#{solution}"))
53
+ end
54
+
55
+ # The whole submission: the nonce is ours and unexpired, and the work is done.
56
+ def verify?(ip, now_ms, nonce, solution)
57
+ nonce_valid?(ip, now_ms, nonce) && solution_ok?(nonce, solution)
58
+ end
59
+
60
+ private
61
+
62
+ def hmac(msg) = OpenSSL::HMAC.hexdigest("SHA256", @secret, msg)
63
+ def at(ip, day) = hmac(Challenge.nonce_message(ip, day))[0, NONCE_HEX]
64
+ end
65
+
66
+ def self.create_challenge(secret) = Kit.new(secret)
67
+ end
68
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Camada
4
+ # Configuration shapes shared by the client and the engine. `parse_key` splits CAMADA_KEY. The
5
+ # remote config GET /snapshot hands back in x-camada-config (whitelisted server-side) is a
6
+ # plain Hash: { tenant, beacon, sample, exclude, trusted_proxy, poll_seconds } with string keys,
7
+ # as JSON delivers them. A trusted-proxy config mirrors the server-validated tenant config
8
+ # (edge-analyst src/tenant-config.js): {mode: none} | {mode: hops, hops: N} | {mode: cidrs, cidrs: [...]} | {mode: vercel}.
9
+ module Config
10
+ # CAMADA_KEY is `<ingest_token>.<snap_token>` (printed by reconcile instructions and seed).
11
+ def self.parse_key(key)
12
+ return nil if key.nil? || key.empty?
13
+
14
+ dot = key.index(".")
15
+ return nil if dot.nil? || dot <= 0 || dot == key.length - 1
16
+
17
+ [key[0, dot], key[(dot + 1)..]]
18
+ end
19
+
20
+ # CAMADA_TRUSTED_PROXY: none | vercel | hops:N | cidrs:a,b. Unset or malformed returns nil,
21
+ # which callers treat as "defer to the server-delivered tenant config", never as trust.
22
+ def self.parse_trusted_proxy_env(v)
23
+ return nil if v.nil? || v.empty?
24
+ return { "mode" => "none" } if v == "none"
25
+ return { "mode" => "vercel" } if v == "vercel"
26
+
27
+ if v.start_with?("hops:")
28
+ hops = Integer(v[5..], 10, exception: false)
29
+ return hops && hops >= 1 ? { "mode" => "hops", "hops" => hops } : nil
30
+ end
31
+ if v.start_with?("cidrs:")
32
+ cidrs = v[6..].split(",").map(&:strip).reject(&:empty?)
33
+ return cidrs.empty? ? nil : { "mode" => "cidrs", "cidrs" => cidrs }
34
+ end
35
+ nil
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Camada
6
+ # Tap identifier this SDK claims on the wire. The server validates against its own enum and
7
+ # derives the capability mask itself (edge-analyst src/capabilities.js): an SDK can never grant
8
+ # itself capability bits, only name its position — and an unknown name is silently read as a
9
+ # proxy, so this literal is load-bearing.
10
+ TAP = "sdk-ruby"
11
+
12
+ DEFAULT_REFRESH_S = 30.0
13
+ # 5 carries the tenant's ordered custom rules (§D3); a tenant without one is answered with the next container down.
14
+ DEFAULT_SNAPSHOT_VERSION = 5
15
+ KILL_SWITCH_ENV = "CAMADA_DISABLED"
16
+
17
+ SCRIPT_PATH = "/_cam/b.js"
18
+ FP_PATH = "/_cam/fp"
19
+ FP_MAX = 32 * 1024 # matches the server's /fp cap: never accept what ingest will 413
20
+ CHALLENGE_PATH = "/__camada/challenge"
21
+ BODY_MAX = 4 * 1024 # the verify form is ~120 bytes; anything larger is not ours
22
+
23
+ SESSION_COOKIE = "_sfp" # same cookie as the edge collector: sid/ns comparable across taps
24
+ SESSION_MAX_AGE = 2_592_000 # 30 days
25
+
26
+ # nil for nil or "", else the string: the one nil-or-empty helper every module reads through.
27
+ def self.present(s) = s.nil? || s.empty? ? nil : s
28
+
29
+ # JSON.parse that answers nil for anything that is not JSON (a config header, a beacon body).
30
+ def self.parse_json(s)
31
+ JSON.parse(s)
32
+ rescue JSON::ParserError
33
+ nil
34
+ end
35
+ end