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 +7 -0
- data/LICENSE +21 -0
- data/README.md +199 -0
- data/lib/camada/beacon_js.rb +10 -0
- data/lib/camada/body_proxy.rb +48 -0
- data/lib/camada/challenge/format.rb +108 -0
- data/lib/camada/challenge/page.rb +107 -0
- data/lib/camada/challenge/verify.rb +68 -0
- data/lib/camada/config.rb +38 -0
- data/lib/camada/constants.rb +35 -0
- data/lib/camada/engine.rb +364 -0
- data/lib/camada/env.rb +47 -0
- data/lib/camada/events/build.rb +82 -0
- data/lib/camada/events/queue.rb +184 -0
- data/lib/camada/guarded.rb +34 -0
- data/lib/camada/ip.rb +80 -0
- data/lib/camada/ipparse.rb +100 -0
- data/lib/camada/rack.rb +205 -0
- data/lib/camada/railtie.rb +17 -0
- data/lib/camada/redact.rb +64 -0
- data/lib/camada/snapshot/client.rb +199 -0
- data/lib/camada/snapshot/match.rb +221 -0
- data/lib/camada/snapshot/parse.rb +363 -0
- data/lib/camada/transport.rb +58 -0
- data/lib/camada/version.rb +10 -0
- data/lib/camada.rb +99 -0
- metadata +74 -0
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("&", "&").gsub("<", "<").gsub(">", ">").gsub('"', """).gsub("'", "'")
|
|
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
|