Initial ChronoSeal release
This commit is contained in:
commit
a66debdece
48 files changed
+2362
No files matched your search
@@ -0,0 +1,21 @@
|
||||
name: Rust
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Build
|
||||
run: cargo build --workspace --release
|
||||
|
||||
- name: Test
|
||||
run: cargo test --workspace
|
||||
@@ -0,0 +1,7 @@
|
||||
target/
|
||||
pkg/
|
||||
node_modules/
|
||||
dist/
|
||||
*.log
|
||||
.env
|
||||
.idea/
|
||||
@@ -0,0 +1,22 @@
|
||||
# Contributing
|
||||
|
||||
## Requirements
|
||||
|
||||
- Rust stable
|
||||
- wasm-pack
|
||||
- NodeJS (optional frontend tooling)
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
cargo fmt
|
||||
cargo clippy
|
||||
cargo test
|
||||
```
|
||||
|
||||
## Guidelines
|
||||
|
||||
- Keep security-sensitive logic inside Rust/WASM
|
||||
- Avoid placing trust logic in JavaScript
|
||||
- Preserve silent-failure behavior
|
||||
- Maintain deterministic protocol serialization
|
||||
Generated
+1302
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,8 @@
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
|
||||
members = [
|
||||
"shared",
|
||||
"server",
|
||||
"wasm"
|
||||
]
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
FROM rust:1.88-bookworm AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN cargo build -p server --release
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
RUN apt-get update && apt-get install -y \
|
||||
ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /opt/chronoseal
|
||||
|
||||
COPY --from=builder /app/target/release/server /usr/local/bin/chronoseal
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENV RUST_LOG=info
|
||||
|
||||
CMD ["chronoseal"]
|
||||
@@ -0,0 +1,5 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
This project is licensed under GPLv3.
|
||||
https://www.gnu.org/licenses/gpl-3.0.txt
|
||||
@@ -0,0 +1,49 @@
|
||||
# ChronoSeal
|
||||
|
||||
ChronoSeal is a high-security anti-automation and anti-AI-scraping framework built using Rust, WASM, cryptographic heartbeat ledgers, and behavioral attestation.
|
||||
|
||||
## Features
|
||||
|
||||
- Rust + Axum backend
|
||||
- WASM runtime verification
|
||||
- Ed25519 signatures
|
||||
- Blake3 hash-chain continuity
|
||||
- Behavioral entropy collection
|
||||
- Silent anti-bot mitigation
|
||||
- Adaptive trust scoring
|
||||
- Stateless HTTP verification
|
||||
- GPLv3 licensed
|
||||
|
||||
## Architecture
|
||||
|
||||
```text
|
||||
Browser
|
||||
├── WASM VM
|
||||
├── Heartbeat protocol
|
||||
├── Cryptographic ledger
|
||||
└── Behavioral attestation
|
||||
|
||||
Server
|
||||
├── Session validation
|
||||
├── Hash-chain verification
|
||||
├── Trust scoring
|
||||
└── Adaptive mitigation
|
||||
```
|
||||
|
||||
## Build
|
||||
|
||||
### Backend
|
||||
|
||||
```bash
|
||||
cargo run -p server --release
|
||||
```
|
||||
|
||||
### WASM
|
||||
|
||||
```bash
|
||||
wasm-pack build wasm --target web --release
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
GPLv3
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
# Security Policy
|
||||
|
||||
## Reporting Vulnerabilities
|
||||
|
||||
Please do not disclose security vulnerabilities publicly before responsible disclosure.
|
||||
|
||||
Contact maintainers privately with:
|
||||
- reproduction steps
|
||||
- affected versions
|
||||
- impact assessment
|
||||
- proof-of-concept if applicable
|
||||
|
||||
## Scope
|
||||
|
||||
ChronoSeal intentionally operates as:
|
||||
- anti-automation middleware
|
||||
- behavioral attestation layer
|
||||
- cryptographic continuity verifier
|
||||
|
||||
Security hardening evolves continuously.
|
||||
@@ -0,0 +1,35 @@
|
||||
[Unit]
|
||||
Description=ChronoSeal Anti-Bot Service
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
|
||||
User=chronoseal
|
||||
Group=chronoseal
|
||||
|
||||
WorkingDirectory=/opt/chronoseal
|
||||
|
||||
ExecStart=/usr/local/bin/chronoseal
|
||||
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectControlGroups=true
|
||||
|
||||
MemoryDenyWriteExecute=true
|
||||
RestrictRealtime=true
|
||||
RestrictSUIDSGID=true
|
||||
|
||||
LockPersonality=true
|
||||
|
||||
SystemCallArchitectures=native
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,16 @@
|
||||
version: "3.9"
|
||||
|
||||
services:
|
||||
chronoseal:
|
||||
build: .
|
||||
container_name: chronoseal
|
||||
restart: unless-stopped
|
||||
|
||||
ports:
|
||||
- "3000:3000"
|
||||
|
||||
environment:
|
||||
RUST_LOG: info
|
||||
|
||||
tmpfs:
|
||||
- /tmp
|
||||
@@ -0,0 +1,36 @@
|
||||
# ChronoSeal Architecture
|
||||
|
||||
## Core Principles
|
||||
|
||||
- Continuous browser attestation
|
||||
- Cryptographic heartbeat chains
|
||||
- WASM-isolated secrets
|
||||
- Behavioral entropy verification
|
||||
- Silent mitigation
|
||||
|
||||
## Components
|
||||
|
||||
### WASM Runtime
|
||||
|
||||
Responsible for:
|
||||
- heartbeat generation
|
||||
- signature generation
|
||||
- entropy collection
|
||||
- VM execution
|
||||
|
||||
### Server
|
||||
|
||||
Responsible for:
|
||||
- session verification
|
||||
- trust scoring
|
||||
- chain validation
|
||||
- mitigation
|
||||
|
||||
## Threat Model
|
||||
|
||||
Designed to increase:
|
||||
- scraping cost
|
||||
- operational complexity
|
||||
- synchronization burden
|
||||
|
||||
ChronoSeal does not attempt impossible perfect prevention.
|
||||
@@ -0,0 +1,36 @@
|
||||
# Deployment
|
||||
|
||||
## Native
|
||||
|
||||
```bash
|
||||
cargo build -p server --release
|
||||
sudo cp target/release/server /usr/local/bin/chronoseal
|
||||
```
|
||||
|
||||
## systemd
|
||||
|
||||
```bash
|
||||
sudo cp chronoseal.service /etc/systemd/system/
|
||||
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable chronoseal
|
||||
sudo systemctl start chronoseal
|
||||
```
|
||||
|
||||
## Docker
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
## Reverse Proxy
|
||||
|
||||
Recommended:
|
||||
- nginx
|
||||
- Nginx Proxy Manager
|
||||
- HAProxy
|
||||
|
||||
Enable:
|
||||
- HTTP/2
|
||||
- TLS 1.3
|
||||
- aggressive timeout policies
|
||||
@@ -0,0 +1,10 @@
|
||||
const events = [];
|
||||
|
||||
document.addEventListener('mousemove', (e) => {
|
||||
events.push({ x: e.clientX, y: e.clientY, t: performance.now() });
|
||||
if (events.length > 300) events.shift();
|
||||
});
|
||||
|
||||
export function collectEntropy(since) {
|
||||
return events.filter(e => e.t > since);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import init, { generate_keypair, sign_message, compute_next_hash, run_program } from './pkg/antibot_wasm.js';
|
||||
import { collectEntropy } from './entropy.js';
|
||||
import { sendRequest } from './transport.js';
|
||||
|
||||
let session, prevHash, currentSalt, opcodesB64, lastTime;
|
||||
|
||||
export async function initHeartbeat() {
|
||||
await init();
|
||||
const pubHex = generate_keypair();
|
||||
const initResp = await sendRequest('/init', 'POST', { public_key: pubHex });
|
||||
session = initResp.session_id;
|
||||
prevHash = initResp.initial_hash;
|
||||
currentSalt = initResp.salt;
|
||||
opcodesB64 = initResp.opcodes_b64;
|
||||
lastTime = performance.now();
|
||||
scheduleNext();
|
||||
}
|
||||
|
||||
function scheduleNext() {
|
||||
const delay = 12000 + Math.random() * 13000;
|
||||
setTimeout(sendHeartbeat, delay);
|
||||
}
|
||||
|
||||
async function sendHeartbeat() {
|
||||
try {
|
||||
const now = performance.now();
|
||||
const events = collectEntropy(lastTime);
|
||||
lastTime = now;
|
||||
|
||||
const stackState = JSON.stringify(run_program(opcodesB64));
|
||||
const fingerprint = {
|
||||
aspectRatio: (screen.width / screen.height).toFixed(10),
|
||||
devicePixelRatio: String(window.devicePixelRatio),
|
||||
hardwareConcurrency: navigator.hardwareConcurrency || 1
|
||||
};
|
||||
const timestamp = Date.now();
|
||||
const entropyData = { events: events.map(e => ({ x: e.x, y: e.y, t: e.t })) };
|
||||
const entropyJson = JSON.stringify(entropyData);
|
||||
|
||||
const signable = {
|
||||
sessionId: session,
|
||||
prevHash: prevHash,
|
||||
timestamp: timestamp,
|
||||
entropyData: entropyData,
|
||||
stackState: JSON.parse(stackState),
|
||||
fingerprint: fingerprint
|
||||
};
|
||||
const msg = JSON.stringify(signable, Object.keys(signable).sort());
|
||||
const sig = sign_message(msg);
|
||||
|
||||
const resp = await sendRequest('/hb', 'POST', {
|
||||
session_id: session,
|
||||
prev_hash: prevHash,
|
||||
timestamp,
|
||||
entropy_data: entropyData,
|
||||
stack_state: JSON.parse(stackState),
|
||||
fingerprint,
|
||||
signature: sig
|
||||
});
|
||||
|
||||
if (resp.next_salt) {
|
||||
currentSalt = resp.next_salt;
|
||||
prevHash = compute_next_hash(prevHash, timestamp, entropyJson, stackState, currentSalt);
|
||||
} else {
|
||||
console.warn('Heartbeat rejected');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
scheduleNext();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Anti-Scraper Demo</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Protected Page</h1>
|
||||
<p>Move your mouse to generate entropy.</p>
|
||||
<script type="module" src="./main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,5 @@
|
||||
import { initHeartbeat } from './heartbeat.js';
|
||||
|
||||
(async () => {
|
||||
await initHeartbeat();
|
||||
})();
|
||||
@@ -0,0 +1,9 @@
|
||||
export async function sendRequest(url, method, body) {
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
if (!res.ok) throw new Error(`Request failed: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
echo "Building WASM..."
|
||||
cd ../wasm
|
||||
wasm-pack build --target web
|
||||
mv pkg ../frontend/pkg
|
||||
echo "Building server..."
|
||||
cd ../server
|
||||
cargo build --release
|
||||
echo "Done."
|
||||
@@ -0,0 +1,4 @@
|
||||
#!/bin/bash
|
||||
echo "Starting server with static frontend serving..."
|
||||
cd ../server
|
||||
cargo run --release
|
||||
@@ -0,0 +1,5 @@
|
||||
#!/bin/bash
|
||||
bash build.sh
|
||||
echo "Release artifacts:"
|
||||
echo " - server/target/release/antibot-server"
|
||||
echo " - frontend/ (including pkg/)"
|
||||
@@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "antibot-server"
|
||||
version = "0.2.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
shared = { path = "../shared" }
|
||||
axum = "0.7"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
rusqlite = { version = "0.31", features = ["bundled"] }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = "0.3"
|
||||
tower = "0.4"
|
||||
tower-http = { version = "0.5", features = ["cors", "fs"] }
|
||||
hex = "0.4"
|
||||
base64 = "0.22"
|
||||
rand = "0.8"
|
||||
ed25519-dalek = "2"
|
||||
@@ -0,0 +1,14 @@
|
||||
use std::sync::Arc;
|
||||
use crate::session::AppState;
|
||||
|
||||
pub async fn cleanup_loop(state: Arc<AppState>) {
|
||||
loop {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(60)).await;
|
||||
let db = state.db.lock().await; // this is infallible
|
||||
let now = crate::storage::current_time_ms();
|
||||
let _ = db.execute(
|
||||
"DELETE FROM sessions WHERE expires_at < ?1",
|
||||
rusqlite::params![now],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
use ed25519_dalek::{VerifyingKey, Signature};
|
||||
use shared::protocol::HeartbeatRequest;
|
||||
|
||||
pub fn verify_signature(
|
||||
pub_key_bytes: &[u8],
|
||||
req: &HeartbeatRequest,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let pk = VerifyingKey::from_bytes(
|
||||
&pub_key_bytes.try_into().map_err(|_| "invalid pubkey")?,
|
||||
)?;
|
||||
let sig_bytes = hex::decode(&req.signature)?;
|
||||
let sig = Signature::from_slice(&sig_bytes)?;
|
||||
|
||||
// Build canonical JSON exactly as client signed (sorted keys, no extra spaces)
|
||||
let payload = serde_json::json!({
|
||||
"sessionId": req.session_id,
|
||||
"prevHash": req.prev_hash,
|
||||
"timestamp": req.timestamp,
|
||||
"entropyData": req.entropy_data,
|
||||
"stackState": req.stack_state,
|
||||
"fingerprint": req.fingerprint,
|
||||
});
|
||||
let message = serde_json::to_string(&payload)?;
|
||||
|
||||
pk.verify_strict(message.as_bytes(), &sig)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
use shared::protocol::Fingerprint;
|
||||
|
||||
pub fn validate(fp: &Fingerprint) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let ar: f64 = fp.aspect_ratio.parse().map_err(|_| "ar")?;
|
||||
if ar < 0.5 || ar > 3.0 { return Err("aspect ratio".into()); }
|
||||
let dpr: f64 = fp.device_pixel_ratio.parse().map_err(|_| "dpr")?;
|
||||
if dpr <= 0.0 || dpr > 5.0 { return Err("dpr".into()); }
|
||||
if fp.hardware_concurrency == 0 { return Err("hw".into()); }
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
mod cleanup;
|
||||
mod crypto;
|
||||
mod fingerprint;
|
||||
mod middleware;
|
||||
mod ratelimit;
|
||||
mod routes;
|
||||
mod session;
|
||||
mod storage;
|
||||
mod trust;
|
||||
mod vm;
|
||||
|
||||
use axum::Router;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
use tracing::info;
|
||||
|
||||
use session::AppState;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
tracing_subscriber::fmt::init();
|
||||
|
||||
let conn = storage::init_db().expect("DB init");
|
||||
let state = Arc::new(AppState {
|
||||
db: Mutex::new(conn),
|
||||
rate_limiter: Mutex::new(ratelimit::RateLimiter::new(
|
||||
shared::constants::RATE_LIMIT_COUNT,
|
||||
shared::constants::RATE_LIMIT_WINDOW_SECS,
|
||||
)),
|
||||
});
|
||||
|
||||
// Periodic cleanup
|
||||
let bg_state = state.clone();
|
||||
tokio::spawn(async move { cleanup::cleanup_loop(bg_state).await });
|
||||
|
||||
let app = Router::new()
|
||||
.route("/init", axum::routing::post(routes::init::handler))
|
||||
.route("/hb", axum::routing::post(routes::heartbeat::handler))
|
||||
.nest_service("/", tower_http::services::ServeDir::new("../frontend"))
|
||||
.layer(tower_http::cors::CorsLayer::permissive())
|
||||
.layer(axum::middleware::from_fn(middleware::log_request))
|
||||
.with_state(state);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
|
||||
info!("Server running on :3000");
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
use axum::extract::Request;
|
||||
use axum::middleware::Next;
|
||||
use axum::response::Response;
|
||||
|
||||
pub async fn log_request(req: Request, next: Next) -> Response {
|
||||
let method = req.method().clone();
|
||||
let uri = req.uri().clone();
|
||||
let response = next.run(req).await;
|
||||
tracing::info!("{} {} -> {}", method, uri, response.status());
|
||||
response
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
use std::collections::HashMap;
|
||||
use std::time::Instant;
|
||||
|
||||
pub struct RateLimiter {
|
||||
buckets: HashMap<String, (u32, Instant)>,
|
||||
limit: u32,
|
||||
window_secs: u64,
|
||||
}
|
||||
|
||||
impl RateLimiter {
|
||||
pub fn new(limit: u32, window_secs: u64) -> Self {
|
||||
Self { buckets: HashMap::new(), limit, window_secs }
|
||||
}
|
||||
pub fn check(&mut self, key: &str) -> bool {
|
||||
let now = Instant::now();
|
||||
let entry = self.buckets.entry(key.to_string()).or_insert((0, now));
|
||||
if now.duration_since(entry.1).as_secs() >= self.window_secs {
|
||||
*entry = (1, now);
|
||||
true
|
||||
} else if entry.0 >= self.limit {
|
||||
false
|
||||
} else {
|
||||
entry.0 += 1;
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
use axum::{extract::State, http::StatusCode, Json};
|
||||
use std::sync::Arc;
|
||||
use shared::protocol::{HeartbeatRequest, HeartbeatResponse};
|
||||
use crate::session::AppState;
|
||||
|
||||
pub async fn handler(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(payload): Json<HeartbeatRequest>,
|
||||
) -> (StatusCode, Json<HeartbeatResponse>) {
|
||||
// Rate limiting
|
||||
{
|
||||
let mut rl = state.rate_limiter.lock().await;
|
||||
if !rl.check(&payload.session_id) {
|
||||
tracing::debug!("Rate limit hit: {}", payload.session_id);
|
||||
return (StatusCode::OK, Json(HeartbeatResponse { status: "ok".into(), next_salt: None }));
|
||||
}
|
||||
}
|
||||
|
||||
let db = state.db.lock().await;
|
||||
match crate::session::verify_heartbeat(&db, &payload) {
|
||||
Ok(next_salt) => (
|
||||
StatusCode::OK,
|
||||
Json(HeartbeatResponse { status: "ok".into(), next_salt: Some(next_salt) }),
|
||||
),
|
||||
Err(e) => {
|
||||
tracing::warn!("Heartbeat failed for {}: {}", payload.session_id, e);
|
||||
(StatusCode::OK, Json(HeartbeatResponse { status: "ok".into(), next_salt: None }))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
use axum::{extract::State, http::StatusCode, Json};
|
||||
use std::sync::Arc;
|
||||
use shared::protocol::{InitRequest, InitResponse};
|
||||
use crate::session::AppState;
|
||||
|
||||
pub async fn handler(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(payload): Json<InitRequest>,
|
||||
) -> Result<Json<InitResponse>, (StatusCode, String)> {
|
||||
let db = state.db.lock().await;
|
||||
crate::session::create_session(&db, &payload.public_key)
|
||||
.map(Json)
|
||||
.map_err(|e| {
|
||||
tracing::error!("Init error: {}", e);
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "Internal".into())
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod init;
|
||||
pub mod heartbeat;
|
||||
@@ -0,0 +1,98 @@
|
||||
pub struct AppState {
|
||||
pub db: tokio::sync::Mutex<rusqlite::Connection>,
|
||||
pub rate_limiter: tokio::sync::Mutex<crate::ratelimit::RateLimiter>,
|
||||
}
|
||||
|
||||
use rusqlite::params;
|
||||
use shared::protocol::{HeartbeatRequest, InitResponse};
|
||||
use crate::{crypto, trust, fingerprint, vm, storage};
|
||||
|
||||
pub fn create_session(
|
||||
conn: &rusqlite::Connection,
|
||||
pub_key_hex: &str,
|
||||
) -> Result<InitResponse, Box<dyn std::error::Error>> {
|
||||
let pub_key = hex::decode(pub_key_hex)?;
|
||||
if pub_key.len() != shared::constants::SESSION_ID_LEN {
|
||||
return Err("invalid pubkey len".into());
|
||||
}
|
||||
let session_id = hex::encode(rand::random::<[u8; shared::constants::SESSION_ID_LEN]>());
|
||||
let salt = rand::random::<[u8; shared::constants::SALT_LEN]>();
|
||||
let now = storage::current_time_ms();
|
||||
let expires_at = now + (shared::constants::EXPIRATION_MINUTES as u64) * 60 * 1000;
|
||||
|
||||
let initial_hash = shared::hashing::initial_hash(&session_id, &pub_key, &salt);
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO sessions (session_id, public_key, salt, last_hash, created_at, last_seen, expires_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
||||
params![session_id, pub_key, salt.to_vec(), initial_hash, now, now, expires_at],
|
||||
)?;
|
||||
|
||||
let opcodes = vm::generate_random_program(8..=16);
|
||||
let opcodes_b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &opcodes);
|
||||
|
||||
Ok(InitResponse {
|
||||
session_id,
|
||||
salt: hex::encode(salt),
|
||||
opcodes_b64,
|
||||
initial_hash: hex::encode(&initial_hash),
|
||||
expires_at,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn verify_heartbeat(
|
||||
conn: &rusqlite::Connection,
|
||||
req: &HeartbeatRequest,
|
||||
) -> Result<String, Box<dyn std::error::Error>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT public_key, salt, last_hash, expires_at FROM sessions WHERE session_id = ?1",
|
||||
)?;
|
||||
let (pub_key, salt, stored_last_hash, expires_at): (Vec<u8>, Vec<u8>, Vec<u8>, u64) =
|
||||
stmt.query_row(params![req.session_id], |row| {
|
||||
Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
|
||||
})?;
|
||||
|
||||
let now = storage::current_time_ms();
|
||||
if now > expires_at {
|
||||
return Err("expired".into());
|
||||
}
|
||||
|
||||
// 1. Verify signature
|
||||
crypto::verify_signature(&pub_key, req)?;
|
||||
|
||||
// 2. Check chain continuity
|
||||
if stored_last_hash != hex::decode(&req.prev_hash)? {
|
||||
return Err("chain broken".into());
|
||||
}
|
||||
|
||||
// 3. Time window
|
||||
let diff = (now as i64) - (req.timestamp as i64);
|
||||
if diff.abs() > shared::constants::MAX_TIMESTAMP_DRIFT_MS {
|
||||
return Err("timestamp drift".into());
|
||||
}
|
||||
|
||||
// 4. Trusted mouse & fingerprint
|
||||
trust::validate_mouse(&req.entropy_data)?;
|
||||
fingerprint::validate(&req.fingerprint)?;
|
||||
|
||||
// 5. Compute new hash
|
||||
let prev_hash_bytes = hex::decode(&req.prev_hash)?;
|
||||
let new_hash = shared::hashing::next_chain_hash(
|
||||
&prev_hash_bytes,
|
||||
req.timestamp,
|
||||
&req.entropy_data,
|
||||
&req.stack_state,
|
||||
&salt,
|
||||
);
|
||||
|
||||
// 6. New salt for client
|
||||
let next_salt = rand::random::<[u8; shared::constants::SALT_LEN]>();
|
||||
let next_salt_hex = hex::encode(next_salt);
|
||||
|
||||
conn.execute(
|
||||
"UPDATE sessions SET last_hash=?1, salt=?2, chain_length=chain_length+1, last_seen=?3 WHERE session_id=?4",
|
||||
params![new_hash, next_salt.to_vec(), now, req.session_id],
|
||||
)?;
|
||||
|
||||
Ok(next_salt_hex)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
use rusqlite::Connection;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
pub fn init_db() -> Result<Connection, rusqlite::Error> {
|
||||
let conn = Connection::open_in_memory()?;
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS sessions (
|
||||
session_id TEXT PRIMARY KEY,
|
||||
public_key BLOB NOT NULL,
|
||||
salt BLOB NOT NULL,
|
||||
last_hash BLOB NOT NULL,
|
||||
chain_length INTEGER NOT NULL DEFAULT 1,
|
||||
created_at INTEGER NOT NULL,
|
||||
last_seen INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL
|
||||
);",
|
||||
)?;
|
||||
Ok(conn)
|
||||
}
|
||||
|
||||
pub fn current_time_ms() -> u64 {
|
||||
SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_millis() as u64
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
use shared::protocol::EntropyData;
|
||||
|
||||
pub fn validate_mouse(data: &EntropyData) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let events = &data.events;
|
||||
if events.len() < 3 {
|
||||
return Err("few events".into());
|
||||
}
|
||||
let mut total_dist = 0.0;
|
||||
let mut pauses = 0u32;
|
||||
for i in 1..events.len() {
|
||||
let p = &events[i-1];
|
||||
let c = &events[i];
|
||||
let dx = c.x - p.x;
|
||||
let dy = c.y - p.y;
|
||||
let dt = (c.timestamp_ms - p.timestamp_ms).max(1.0);
|
||||
let dist = (dx*dx + dy*dy).sqrt();
|
||||
total_dist += dist;
|
||||
if dist < 0.2 && dt > 50.0 { pauses += 1; }
|
||||
}
|
||||
if total_dist < shared::constants::MIN_MOUSE_TOTAL_DIST {
|
||||
return Err("insufficient distance".into());
|
||||
}
|
||||
let avg_speed = total_dist / events.len() as f64;
|
||||
if avg_speed > shared::constants::MAX_MOUSE_AVG_SPEED {
|
||||
return Err("speed too high".into());
|
||||
}
|
||||
if pauses < shared::constants::MIN_PAUSE_COUNT {
|
||||
return Err("no pause".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
use rand::Rng;
|
||||
|
||||
pub fn generate_random_program(len_range: std::ops::RangeInclusive<usize>) -> Vec<u8> {
|
||||
// Same logic as earlier, using shared::hashing for HASH if needed
|
||||
let mut rng = rand::thread_rng();
|
||||
let count = rng.gen_range(len_range);
|
||||
let mut ops = Vec::new();
|
||||
let mut depth: i32 = 0;
|
||||
for _ in 0..count {
|
||||
if depth < 2 {
|
||||
ops.push(0x00); // PUSH
|
||||
let val = rng.gen::<u32>();
|
||||
ops.extend_from_slice(&val.to_le_bytes());
|
||||
depth += 1;
|
||||
} else {
|
||||
let op = rng.gen_range(0..10);
|
||||
match op {
|
||||
0x00 => {
|
||||
ops.push(0x00);
|
||||
let val = rng.gen::<u32>();
|
||||
ops.extend_from_slice(&val.to_le_bytes());
|
||||
depth += 1;
|
||||
}
|
||||
0x01..=0x08 => { ops.push(op as u8); depth -= 1; }
|
||||
0x09 => { ops.push(0x09); depth = 1; }
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
}
|
||||
ops
|
||||
}
|
||||
|
||||
// Server does not need to execute the program; client does.
|
||||
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "shared"
|
||||
version = "0.2.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1" # <- add this line
|
||||
blake3 = "1"
|
||||
hex = "0.4"
|
||||
base64 = "0.22"
|
||||
rand = "0.8"
|
||||
ed25519-dalek = { version = "2", features = ["rand_core"] }
|
||||
@@ -0,0 +1,11 @@
|
||||
pub const SESSION_ID_LEN: usize = 32;
|
||||
pub const SALT_LEN: usize = 16;
|
||||
pub const HEARTBEAT_MIN_INTERVAL_MS: u64 = 12_000;
|
||||
pub const HEARTBEAT_MAX_INTERVAL_MS: u64 = 25_000;
|
||||
pub const EXPIRATION_MINUTES: i64 = 30;
|
||||
pub const RATE_LIMIT_COUNT: u32 = 5;
|
||||
pub const RATE_LIMIT_WINDOW_SECS: u64 = 10;
|
||||
pub const MAX_TIMESTAMP_DRIFT_MS: i64 = 30_000;
|
||||
pub const MIN_MOUSE_TOTAL_DIST: f64 = 10.0;
|
||||
pub const MAX_MOUSE_AVG_SPEED: f64 = 2.0; // px/ms
|
||||
pub const MIN_PAUSE_COUNT: u32 = 1;
|
||||
@@ -0,0 +1,42 @@
|
||||
use blake3::Hasher;
|
||||
use crate::protocol::{EntropyData, StackState};
|
||||
|
||||
/// Initial hash for a brand-new session: Blake3(session_id || pub_key || salt)
|
||||
pub fn initial_hash(session_id: &str, pub_key: &[u8], salt: &[u8]) -> Vec<u8> {
|
||||
let mut h = Hasher::new();
|
||||
h.update(session_id.as_bytes());
|
||||
h.update(pub_key);
|
||||
h.update(salt);
|
||||
h.finalize().as_bytes().to_vec()
|
||||
}
|
||||
|
||||
/// Next hash in the chain: Blake3 with the salt mixed in (no keyed mode needed)
|
||||
pub fn next_chain_hash(
|
||||
prev_hash: &[u8],
|
||||
timestamp: u64,
|
||||
entropy: &EntropyData,
|
||||
stack: &StackState,
|
||||
salt: &[u8],
|
||||
) -> Vec<u8> {
|
||||
let entropy_json = serde_json::to_string(entropy).unwrap();
|
||||
let stack_json = serde_json::to_string(stack).unwrap();
|
||||
|
||||
let entropy_hash = blake3::hash(entropy_json.as_bytes());
|
||||
let stack_hash = blake3::hash(stack_json.as_bytes());
|
||||
|
||||
let mut h = Hasher::new();
|
||||
// Mix the salt into the hash state
|
||||
h.update(salt);
|
||||
h.update(prev_hash);
|
||||
h.update(×tamp.to_le_bytes());
|
||||
h.update(entropy_hash.as_bytes());
|
||||
h.update(stack_hash.as_bytes());
|
||||
h.finalize().as_bytes().to_vec()
|
||||
}
|
||||
|
||||
/// Hash of all stack items for VM HASH opcode
|
||||
pub fn hash_stack(stack: &[u32]) -> u32 {
|
||||
let data: Vec<u8> = stack.iter().flat_map(|x| x.to_le_bytes()).collect();
|
||||
let hash = blake3::hash(&data);
|
||||
u32::from_le_bytes(hash.as_bytes()[..4].try_into().unwrap())
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod constants;
|
||||
pub mod hashing;
|
||||
pub mod protocol;
|
||||
@@ -0,0 +1,62 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
pub struct InitRequest {
|
||||
pub public_key: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct InitResponse {
|
||||
pub session_id: String,
|
||||
pub salt: String,
|
||||
pub opcodes_b64: String,
|
||||
pub initial_hash: String,
|
||||
pub expires_at: u64,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
pub struct HeartbeatRequest {
|
||||
pub session_id: String,
|
||||
pub prev_hash: String,
|
||||
pub timestamp: u64,
|
||||
pub entropy_data: EntropyData,
|
||||
pub stack_state: StackState,
|
||||
pub fingerprint: Fingerprint,
|
||||
pub signature: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct HeartbeatResponse {
|
||||
pub status: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub next_salt: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
pub struct Fingerprint {
|
||||
#[serde(rename = "aspectRatio")]
|
||||
pub aspect_ratio: String,
|
||||
#[serde(rename = "devicePixelRatio")]
|
||||
pub device_pixel_ratio: String,
|
||||
#[serde(rename = "hardwareConcurrency")]
|
||||
pub hardware_concurrency: u32,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
pub struct EntropyData {
|
||||
pub events: Vec<MouseEvent>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug)]
|
||||
pub struct MouseEvent {
|
||||
pub x: f64,
|
||||
pub y: f64,
|
||||
#[serde(rename = "t")]
|
||||
pub timestamp_ms: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StackState {
|
||||
pub stack: Vec<u32>,
|
||||
pub ip: u16,
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "antibot-wasm"
|
||||
version = "0.2.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
shared = { path = "../shared" }
|
||||
wasm-bindgen = "0.2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
ed25519-dalek = { version = "2", features = ["rand_core"] }
|
||||
rand = "0.8" # <-- add this
|
||||
blake3 = "1"
|
||||
getrandom = { version = "0.2", features = ["js"] }
|
||||
hex = "0.4"
|
||||
base64 = "0.22"
|
||||
serde-wasm-bindgen = "0.6"
|
||||
@@ -0,0 +1,2 @@
|
||||
// Example: break debugger detection, console clearing, etc.
|
||||
// Currently empty.
|
||||
@@ -0,0 +1,47 @@
|
||||
use ed25519_dalek::{SigningKey, Signer};
|
||||
use std::cell::RefCell;
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
thread_local! {
|
||||
static KEYPAIR: RefCell<Option<SigningKey>> = RefCell::new(None);
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn generate_keypair() -> String {
|
||||
let mut rng = rand::thread_rng();
|
||||
let sk = SigningKey::generate(&mut rng);
|
||||
let pk = sk.verifying_key();
|
||||
let hex_pub = hex::encode(pk.as_bytes());
|
||||
KEYPAIR.with(|kp| *kp.borrow_mut() = Some(sk));
|
||||
hex_pub
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn get_public_key() -> String {
|
||||
KEYPAIR.with(|kp| hex::encode(kp.borrow().as_ref().unwrap().verifying_key().as_bytes()))
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn sign_message(message_json: &str) -> String {
|
||||
KEYPAIR.with(|kp| {
|
||||
let sk = kp.borrow();
|
||||
let sig = sk.as_ref().unwrap().sign(message_json.as_bytes());
|
||||
hex::encode(sig.to_bytes())
|
||||
})
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn compute_next_hash(
|
||||
prev_hash_hex: &str,
|
||||
timestamp: u64,
|
||||
entropy_data_json: &str,
|
||||
stack_state_json: &str,
|
||||
salt_hex: &str,
|
||||
) -> String {
|
||||
let prev = hex::decode(prev_hash_hex).unwrap();
|
||||
let salt = hex::decode(salt_hex).unwrap();
|
||||
let entropy = serde_json::from_str::<shared::protocol::EntropyData>(entropy_data_json).unwrap();
|
||||
let stack = serde_json::from_str::<shared::protocol::StackState>(stack_state_json).unwrap();
|
||||
let new = shared::hashing::next_chain_hash(&prev, timestamp, &entropy, &stack, &salt);
|
||||
hex::encode(&new)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// This module is handled on the JS side; WASM only receives the prepared entropy data.
|
||||
// Could be used to add extra entropy sources (e.g., from JS via import).
|
||||
@@ -0,0 +1 @@
|
||||
// Fingerprint collection is done in JS, this module is a placeholder.
|
||||
@@ -0,0 +1,6 @@
|
||||
pub mod anti_debug;
|
||||
pub mod crypto;
|
||||
pub mod entropy;
|
||||
pub mod fingerprint;
|
||||
pub mod transport;
|
||||
pub mod vm;
|
||||
@@ -0,0 +1 @@
|
||||
// Could contain WebTransport related code if needed later.
|
||||
@@ -0,0 +1,55 @@
|
||||
use wasm_bindgen::prelude::*;
|
||||
use shared::protocol::StackState;
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn run_program(program_b64: &str) -> JsValue {
|
||||
use base64::Engine;
|
||||
let bytes = base64::engine::general_purpose::STANDARD.decode(program_b64).unwrap();
|
||||
let state = execute(&bytes);
|
||||
serde_wasm_bindgen::to_value(&state).unwrap()
|
||||
}
|
||||
|
||||
fn execute(program: &[u8]) -> StackState {
|
||||
let mut stack: Vec<u32> = Vec::new();
|
||||
let mut ip: usize = 0;
|
||||
while ip < program.len() {
|
||||
let op = program[ip];
|
||||
ip += 1;
|
||||
match op {
|
||||
0x00 => {
|
||||
if ip + 4 > program.len() { break; }
|
||||
let val = u32::from_le_bytes([program[ip], program[ip+1], program[ip+2], program[ip+3]]);
|
||||
ip += 4;
|
||||
stack.push(val);
|
||||
}
|
||||
0x01..=0x07 => {
|
||||
if stack.len() < 2 { break; }
|
||||
let b = stack.pop().unwrap();
|
||||
let a = stack.pop().unwrap();
|
||||
let r = match op {
|
||||
0x01 => a.wrapping_add(b),
|
||||
0x02 => a.wrapping_sub(b),
|
||||
0x03 => a.wrapping_mul(b),
|
||||
0x04 => a ^ b,
|
||||
0x05 => a & b,
|
||||
0x06 => a | b,
|
||||
0x07 => a.rotate_left(b % 32),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
stack.push(r);
|
||||
}
|
||||
0x08 => {
|
||||
if stack.is_empty() { break; }
|
||||
let a = stack.pop().unwrap();
|
||||
stack.push(!a);
|
||||
}
|
||||
0x09 => {
|
||||
let r = shared::hashing::hash_stack(&stack);
|
||||
stack.clear();
|
||||
stack.push(r);
|
||||
}
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
StackState { stack, ip: ip as u16 }
|
||||
}
|
||||
Reference in new issue
Block a user