docs: update README and docs for v0.6.0 architecture and deployment, preserve current server/shared/wasm updates

This commit is contained in:
thakares committed 2026-05-29 21:27:11 +05:30
1 parent 6067746898
commit 2b8afd54e0
27 files changed
+1395 -2549

No files matched your search

+2
View File
@@ -5,3 +5,5 @@ dist/
*.log *.log
.env .env
.idea/ .idea/
.antigravitycli/
Generated
+12
View File
@@ -269,6 +269,7 @@ dependencies = [
"tracing", "tracing",
"tracing-appender", "tracing-appender",
"tracing-subscriber", "tracing-subscriber",
"valkey",
] ]
[[package]] [[package]]
@@ -285,6 +286,7 @@ dependencies = [
"serde-wasm-bindgen", "serde-wasm-bindgen",
"serde_json", "serde_json",
"shared", "shared",
"tracing",
"wasm-bindgen", "wasm-bindgen",
] ]
@@ -1303,6 +1305,7 @@ dependencies = [
"rand 0.8.6", "rand 0.8.6",
"serde", "serde",
"serde_json", "serde_json",
"tracing",
] ]
[[package]] [[package]]
@@ -1749,6 +1752,15 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "valkey"
version = "0.0.0-alpha5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "591043068c3f8db7fc1dcf34852eb03994175d39045cf01ad036c099f3c4f888"
dependencies = [
"tracing",
]
[[package]] [[package]]
name = "valuable" name = "valuable"
version = "0.1.1" version = "0.1.1"
+97 -656
View File
@@ -5,11 +5,11 @@
</p> </p>
<p align="center"> <p align="center">
<strong>Cryptographic attestation daemon and anti-automation framework.</strong> <strong>Unix-native cryptographic attestation daemon for browser session continuity.</strong>
</p> </p>
<p align="center"> <p align="center">
Privacy-preserving • Unix-native • Lightweight • WASM-powered Privacy-first • Deterministic WASM parity • Silent rejection • Low overhead
</p> </p>
<p align="center"> <p align="center">
@@ -27,82 +27,69 @@
--- ---
ChronoSeal is a lightweight cryptographic attestation daemon designed to raise the operational cost of browser automation, scraping, replay attacks, and synthetic interaction. ChronoSeal is a mature Unix-native cryptographic attestation daemon for browser session continuity and anti-automation defense.
Instead of relying on: It provides a low-overhead, privacy-respecting proof-of-runtime system built around a deterministic **Synthetic Gene Mutation Engine** and a silent, replay-resistant heartbeat protocol.
* CAPTCHA systems v0.6.0 introduces the core innovation: a deterministic synthetic gene mutation chain with server/WASM parity, stronger liveness guarantees, and a domain-separated mutation commitment handshake.
* invasive browser fingerprinting
* telemetry-heavy tracking
* persistent identifiers
ChronoSeal establishes a continuous cryptographic proof-of-runtime continuity using: ---
* WASM execution ## What ChronoSeal Provides
* chained cryptographic heartbeats
* Native Linux daemon with hardened `systemd` integration
* Deterministic mutation engine running in both server Rust and client WASM
* Silent rejection semantics for attacker resilience
* Multi-backend storage: `sqlite-in-memory`, `sqlite-disk`, and `valkey`
* CLI-first operation with rich subcommands
* Structured logging, PID file management, graceful shutdown
* Prometheus-compatible metrics and runtime statistics
* Lightweight browser runtime with WASM-based attestation
* Privacy-first design with ephemeral session state and no persistent tracking
---
## Why ChronoSeal
ChronoSeal raises the operational cost of automation by combining:
* cryptographic session continuity
* deterministic VM execution
* behavioral entropy validation * behavioral entropy validation
* ephemeral attestation state * mutation commitment parity
* silent, ambiguous rejection behavior
while remaining completely invisible and frictionless to legitimate human users. This is not a fingerprinting or surveillance platform. ChronoSeal is designed to make automation expensive, not to collect user identities.
v0.6.0 adds a deterministic synthetic gene mutation chain (hybrid `Vec<u8>` gene + bounded environment records) to strengthen anti-replay continuity with server/WASM parity.
See [docs/REFRACTORING-v0.6.0.md](docs/REFRACTORING-v0.6.0.md) for the full refactoring details.
--- ---
# Features ## Quick Start
* CLI-first Unix-native architecture ### Install
* Rich operational subcommands
* Machine-readable JSON/YAML outputs
* Hardened systemd integration
* Graceful shutdown and signal handling
* One-line installation workflow
* Prometheus-compatible metrics
* WASM-based client runtime
* Ed25519 + Blake3 cryptographic chaining
* Behavioral entropy validation
* Randomized stack-machine verification
* Deterministic synthetic gene mutation chain
* Server/WASM mutation parity checks
* Silent rejection model
* SQLite-backed ephemeral sessions
* Configurable runtime DB backend selection (`db_type`)
* Connection-pooled runtime architecture
* Lightweight deployment footprint
* Docker and native deployment support
* Adaptive trust scoring
* GPLv3 licensed
---
# Quick Start
## Install
```bash ```bash
sudo bash scripts/install.sh sudo bash scripts/install.sh
``` ```
## Check Status ### Verify status
```bash ```bash
chronoseal status --format json chronoseal status --format json
``` ```
## Health Probe ### Health probe
```bash ```bash
chronoseal health chronoseal health
``` ```
## View Metrics ### View metrics
```bash ```bash
chronoseal metrics chronoseal metrics
``` ```
## View Logs ### Follow logs
```bash ```bash
sudo journalctl -u chronoseal -f sudo journalctl -u chronoseal -f
@@ -110,13 +97,13 @@ sudo journalctl -u chronoseal -f
--- ---
# CLI ## CLI Overview
```bash ```bash
chronoseal --help chronoseal --help
``` ```
## Available Commands ### Available commands
| Command | Description | | Command | Description |
| ------------ | ------------------------------------------ | | ------------ | ------------------------------------------ |
@@ -151,635 +138,89 @@ chronoseal status --format json
--- ---
# How It Works ## Architecture Summary
ChronoSeal establishes a continuous cryptographic proof-of-presence for browser sessions. ChronoSeal is composed of three primary runtime components:
The system is inspired by heartbeat validation models used in embedded and distributed systems. * `shared/` — shared cryptographic primitives, hash chaining, gene model, and mutation engine used by both server and WASM
* `server/` — Axum-based Unix-native daemon, session lifecycle, storage, trust evaluation, and `POST /init` / `POST /hb` routes
* `wasm/` — browser runtime for key generation, signature creation, VM execution, and mutation preview/commit lifecycle
## Session Flow ### Key innovations in v0.6.0
```text * Synthetic Gene Mutation Engine with deterministic, shared opcode semantics
Browser Server * Server-side gene commitment validation on every heartbeat
│ │ * `mutation_step` and `mutation_order_b64` handshake in init and heartbeat responses
│ WASM loads, generates Ed25519 keypair │ * `db_type` runtime backend selection with SQLite and Valkey support
│ Private key never leaves WASM memory │
│ │
├──── POST /init { public_key } ──────────►│
│◄─── { session_id, salt, opcodes_b64, H0, │
│ mutation_step, mutation_order_b64 } ──┤
│ │
│ Every 12–25s (randomized): │
│ ┌─ Collect behavioral entropy │
│ ├─ Execute verification VM opcodes │
│ ├─ Preview mutation commitment │
│ ├─ Attach mutation_step + commitment │
│ ├─ Advance Blake3 hash chain │
│ └─ Sign payload using Ed25519 │
│ │
├──── POST /hb { signed_payload } ────────►│
│◄─── { status, next_salt, │
│ next_mutation_step, │
│ next_mutation_order_b64 } ────────────┤
│ │
│ Invalid sessions silently rejected │
│ (`status=ok` without next_* fields) │
```
The server validates:
* signature authenticity
* heartbeat continuity
* replay resistance
* mutation step parity
* mutation commitment parity
* behavioral entropy
* timestamp validity
* fingerprint sanity
--- ---
# Security Model ## How ChronoSeal Works
## What ChronoSeal Protects Against ChronoSeal establishes continuity by chaining signed heartbeats between client and server.
| Threat | Mechanism | ### Session flow
| ------------------------ | ------------------------------------- |
| Replay attacks | Blake3 chained heartbeat continuity | 1. Client loads the WASM runtime and generates an Ed25519 keypair in WASM memory.
| Signature forgery | Ed25519 keypair generated inside WASM | 2. Client calls `POST /init` with the public key.
| Session cloning | Ephemeral session-bound keypairs | 3. Server creates an ephemeral session and returns a `session_id`, initial salt, VM program, and mutation order metadata.
| Static scraping | Runtime participation requirements | 4. Client executes the VM program, collects browser entropy, previews the mutation commitment, signs the heartbeat payload, and sends `POST /hb`.
| Naive browser automation | Behavioral continuity validation | 5. Server verifies signature, hash chain continuity, behavioral sanity, mutation step parity, and gene commitment before returning the next salt and mutation order.
| Timestamp replay | Drift-window enforcement |
| Session flooding | Per-session rate limiting | ### Silent failure model
| Mutation tampering | Server-side commitment parity checks |
Invalid heartbeats are returned as `{"status":"ok"}` without mutation fields. This avoids giving attackers explicit feedback.
--- ---
## Silent Rejection Model ## Storage Backends
ChronoSeal intentionally avoids explicit rejection semantics. ChronoSeal supports multiple runtime storage backends configured via `db_type`:
Invalid sessions may still receive: * `sqlite-in-memory` — default ephemeral session storage
* `sqlite-disk` — persisted SQLite storage on disk
```json * `valkey` — alternative backend compatibility mode for future high-performance storage
{ "status": "ok" }
```
This prevents:
* oracle-style probing
* protocol learning
* easy automation tuning
* behavioral enumeration
--- ---
## What ChronoSeal Does Not Claim ## Deployment
ChronoSeal is a cost-raising mechanism, not an impenetrable barrier. ChronoSeal is intended to run as a systemd-managed Unix daemon with strict sandboxing and observable metrics.
A sufficiently motivated adversary with: See [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md) for build, installation, and production deployment guidance.
* real browsers
* genuine input devices
* enough reverse engineering effort
can eventually bypass the system.
The goal is to make automation:
* expensive
* operationally complex
* difficult to scale
* harder to replay deterministically
--- ---
# Architecture ## Security Model
```text ChronoSeal is a cost-raising attestations layer, not a perfect bot blocker.
chronoseal-rs/
├── shared/ Shared types, hash chain, gene + mutation engine It protects against:
├── server/ Axum HTTP daemon
│ ├── routes/ API routes * replay attacks
│ ├── session.rs Session lifecycle + mutation parity checks * session cloning
│ ├── crypto.rs Ed25519 verification * invalid signature injection
│ ├── trust.rs Behavioral validation * broken hash chain continuity
│ ├── fingerprint/ Browser sanity validation * mutation tampering
│ ├── vm.rs Random opcode generator * simple synthetic mouse and browser automation
│ ├── ratelimit.rs Token bucket limiter
│ ├── cleanup.rs Session expiration lifecycle It does not attempt to protect against:
│ └── metrics.rs Prometheus metrics
├── wasm/ Rust → WASM runtime * real users acting as bots
│ ├── crypto.rs Signing + hash chaining * server-side application vulnerabilities
│ ├── vm.rs Stack-machine executor * fully resourced adversaries with real browsers and hardware input devices
│ └── vm_extensions.rs Gene mutation preview/commit
├── frontend/ Lightweight JS integration See [docs/THREAT_MODEL.md](docs/THREAT_MODEL.md) for the full threat model.
├── scripts/ Build/install/dev scripts
└── docs/ Project documentation
```
--- ---
# Stack Machine ## Further Reading
ChronoSeal includes a lightweight randomized stack-machine execution engine. * [Architecture](docs/ARCHITECTURE.md)
* [API Reference](docs/API.md)
The server generates a randomized opcode program during session initialization. * [Deployment](docs/DEPLOYMENT.md)
* [Threat Model](docs/THREAT_MODEL.md)
The client executes this program on every heartbeat and includes the resulting stack state in the signed payload. * [Design Philosophy](docs/DESIGN-PHILOSOPHY.md)
* [Privacy Policy](docs/PRIVACY%20POLICY.md)
This makes heartbeat payloads structurally dynamic. * [WASM Build](docs/WASM_BUILD.md)
* [Refactoring v0.6.0](docs/REFRACTORING-v0.6.0.md)
## Supported Opcodes
| Opcode | Mnemonic | Effect |
| ------ | -------- | ----------------------- |
| `0x00` | PUSH | Push literal |
| `0x01` | ADD | Wrapping addition |
| `0x02` | SUB | Wrapping subtraction |
| `0x03` | MUL | Wrapping multiplication |
| `0x04` | XOR | Bitwise XOR |
| `0x05` | AND | Bitwise AND |
| `0x06` | OR | Bitwise OR |
| `0x07` | ROT | Rotate left |
| `0x08` | NOT | Unary inversion |
| `0x09` | HASH | Blake3 stack hash |
## Mutation Opcodes (v0.6.0)
| Opcode | Mnemonic | Effect |
| ------ | -------------------- | ------ |
| `0x23` | GENE_LOAD | Push `gene[idx]` |
| `0x24` | GENE_STORE | Pop and store at `gene[idx]` |
| `0x25` | MUTATE_POINT | Apply wrapping byte delta at index |
| `0x26` | INSERT | Insert popped byte at index |
| `0x27` | DELETE | Delete byte at index and push removed value |
| `0x28` | TRANSCRIBE | Push deterministic transcription hash |
| `0x29` | APPLY_MUTAGEN | Mix environment symbol quantity into gene byte |
| `0x2A` | FINALIZE_GENE_HASH | Push commitment-derived `u32` |
| `0x2B` | CONSUME | Pop amount, subtract environment quantity |
| `0x2C` | PRODUCE | Pop amount, add environment quantity |
---
# Hash Chain
ChronoSeal uses Blake3 chained continuity validation.
## Initial Hash
```text
H(0) = Blake3( session_id ║ public_key ║ salt₀ )
```
## Heartbeat Progression
```text
H(n) = Blake3(
saltₙ₋₁ ║
H(n-1) ║
timestamp ║
Blake3(entropy_json) ║
Blake3(stack_json)
)
```
Each heartbeat depends on:
* prior continuity
* prior server-issued salt
* behavioral entropy
* VM execution result
* timestamp progression
---
# Signature Canonicalization
Heartbeat payloads are serialized into canonical key order before signing.
The server reconstructs payloads identically before:
* Ed25519 verification
* hash progression validation
This prevents:
* serialization inconsistencies
* ambiguous signing layouts
* malformed payload tricks
---
# SQLite Schema
```sql
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,
gene BLOB NOT NULL DEFAULT X'',
environment BLOB NOT NULL DEFAULT X'',
pending_mutation BLOB NOT NULL DEFAULT X'',
pending_mutation_step INTEGER NOT NULL DEFAULT 0
);
```
ChronoSeal intentionally uses ephemeral session persistence.
Session continuity is designed to reset transparently.
---
# v0.6.0 — Synthetic Gene Mutation System
## Overview & Motivation
ChronoSeal v0.6.0 introduces a synthetic mutation chain model to strengthen attestation liveness and anti-replay guarantees while preserving privacy-first behavior. The core model combines:
* a primary byte-oriented gene buffer (`Vec<u8>`), and
* a bounded secondary environment map (`Vec<(u16 symbol, u32 quantity)>`).
Each heartbeat now carries deterministic mutation progression evidence (`mutation_step`, `gene_commitment`) that is validated server-side against the exact server-issued mutation order. This design increases attacker workload by coupling cryptographic chain continuity with stateful deterministic mutation parity.
## Architectural Goals
1. Keep runtime behavior deterministic across server and WASM execution.
2. Preserve ephemerality and low operational complexity.
3. Minimize additional latency on the heartbeat path.
4. Improve protocol resistance against replay and mutation tampering.
5. Maintain a maintainable codebase with explicit invariants and focused modules.
## Design Decisions
**Shared mutation engine** — Mutation opcode semantics live in `shared/src/vm_extensions.rs` to guarantee server/client parity from one implementation.
**Deterministic gene commitment** — A domain-separated BLAKE3 commitment (`chronoseal/gene/v1`) binds both gene bytes and sorted environment records.
**Bounded mutation complexity** — Mutation program length is capped (`MAX_MUTATION_PROGRAM_BYTES`) and environment cardinality is capped (`MAX_ENV_RECORDS`).
**Strict validation on ingest** — Environment payloads are validated for sortedness, uniqueness, non-zero quantity, and length constraints.
**Protocol-level mutation handshake** — `InitResponse` and `Heartbeat` payloads now include mutation step/order and commitment fields.
**DB backend control via `db_type`** — Server CLI/config now supports:
* `sqlite-in-memory` (default)
* `sqlite-in-disk` (active; uses `db_path`)
* `valkey` (active compatibility mode; currently falls back to in-memory)
## Implementation
1. Gene model + deterministic commitment in `shared/gene.rs`.
2. v0.6.0 mutation opcode set in shared VM extensions.
3. Mutation state persisted per session (`gene`, `environment`, `pending_mutation`, `pending_mutation_step`).
4. Protocol schema extended for mutation fields in init/heartbeat exchange.
5. Mutation step + commitment parity validated before accepting heartbeat updates.
6. WASM preview/commit mutation lifecycle mirrors server behavior.
7. `db_type` CLI/config flow and runtime backend initialization strategy.
8. Migration-safe schema extension (column existence checks + index creation).
## Testing Strategy
ChronoSeal v0.6.0 test coverage is organized across unit, integration, and randomized/fuzz-style validation.
**Unit, integration, and property tests:**
* Unit tests for gene invariants and encoding/decoding.
* Unit tests for every mutation opcode with stack-effect assertions.
* Integration tests for full session lifecycle and heartbeat acceptance/rejection paths.
* Table-driven randomized tests and fuzz-style random bytecode tests to validate deterministic failure/success symmetry.
**Server-client parity testing:**
* Shared opcode engine parity tests across seeded mutation sequences.
* Multi-step mutation chain test (`test_mutation_chain`) asserting identical server/client final state.
* 10+ heartbeat deterministic simulation tests in session integration suite.
**Evasion / attack simulation testing:**
* Replay attack simulation.
* Mutation step mismatch rejection.
* Mutation commitment tampering rejection.
* Malformed server mutation payload rejection.
* Stack underflow / unknown opcode / truncated program rejection.
**Performance regression testing:**
* Bounded execution checks through capped program size and bounded record counts.
* Timing smoke regression test for mutation execution loops.
* End-to-end heartbeat test coverage to detect behavior regressions on hot paths.
## Security Analysis
**Replay resistance** — Heartbeats are now tied to both chain hash and mutation step progression.
**Mutation tampering resistance** — Server recomputes candidate gene state from authoritative pending mutation program and rejects commitment mismatch.
**Protocol ambiguity reduction** — Canonical signing payload includes mutation fields, reducing exploitable unsigned state.
**Input hardening** — Program size limits, stack underflow checks, and strict environment decoding reduce parser abuse and malformed payload amplification.
**Deterministic failure semantics** — Invalid mutation instructions fail predictably and symmetrically across server and WASM paths.
## Performance Considerations
* Mutation instructions are lightweight and mostly O(1); only `INSERT`/`DELETE` are O(n) but bounded by max gene size.
* Environment operations use sorted-vector binary search with tight upper bound (`MAX_ENV_RECORDS`).
* Commitment hashing is linear in gene size and record count, both bounded.
* Shared engine avoids duplicate logic and divergence-induced debugging overhead.
## Migration & Backward Compatibility
* Schema migration is additive; new columns are created when missing.
* Existing deployments without mutation fields require updated client+server pair for heartbeat compatibility.
* `db_type` defaults to in-memory to preserve ephemeral behavior.
* `sqlite-in-disk` is now directly usable via `db_path`.
* `valkey` currently runs in compatibility mode (in-memory fallback) to avoid startup failure while preserving CLI contract.
## Risks & Mitigations
| Risk | Mitigation |
| ---- | ---------- |
| State divergence between server and client | Shared opcode engine + deterministic seeded parity tests + multi-heartbeat integration tests |
| Mutation opcode abuse via malformed programs | Strict parsing, length caps, explicit underflow/unknown-opcode errors |
| Performance regressions | Bounded structures, smoke timing tests, focused hot-path validation |
| Backend confusion during `db_type` rollout | Explicit CLI command (`chronoseal db-type`), config output visibility, and clear runtime compatibility behavior |
---
# Runtime Architecture
## Server Runtime
* Rust
* Axum
* Tokio
* SQLite (`sqlite-in-memory` / `sqlite-in-disk`)
* `db_type=valkey` compatibility mode (falls back to in-memory in v0.6.0)
* `r2d2`
* `thiserror`
## Browser Runtime
* Rust → WASM
* Ed25519 signing
* Blake3 chaining
* Stack-machine execution
---
# Deployment
## Recommended Installation
```bash
sudo bash scripts/install.sh
```
The installer:
* creates `chronoseal` service user
* builds release artifacts
* installs frontend assets
* deploys hardened systemd service
* enables and starts daemon
---
## Manual Installation
```bash
bash scripts/build.sh
sudo cp target/release/chronoseal /usr/local/bin/
sudo cp chronoseal.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now chronoseal
```
---
## Docker
```bash
docker compose up -d --build
```
---
# Development
## Full Build
```bash
bash scripts/build.sh
```
## Development Mode
```bash
bash scripts/dev.sh
```
## Direct Execution
```bash
cargo run -p server -- run --bind 127.0.0.1:3000
```
---
# Prerequisites
* Rust stable ≥ 1.87
* `wasm-pack`
* NodeJS (optional frontend tooling)
Install `wasm-pack`:
```bash
cargo install wasm-pack
```
Build backend:
```bash
cargo run -p server --release
```
Build WASM:
```bash
wasm-pack build wasm --target web --release
```
---
# Configuration
## Precedence
```text
CLI flags > CHRONOSEAL_* environment variables > config file > defaults
```
## Default Config Locations
```text
/etc/chronoseal/config.toml
$XDG_CONFIG_HOME/chronoseal/config.toml
~/.config/chronoseal/config.toml
```
## Runtime State
```text
~/.local/state/chronoseal/
```
## Database Backend Selection (v0.6.0)
Choose backend with config, env var, or CLI flag:
* Config: `db_type = "sqlite-in-memory" | "sqlite-in-disk" | "valkey"`
* Env: `CHRONOSEAL_DB_TYPE=...`
* CLI: `chronoseal run --db-type sqlite-in-disk --db-path /var/lib/chronoseal/chronoseal.sqlite`
Inspect backend status:
```bash
chronoseal db-type --format text
```
---
# Observability
ChronoSeal exposes:
* health probes
* runtime statistics
* Prometheus metrics
## Metrics Example
```bash
chronoseal metrics
```
```text
# HELP chronoseal_sessions Active ChronoSeal sessions
# TYPE chronoseal_sessions gauge
chronoseal_sessions 1
```
---
# Lightweight Runtime
Current release artifacts:
```text
chronoseal ~8.5 MB
chronoseal_wasm.wasm ~719 KB
```
ChronoSeal intentionally avoids:
* heavyweight frontend frameworks
* Electron-style packaging
* telemetry-heavy dependencies
* oversized runtime models
---
# Philosophy
ChronoSeal is intentionally not:
* a surveillance framework
* invasive browser fingerprinting
* a CAPTCHA replacement
* a telemetry ecosystem
ChronoSeal is:
* a cryptographic attestation runtime
* a behavioral continuity engine
* a proof-of-runtime framework
* a lightweight Unix-native daemon
---
# Contributing
## Requirements
* Rust stable
* wasm-pack
* NodeJS (optional frontend tooling)
## Development Workflow
```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
---
# 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.
---
# Language Breakdown
| Language | Share |
| ---------- | ------ |
| Rust | 82.4% |
| JavaScript | 13.7% |
| Shell | 1.6% |
| Dockerfile | 1.4% |
| HTML | 0.9% |
---
Topics: `rust` · `cryptography` · `wasm` · `antibot` · `browser-security` · `behavioral-analysis` · `anti-scraping` · `headless-detection`
+94 -131
View File
@@ -1,20 +1,18 @@
# ChronoSeal — API Reference # ChronoSeal API Reference
ChronoSeal defines a small, deterministic API surface for browser attestation and heartbeat verification.
## Base URL ## Base URL
All endpoints are relative to the server root. In development: `http://localhost:3000`. All endpoints are relative to the server root. In development: `http://localhost:3000`. In production: the HTTPS origin of the protected site.
In production: your HTTPS domain via reverse proxy.
--- ---
## Endpoints ## POST /init
### `POST /init` Initialise a new browser session.
Initialise a new session. Called once per page load, immediately after the ### Request
WASM module generates an Ed25519 keypair.
#### Request
```http ```http
POST /init POST /init
@@ -29,16 +27,16 @@ Content-Type: application/json
| Field | Type | Description | | Field | Type | Description |
|---|---|---| |---|---|---|
| `public_key` | `string` | Hex-encoded 32-byte Ed25519 verifying key generated by the WASM module | | `public_key` | `string` | Hex-encoded 32-byte Ed25519 public key generated by the WASM runtime |
#### Response `200 OK` ### Response `200 OK`
```json ```json
{ {
"session_id": "64-char hex string (32 bytes)", "session_id": "64-char hex string",
"salt": "32-char hex string (16 bytes)", "salt": "32-char hex string",
"opcodes_b64": "base64-encoded VM program (8–16 opcodes)", "opcodes_b64": "base64-encoded VM program",
"initial_hash": "64-char hex string (32 bytes Blake3)", "initial_hash": "64-char hex string",
"expires_at": 1234567890123, "expires_at": 1234567890123,
"heartbeat_min_interval_ms": 12000, "heartbeat_min_interval_ms": 12000,
"heartbeat_max_interval_ms": 25000, "heartbeat_max_interval_ms": 25000,
@@ -50,29 +48,28 @@ Content-Type: application/json
| Field | Type | Description | | Field | Type | Description |
|---|---|---| |---|---|---|
| `session_id` | `string` | Opaque session identifier; include in every heartbeat | | `session_id` | `string` | Opaque session identifier for the current browser session |
| `salt` | `string` | Initial salt; used to compute `H(0)` and first `H(1)` | | `salt` | `string` | Random 16-byte salt used to seed the hash chain |
| `opcodes_b64` | `string` | Base64 VM program; execute with `run_program()` on every heartbeat | | `opcodes_b64` | `string` | Base64-encoded randomized VM program executed on every heartbeat |
| `initial_hash` | `string` | `H(0) = Blake3(session_id ║ pub_key ║ salt)`; the first `prev_hash` | | `initial_hash` | `string` | Initial chain hash `H(0)` used as `prev_hash` for the first heartbeat |
| `expires_at` | `number` | Unix timestamp in milliseconds; session expires after 30 minutes of inactivity | | `expires_at` | `number` | Unix timestamp in milliseconds after which the session expires |
| `heartbeat_min_interval_ms` | `number` | Lower bound for randomized heartbeat scheduling | | `heartbeat_min_interval_ms` | `number` | Minimum heartbeat interval in milliseconds |
| `heartbeat_max_interval_ms` | `number` | Upper bound for randomized heartbeat scheduling | | `heartbeat_max_interval_ms` | `number` | Maximum heartbeat interval in milliseconds |
| `gene_size` | `number` | Initial synthetic gene size used by server and WASM (default 512) | | `gene_size` | `number` | Size of the initial synthetic gene buffer |
| `mutation_step` | `number` | Server-issued mutation order step expected on next heartbeat | | `mutation_step` | `number` | Initial mutation step expected on the first heartbeat |
| `mutation_order_b64` | `string` | Base64-encoded mutation opcode program for the current step | | `mutation_order_b64` | `string` | Base64-encoded mutation order for gene commitment preview |
#### Error ### Error
Returns `500 Internal Server Error` only on server-side failures (DB errors, `POST /init` returns `500 Internal Server Error` only for server-side failures such as invalid public key length or persistence errors. No detailed error information is exposed to callers.
invalid public key length). No meaningful error body is returned.
--- ---
### `POST /hb` ## POST /hb
Submit a heartbeat. Called every 12–25 seconds with uniform random jitter. Submit a heartbeat to continue the session.
#### Request ### Request
```http ```http
POST /hb POST /hb
@@ -86,8 +83,7 @@ Content-Type: application/json
"timestamp": 1234567890123, "timestamp": 1234567890123,
"entropy_data": { "entropy_data": {
"events": [ "events": [
{ "x": 412.0, "y": 308.5, "t": 1234.567 }, { "x": 412.0, "y": 308.5, "t": 1234.567 }
{ "x": 415.2, "y": 310.1, "t": 1285.123 }
] ]
}, },
"stack_state": { "stack_state": {
@@ -96,73 +92,72 @@ Content-Type: application/json
}, },
"fingerprint": { "fingerprint": {
"aspectRatio": "1.7777777778", "aspectRatio": "1.7777777778",
"devicePixelRatio": "2", "devicePixelRatio": 2,
"hardwareConcurrency": 8 "hardwareConcurrency": 8
}, },
"mutation_step": 1, "mutation_step": 1,
"gene_commitment": "64-char hex Blake3 commitment", "gene_commitment": "64-char hex",
"signature": "128-char hex Ed25519 signature" "signature": "128-char hex"
} }
``` ```
| Field | Type | Description | | Field | Type | Description |
|---|---|---| |---|---|---|
| `session_id` | `string` | Session ID from `/init` | | `session_id` | `string` | Session ID from `/init` |
| `prev_hash` | `string` | Hash chain head from previous heartbeat (or `initial_hash` for the first) | | `prev_hash` | `string` | Previous hash chain head (`initial_hash` on first heartbeat) |
| `timestamp` | `number` | `Date.now()` in milliseconds; must be within ±30s of server time | | `timestamp` | `number` | `Date.now()` in milliseconds |
| `entropy_data.events` | `array` | Mouse events since previous heartbeat; each has `x`, `y` (px), `t` (performance.now ms) | | `entropy_data.events` | `array` | Mouse event list since the previous heartbeat |
| `stack_state.stack` | `array` | `u32[]` result of executing the VM program | | `stack_state.stack` | `array` | VM stack contents after program execution |
| `stack_state.ip` | `number` | Instruction pointer after execution | | `stack_state.ip` | `number` | VM instruction pointer after execution |
| `fingerprint.aspectRatio` | `string` | `(screen.width / screen.height).toFixed(10)` | | `fingerprint.aspectRatio` | `string` | `screen.width / screen.height` to 10 decimal places |
| `fingerprint.devicePixelRatio` | `string` | `String(window.devicePixelRatio)` | | `fingerprint.devicePixelRatio` | `number` | `window.devicePixelRatio` |
| `fingerprint.hardwareConcurrency` | `number` | `navigator.hardwareConcurrency \|\| 1` | | `fingerprint.hardwareConcurrency` | `number` | `navigator.hardwareConcurrency || 1` |
| `mutation_step` | `number` | Must match server-side pending mutation step | | `mutation_step` | `number` | Current mutation step sent by the client |
| `gene_commitment` | `string` | Commitment of the locally previewed candidate gene after applying `mutation_order_b64` | | `gene_commitment` | `string` | Gene commitment produced by the WASM preview mutation engine |
| `signature` | `string` | Hex-encoded 64-byte Ed25519 signature over the canonical payload | | `signature` | `string` | Hex-encoded 64-byte Ed25519 signature over the canonical payload |
#### Canonical Signing Payload ### Canonical Signing Payload
The client signs the following JSON object. Top-level keys must be sorted The client signs a canonical JSON object with top-level keys sorted alphabetically:
alphabetically. Nested object keys follow their natural serialisation order.
```json ```json
{ {
"entropyData": { "events": [{ "t": …, "x": …, "y": … }] }, "entropyData": { "events": [{ "t": ..., "x": ..., "y": ... }] },
"fingerprint": { "aspectRatio": "…", "devicePixelRatio": "…", "hardwareConcurrency": … }, "fingerprint": {
"geneCommitment":"…", "aspectRatio": "...",
"mutationStep": …, "devicePixelRatio": ...,
"prevHash": "…", "hardwareConcurrency": ...
"sessionId": "…", },
"stackState": { "ip": …, "stack": […] }, "geneCommitment": "...",
"timestamp": … "mutationStep": ...,
"prevHash": "...",
"sessionId": "...",
"stackState": { "ip": ..., "stack": [...] },
"timestamp": ...
} }
``` ```
Note: field names in the signing payload use camelCase (`sessionId`, Note: the signed payload uses camelCase while the transport request uses snake_case.
`prevHash`, `entropyData`, `stackState`, `mutationStep`, `geneCommitment`)
while the request body uses snake_case (`session_id`, `prev_hash`,
`entropy_data`, `stack_state`, `mutation_step`, `gene_commitment`).
#### Response `200 OK` — Accepted ### Response `200 OK` — Accepted
```json ```json
{ {
"status": "ok", "status": "ok",
"next_salt": "32-char hex string (16 bytes)", "next_salt": "32-char hex string",
"next_mutation_step": 2, "next_mutation_step": 2,
"next_mutation_order_b64": "base64-encoded mutation program" "next_mutation_order_b64": "base64-encoded mutation program"
} }
``` ```
The client must: | Field | Type | Description |
1. Preview commitment locally from `mutation_order_b64` and send it in the heartbeat. |---|---|---|
2. Capture `sentSalt = currentSalt` before updating. | `status` | `string` | Always `ok` |
3. Set `currentSalt = next_salt`. | `next_salt` | `string` | Next server salt for the following heartbeat |
4. Compute `prevHash = compute_next_hash(prevHash, timestamp, entropyJson, stackStateJson, sentSalt)`. | `next_mutation_step` | `number` | Next mutation step to apply after acceptance |
5. Commit the previewed gene state. | `next_mutation_order_b64` | `string` | Base64-encoded next mutation program |
6. Replace pending mutation values with `next_mutation_step` and `next_mutation_order_b64`.
#### Response `200 OK` — Rejected ### Response `200 OK` — Rejected
```json ```json
{ {
@@ -170,77 +165,45 @@ The client must:
} }
``` ```
`next_salt`, `next_mutation_step`, and `next_mutation_order_b64` are absent. A rejected heartbeat omits `next_salt`, `next_mutation_step`, and `next_mutation_order_b64`.
The response body is intentionally identical in
structure. Rejections are silent — the caller cannot distinguish a validation
failure from a rate limit hit or an expired session.
The client should log a warning and continue scheduling heartbeats (they will This silent rejection model avoids giving attackers distinct failure signals.
continue to fail until the page is reloaded and a new session is established).
--- ---
## Validation Rules (Server-Side) ## Validation Rules
Heartbeats are rejected (silently) if any of the following checks fail: Heartbeats are rejected silently when any validation step fails:
| Check | Condition for rejection | * session missing or expired
|---|---| * signature invalid
| Rate limit | > 5 requests per 10-second window for this `session_id` | * hash chain mismatch
| Session not found | `session_id` not in SQLite | * mutation step mismatch
| Session expired | `current_time_ms > expires_at` | * gene commitment mismatch
| Signature invalid | Ed25519 verification fails against stored public key | * timestamp outside ±30 seconds
| Hash chain broken | `hex(prev_hash) ≠ stored last_hash` | * insufficient mouse events
| Mutation step mismatch | `mutation_step ≠ pending_mutation_step` | * insufficient mouse movement
| Mutation commitment mismatch | `gene_commitment` does not match server-computed candidate commitment | * unrealistic speed profile
| Timestamp drift | `\|server_now_ms - timestamp\| > 30 000` | * missing pause intervals
| Insufficient mouse events | `events.len() < 3` | * invalid fingerprint values
| Insufficient mouse distance | `total_dist < 10.0 px` |
| Mouse speed too high | `total_dist / total_time_ms > 2.0 px/ms` |
| No mouse pauses | `pause_count < 1` |
| Invalid aspect ratio | `ar < 0.5` or `ar > 3.0` |
| Invalid devicePixelRatio | `dpr ≤ 0.0` or `dpr > 5.0` |
| Zero hardwareConcurrency | `hardware_concurrency == 0` |
--- ---
## Hash Chain Specification ## WASM Runtime Exports
``` The WASM module exports the following functions to JavaScript:
H(0) = Blake3( session_id_bytes ║ pub_key_bytes ║ salt₀_bytes )
H(n) = Blake3(
saltₙ₋₁_bytes
║ H(n-1)_bytes
║ timestamp_u64_le_bytes
║ Blake3( UTF-8( JSON(entropy_data) ) )
║ Blake3( UTF-8( JSON(stack_state) ) )
)
```
All inputs are concatenated in the order shown. `timestamp` is encoded as a
64-bit unsigned integer in little-endian byte order. JSON serialisation of
`entropy_data` and `stack_state` uses the field order defined by the shared
Rust types (serde derive, no custom ordering).
---
## WASM API
The WASM module (`chronoseal_wasm`) exports the following functions to JavaScript:
| Function | Signature | Description | | Function | Signature | Description |
|---|---|---| |---|---|---|
| `generate_keypair()` | `() → string` | Generate Ed25519 keypair; return hex public key. Private key stored in WASM memory. | | `generate_keypair()` | `() -> string` | Generate a new Ed25519 keypair and return the public key hex |
| `get_public_key()` | `() → string` | Return hex public key, or `""` if not initialised. | | `get_public_key()` | `() -> string` | Return the current public key hex |
| `sign_message(msg)` | `(string) → string` | Sign UTF-8 string; return hex signature, or `""` if not initialised. | | `sign_message(msg)` | `(string) -> string` | Sign a UTF-8 string payload and return the hex signature |
| `compute_next_hash(prev, ts, entropy, stack, salt)` | `(string, u64, string, string, string) → string` | Compute next Blake3 chain hash; all inputs/output hex or JSON strings. | | `compute_next_hash(prev, ts, entropy, stack, salt)` | `(string, u64, string, string, string) -> string` | Compute the next Blake3 hash chain value |
| `run_program(b64)` | `(string) → JsValue` | Execute base64 VM program; return `{ stack: u32[], ip: number }`. | | `run_program(b64)` | `(string) -> JsValue` | Execute a base64 VM program and return stack state |
| `init_gene_state(gene_size)` | `(u32) → bool` | Initialise synthetic gene state in WASM memory. | | `init_gene_state(gene_size)` | `(u32) -> bool` | Initialise the synthetic gene buffer in WASM memory |
| `preview_gene_commitment(order_b64)` | `(string) → string` | Apply mutation order on preview state and return commitment hex. | | `preview_gene_commitment(order_b64)` | `(string) -> string` | Preview the next gene commitment from a mutation order |
| `commit_gene_preview()` | `() → bool` | Commit previewed mutation state after accepted heartbeat. | | `commit_gene_preview()` | `() -> bool` | Commit the previewed mutation after an accepted heartbeat |
| `discard_gene_preview()` | `() → void` | Discard previewed mutation state after rejection/error. | | `discard_gene_preview()` | `() -> void` | Discard the previewed mutation after rejection or error |
| `current_gene_commitment()` | `() → string` | Return current committed gene commitment hex. | | `current_gene_commitment()` | `() -> string` | Return the current committed gene commitment |
String-returning functions return `""` on error rather than panicking. Callers String-returning functions return `""` on error. Callers must handle empty values and boolean failures gracefully.
must check for empty strings and boolean return values before use.
+107 -488
View File
@@ -1,526 +1,145 @@
# ChronoSeal — Architecture # ChronoSeal Architecture
> Note (v0.6.0): Synthetic Gene Mutation flow and mutation handshake updates are documented in [REFRACTORING-v0.6.0.md](https://github.com/thakares/chronoseal-rs/blob/main/docs/REFRACTORING-v0.6.0.md) and [API.md](https://github.com/thakares/chronoseal-rs/blob/main/docs/API.md). ChronoSeal is a Unix-native cryptographic attestation daemon that validates browser session continuity through deterministic VM execution, chained cryptographic state, and a shared Synthetic Gene Mutation Engine.
## Overview ## Overview
ChronoSeal is a stateless, cryptographic browser attestation framework. Its ChronoSeal is designed as a production-grade infrastructure component, not as a consumer-facing widget. It is a lightweight daemon that can be operated, monitored, and integrated like any other native Linux service.
purpose is to make automated clients (headless browsers, AI scrapers, API
harvesters) computationally expensive and operationally complex to operate,
while remaining completely invisible to real human users.
The design is inspired by the heartbeat model used in embedded IoT firmware: Key characteristics:
a device that stops sending signed, chained attestations is assumed to be
offline or compromised. ChronoSeal applies the same principle to browser
sessions.
--- * Unix-native daemon with systemd-compatible lifecycle
* CLI-first control plane and configuration
* Shared Rust/WASM runtime for server/client parity
* Modular storage backend abstraction (`sqlite-in-memory`, `sqlite-disk`, `valkey`)
* Silent rejection semantics for attacker resilience
* Privacy-preserving ephemeral session state
## Design Principles ## Core Components
**Stateless per request.** The server carries no per-request state beyond what ### `shared/`
is stored in SQLite keyed on `session_id`. Every HTTP request is independently
verifiable.
**Silent failure.** Validation failures never return an error status or an Shared protocol and runtime primitives used by both the server and the browser runtime:
error body. The server always responds `{"status":"ok"}` and simply omits `next_salt`. The client degrades gracefully. Attackers cannot enumerate
validation rules by probing error responses.
**Private key isolation.** The Ed25519 signing key is generated inside the * Cryptographic primitives: Blake3, Ed25519
WASM module and never serialised, never exposed to the JavaScript environment, * Hash chain logic and session commitment handling
and never transmitted. It exists only in WASM linear memory for the lifetime * Synthetic gene model and deterministic mutation engine
of the page. * Serialization, encoding, and canonical signing helpers
**Layered validation.** A heartbeat must pass five independent checks: session ### `server/`
existence, expiry, signature, hash chain, and behavioral signals. Bypassing
one layer is not sufficient.
**Cost asymmetry.** Each heartbeat requires a real browser environment, mouse The server crate implements the runtime daemon:
activity, correct WASM execution, chain state synchronisation, and a valid
Ed25519 signature over a time-windowed payload. For an automated client, the
synchronisation burden alone makes scaled operation expensive.
**Deterministic mutation parity (v0.6.0).** Each heartbeat additionally carries * `routes/init.rs` — session initialization API
a `mutation_step` and `gene_commitment` derived from a server-issued mutation * `routes/heartbeat.rs` — heartbeat verification API
program. Server and WASM execute the same shared opcode engine * `session.rs` — session lifecycle, mutation parity, and heartbeat validation
(`shared/src/vm_extensions.rs`), and the server rejects any heartbeat where * `storage.rs` — backend abstraction and persistence
the recomputed commitment does not match the client-supplied value. * `crypto.rs` — signature verification and key handling
* `trust.rs` — behavioral entropy and sanity validation
* `ratelimit.rs` — per-session request throttling
* `cleanup.rs` — expiration and eviction tasks
* `runtime.rs` — daemon bootstrap, metrics, and state management
### High-Level Design ### `wasm/`
- **Core**: Rust + Axum (async web framework) The client runtime crate compiles to WebAssembly and powers attestation in the browser.
- **Storage**: `db_type` selectable (`sqlite-in-memory`, `sqlite-in-disk`, `valkey` compatibility mode)
- **Client**: WASM + Rust (runs in browser for proof generation)
- **Security Model**: Behavioral analysis + hash chaining + entropy scoring + deterministic gene mutation parity
- **Deployment**: Static musl binary, systemd service, optional Docker
### Key Components * `crypto.rs` — in-WASM signing and hash computation
* `vm.rs` — randomized opcode VM execution
* `vm_extensions.rs` — synthetic gene mutation preview and commit lifecycle
- `shared/` — Types, constants, crypto primitives, gene model, and mutation engine used by server and WASM ### `frontend/`
- `server/` — Axum routes, session management, trust engine, rate limiting, cleanup tasks
- `wasm/` — Client-side proof generation, mutation preview/commit lifecycle
- `frontend/` — Static assets served by the application
### Unix-Native Design Decisions Static browser integration code that loads the WASM module, orchestrates init/heartbeat flow, and collects browser entropy.
- Runs as a proper systemd service with strict sandboxing ## v0.6.0 Innovation
- All state is either in-memory or in standard locations (`/run/`, `/var/log/`, `/etc/`)
- Graceful shutdown and reload support via signals
- Logging designed for `journalctl` and structured parsing
- Configuration is fully runtime (no recompile needed)
### Design Goal The primary innovation in v0.6.0 is the **Synthetic Gene Mutation Engine**.
## ChronoSeal should feel as natural to use as `nginx` or `redis-server` on a Linux system. This layer adds a deterministic, shared server/WASM mutation handshake to the existing heartbeat continuity model.
## Component Map Key v0.6.0 behavior:
* `mutation_order_b64` is issued at session initialization and after every accepted heartbeat
* `mutation_step` is tracked on both client and server
* `gene_commitment` is computed locally in WASM and validated by the server
* mutation state is persisted per session and advanced only on accepted heartbeats
* scalar mutation programs are deterministic and bounded in cost
This makes replay and tampering attacks significantly more expensive while preserving the existing privacy-first and silent-failure semantics.
## Architecture Diagram
``` ```
┌─────────────────────────────────────────────────────────┐ Browser Server
│ Browser │ ┌──────────────────────────────────────────────────────────────┐
│ │ │ frontend/ + WASM runtime │
│ ┌─────────────┐ ┌──────────────┐ ┌─────────────┐ │ │ - generate_keypair() │
│ │ entropy.js │ │ heartbeat.js │ │ transport.js│ │ │ - sign_message() │
│ │ │ │ │ │ │ │ │ - compute_next_hash() │
│ │ mousemove │──►│ orchestrates │──►│ fetch POST │ │ │ - run_program() │
│ │ event ring │ │ init + HB │ │ /init /hb │ │ │ - preview_gene_commitment() │
│ └─────────────┘ └──────┬───────┘ └─────────────┘ │ │ - commit_gene_preview() │
│ │ │
│ ┌──────▼───────────────────────┐ │
│ │ WASM Module (antibot_wasm) │ │
│ │ │ │
│ │ crypto.rs vm.rs │ │
│ │ ├ generate_keypair() │ │
│ │ ├ sign_message() │ │
│ │ ├ compute_next_hash() │ │
│ │ ├ run_program() │ │
│ │ vm_extensions.rs │ │
│ │ ├ preview_mutation() │ │
│ │ └ commit_mutation() │ │
│ └──────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
│ HTTPS
┌─────────────────────────▼───────────────────────────────┐
│ Server (Axum) │
│ │
│ routes/init.rs routes/heartbeat.rs │
│ │ │ │
│ └──────────┬───────────────┘ │
│ ▼ │
│ session.rs │
│ ├ create_session() │
│ └ verify_heartbeat() │
│ └ validate_mutation_parity() [v0.6.0] │
│ │ │
│ ┌──────────┼──────────────┐ │
│ ▼ ▼ ▼ │
│ crypto.rs trust.rs fingerprint.rs │
│ (sig verify) (mouse (aspect ratio, │
│ speed) DPR, HW conc.) │
│ │ │
│ ▼ │
│ shared::hashing (Blake3 hash chain) │
│ shared::gene (gene model + commitment) [v0.6.0] │
│ shared::vm_extensions (mutation opcodes) [v0.6.0] │
│ │ │
│ ▼ │
│ storage.rs (SQLite: in-memory / in-disk / valkey) │
│ │
│ ratelimit.rs cleanup.rs vm.rs middleware.rs │
└─────────────────────────────────────────────────────────┘
```
---
## Session Lifecycle
### 1. Initialisation — `POST /init`
```
Client Server
│ │ │ │
│ generate Ed25519 keypair (in WASM) │ │ POST /init -> │
│ pub_key = verifying_key.to_bytes() │ │ POST /hb -> │
│ │ └──────────────────────────────────────────────────────────────┘
├─── { public_key: hex(pub_key) } ──────►│ │
│ │ session_id = rand::random::<[u8;32]>() ▼
│ │ salt₀ = rand::random::<[u8;16]>() ┌──────────────────────────────────────────────────────────────┐
│ │ H(0) = Blake3(session_id║pub_key║salt₀) │ server/ │
│ │ opcodes = generate_random_program(8..=16) │ - signature validation │
│ │ gene = initial gene buffer [v0.6.0] │ - hash chain continuity │
│ │ mutation_order = generate_mutation_program() [v0.6.0] │ - rate limiting │
│ │ INSERT INTO sessions … │ - behavioral trust checks │
│ │ │ - mutation step validation │
│◄── { session_id, salt, opcodes_b64, │ │ - gene commitment verification │
│ initial_hash, expires_at, │ │ - session persistence │
│ mutation_step, │ [v0.6.0] │ - metrics and health │
│ mutation_order_b64 } ─────────────┤ [v0.6.0] └──────────────────────────────────────────────────────────────┘
│ │ │
│ prevHash = initial_hash │ ▼
│ currentSalt = salt │ ┌──────────────────────────────────────────────────────────────┐
│ opcodesB64 = opcodes_b64 │ │ storage backends │
│ mutationStep = mutation_step │ [v0.6.0] │ - sqlite-in-memory │
│ mutationOrderB64 = mutation_order_b64 │ [v0.6.0] │ - sqlite-disk │
│ - valkey compatibility mode │
└──────────────────────────────────────────────────────────────┘
``` ```
### 2. Heartbeat — `POST /hb` ## Storage Backends
Fired every 12–25 seconds with uniform random jitter. ChronoSeal supports pluggable backend modes using the `db_type` configuration option.
``` * `sqlite-in-memory` — default ephemeral session storage. No persistence across restarts.
Client Server * `sqlite-disk` — persisted SQLite database on disk through `db_path`.
│ │ * `valkey` — compatibility mode for alternative storage backends, currently supported alongside SQLite compatibility semantics.
│ stackState = run_program(opcodesB64) │
│ commitment = preview_mutation( │ [v0.6.0]
│ mutationOrderB64, mutationStep) │
│ events = collectEntropy(lastTime)│
│ ts = Date.now() │
│ │
│ signable = { │
│ entropyData, fingerprint, │ ← keys sorted alphabetically
│ prevHash, sessionId, │
│ stackState, timestamp, │
│ mutation_step, gene_commitment │ [v0.6.0]
│ } │
│ sig = sign_message( │
│ JSON.stringify(signable, keys.sort))│
│ │
├─── { session_id, prev_hash, timestamp, │
│ entropy_data, stack_state, │
│ fingerprint, signature, │
│ mutation_step, gene_commitment }─►│ [v0.6.0]
│ │ 1. Rate limit check
│ │ 2. Lookup session, check expiry
│ │ 3. Verify Ed25519 signature
│ │ 4. Verify hash chain continuity
│ │ 5. Validate timestamp window ±30s
│ │ 6. Validate mouse behavior
│ │ 7. Validate fingerprint signals
│ │ 8. Validate mutation step parity [v0.6.0]
│ │ 9. Validate gene commitment [v0.6.0]
│ │ 10. Compute H(n), rotate salt
│ │ 11. Advance gene state [v0.6.0]
│ │ 12. UPDATE sessions …
│ │
│◄── { status: "ok", next_salt, │
│ next_mutation_step, │ [v0.6.0]
│ next_mutation_order_b64 } ────────┤ [v0.6.0]
│ │
│ sentSalt = currentSalt ◄── captured BEFORE rotation
│ currentSalt = next_salt │
│ mutationStep = next_mutation_step │ [v0.6.0]
│ mutationOrderB64 = next_mutation_order_b64 [v0.6.0]
│ prevHash = compute_next_hash( │
│ prevHash, ts, entropy, │
│ stackState, sentSalt) │
```
### 3. Failure Path ## Runtime Philosophy
On any validation failure the server returns `{"status":"ok"}` with no `next_salt`. The client logs a warning and continues scheduling heartbeats. ChronoSeal is intentionally designed to behave like traditional Unix infrastructure software:
The chain is broken — subsequent heartbeats will also fail silently.
No error is surfaced to the page or its visitors.
--- * explicit CLI operations (`run`, `status`, `health`, `config`, `metrics`, `stats`, `db-type`)
* structured logging for `journalctl`
* PID file management and graceful shutdown
* systemd sandbox support
* runtime configuration via TOML and CLI overrides
* clear separation of protocol, persistence, and runtime concerns
## Cryptographic Protocol ## Integration Points
### Key Generation * Browser clients consume the WASM module and call `/init` and `/hb`
* Existing sites can proxy these API routes through their own web server
* Frontend assets can be served by ChronoSeal directly or mounted in a sidecar deployment
* TLS termination should be handled by a reverse proxy in production
``` ## Operating Assumptions
Ed25519 keypair generated via ed25519-dalek + rand::thread_rng (OS-seeded)
Private key: stored in WASM thread_local, never leaves WASM memory
Public key: 32 bytes, hex-encoded, sent to server at init
```
### Hash Chain ChronoSeal is not a general-purpose authentication service. It is a cryptographic attestation and anti-automation layer intended to be integrated with existing site logic.
``` It assumes:
H(0) = Blake3( session_id ║ pub_key ║ salt₀ )
H(n) = Blake3( * browser clients can execute WASM
saltₙ₋₁ ← server-side only, rotated each heartbeat * heartbeats will arrive every 12–25 seconds
║ H(n-1) ← must match stored last_hash * session state can be safely persisted in SQLite or Valkey
║ timestamp_u64_le * service operators want Unix-native systemd deployment and observability
║ Blake3( JSON(entropy_data) )
║ Blake3( JSON(stack_state) )
)
```
Salt rotation means an attacker who intercepts a heartbeat cannot compute
future chain links without also intercepting every subsequent server response.
### Gene Commitment (v0.6.0)
A domain-separated BLAKE3 commitment binds both the gene buffer and the sorted
environment records into a single 32-byte value that is included in the signed
heartbeat payload and validated server-side:
```
gene_commitment = BLAKE3(
"chronoseal/gene/v1" ← domain separator
║ gene_bytes ← Vec<u8> gene buffer
║ for each (symbol, qty) sorted by symbol:
symbol_u16_le ║ qty_u32_le
)
```
The server recomputes the candidate gene state from its authoritative
`pending_mutation` program and rejects any heartbeat where
`recomputed_commitment != client_gene_commitment`.
### Canonical Signing Payload
The signed message is a JSON object with top-level keys sorted alphabetically,
serialised with no extra whitespace:
```
{
"entropyData": { "events": [{"t":…,"x":…,"y":…}] },
"fingerprint": { "aspectRatio":"…","devicePixelRatio":"…","hardwareConcurrency":… },
"gene_commitment": "hex…",
"mutation_step": N,
"prevHash": "hex…",
"sessionId": "hex…",
"stackState": { "ip":…,"stack":[…] },
"timestamp": 1234567890123
}
```
The server reconstructs this using `std::collections::BTreeMap` (alphabetical
key order) before calling `VerifyingKey::verify_strict`. Any field mismatch,
key order difference, or whitespace difference causes a signature failure.
### Hashing Algorithm
Blake3 is used throughout: hash chain links, entropy data digest, stack state
digest, gene commitment, and the VM HASH opcode. Blake3 is chosen for speed
in WASM, resistance to length-extension attacks, and a clean Rust API.
---
## Stack Machine
The server generates a random program on session init. The client executes it
on every heartbeat and includes the resulting `StackState { stack, ip }` in
the signed payload. This ensures each heartbeat carries unique, verifiable
computation without additional round-trips.
### Core Instruction Set
| Opcode | Mnemonic | Operand | Stack effect | Description |
| ------ | -------- | ----------- | ------------ | -------------------------------------- |
| `0x00` | PUSH | u32 (4B LE) | +1 | Push literal |
| `0x01` | ADD | — | −1 | `a + b` wrapping |
| `0x02` | SUB | — | −1 | `a - b` wrapping |
| `0x03` | MUL | — | −1 | `a * b` wrapping |
| `0x04` | XOR | — | −1 | `a ^ b` |
| `0x05` | AND | — | −1 | `a & b` |
| `0x06` | OR | — | −1 | `a \| b` |
| `0x07` | ROT | — | −1 | `a.rotate_left(b % 32)` |
| `0x08` | NOT | — | 0 | `!a` (unary) |
| `0x09` | HASH | — | -(depth-1) | Blake3 of all stack items → single u32 |
The generator ensures ≥ 2 items on the stack before any binary opcode.
NOT (0x08) does not change depth. HASH resets depth to 1.
### Mutation Opcodes (v0.6.0)
The gene mutation extension operates on a separate `Vec<u8>` gene buffer and a
bounded environment map `Vec<(u16 symbol, u32 quantity)>`. These opcodes are
defined in `shared/src/vm_extensions.rs` and executed identically by both
server and WASM to guarantee deterministic parity.
| Opcode | Mnemonic | Effect |
| ------ | ------------------ | ------------------------------------------------------------- |
| `0x23` | GENE_LOAD | Push `gene[idx]` onto the stack |
| `0x24` | GENE_STORE | Pop stack top and store at `gene[idx]` |
| `0x25` | MUTATE_POINT | Apply wrapping byte delta at index |
| `0x26` | INSERT | Insert popped byte at index |
| `0x27` | DELETE | Delete byte at index and push the removed value |
| `0x28` | TRANSCRIBE | Push deterministic transcription hash of current gene state |
| `0x29` | APPLY_MUTAGEN | Mix environment symbol quantity into gene byte at index |
| `0x2A` | FINALIZE_GENE_HASH | Push commitment-derived `u32` onto the stack |
| `0x2B` | CONSUME | Pop amount, subtract from environment symbol quantity |
| `0x2C` | PRODUCE | Pop amount, add to environment symbol quantity |
**Constraints enforced at runtime:**
- Mutation program length capped at `MAX_MUTATION_PROGRAM_BYTES`
- Environment record count capped at `MAX_ENV_RECORDS`
- Environment records validated for sortedness, uniqueness, non-zero quantity
- Stack underflow and unknown opcodes cause deterministic, symmetric failures on both server and WASM paths
---
## Behavioral Validation
### Mouse Entropy
Every heartbeat includes the mouse events collected since the previous
heartbeat. Server checks:
| Check | Threshold |
| --------------------------- | ------------------------------------ |
| Minimum event count | ≥ 3 |
| Minimum cumulative distance | ≥ 10 px |
| Maximum average speed | ≤ 2.0 px/ms (distance / elapsed ms) |
| Minimum pause count | ≥ 1 (movement < 0.2 px over > 50 ms) |
### Browser Fingerprint
| Signal | Valid range |
| ------------------------------ | ------------- |
| `aspectRatio` (width / height) | 0.5 – 3.0 |
| `devicePixelRatio` | 0 < dpr ≤ 5.0 |
| `hardwareConcurrency` | ≥ 1 |
---
## Rate Limiting
Token bucket per `session_id`: 5 requests / 10-second window.
Stale entries evicted every 60 seconds by the cleanup task.
Rate-limited responses are indistinguishable from validation failures.
---
## SQLite Schema
The schema is extended in v0.6.0 with four new columns to persist per-session
gene mutation state. Migration is additive — columns are created when missing,
preserving compatibility with existing deployments.
```sql
CREATE TABLE IF NOT EXISTS sessions (
session_id TEXT PRIMARY KEY,
public_key BLOB NOT NULL, -- 32-byte Ed25519 verifying key
salt BLOB NOT NULL, -- 16-byte current salt
last_hash BLOB NOT NULL, -- 32-byte Blake3 chain head
chain_length INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL, -- Unix ms
last_seen INTEGER NOT NULL, -- Unix ms
expires_at INTEGER NOT NULL, -- Unix ms
-- v0.6.0: gene mutation state
gene BLOB NOT NULL DEFAULT X'', -- Vec<u8> gene buffer
environment BLOB NOT NULL DEFAULT X'', -- Vec<(u16, u32)> env records
pending_mutation BLOB NOT NULL DEFAULT X'', -- server-issued mutation program
pending_mutation_step INTEGER NOT NULL DEFAULT 0 -- current mutation step counter
);
```
In-memory SQLite (`sqlite-in-memory`) — all sessions lost on server restart by
design. Clients re-initialise transparently on the next page load. Use
`sqlite-in-disk` for persistent sessions across restarts.
---
## Storage Backend (v0.6.0)
ChronoSeal v0.6.0 introduces selectable database backends via the `db_type`
configuration option. The default remains in-memory to preserve ephemeral
session behavior.
### Backend Options
| `db_type` | Behavior |
| ------------------ | -------------------------------------------------------------------- |
| `sqlite-in-memory` | Default. All sessions ephemeral; lost on restart. Zero disk I/O. |
| `sqlite-in-disk` | Persistent sessions. Requires `db_path`. Survives restarts. |
| `valkey` | Compatibility mode. Currently falls back to in-memory. CLI contract preserved. |
### Configuration
**Config file** (`/etc/chronoseal/config.toml`):
```toml
db_type = "sqlite-in-disk"
db_path = "/var/lib/chronoseal/chronoseal.sqlite"
```
**Environment variable**:
```bash
CHRONOSEAL_DB_TYPE=sqlite-in-disk
CHRONOSEAL_DB_PATH=/var/lib/chronoseal/chronoseal.sqlite
```
**CLI flag**:
```bash
chronoseal run --db-type sqlite-in-disk --db-path /var/lib/chronoseal/chronoseal.sqlite
```
**Inspect active backend**:
```bash
chronoseal db-type --format text
```
### Precedence
```
CLI flags > CHRONOSEAL_* environment variables > config file > defaults
```
### Migration Notes
- Schema migration is additive; new columns are created when missing on startup.
- Switching from `sqlite-in-memory` to `sqlite-in-disk` requires no code changes — only config.
- `valkey` is available in the CLI contract today; full backend support is tracked for a future release.
- Existing deployments without the v0.6.0 mutation columns will have those columns added automatically on first start.
---
## Threat Model
### In Scope
| Threat | Mitigation |
| ------------------------------------------ | --------------------------------------------------------------- |
| Playwright / Puppeteer / Selenium | Mouse entropy + behavioral validation |
| Puppeteer Stealth, undetected-chromedriver | Signature over VM execution state |
| Heartbeat replay | Hash chain + ±30s timestamp window |
| Mutation replay | mutation_step + gene_commitment parity check (v0.6.0) |
| Mutation tampering | Server recomputes candidate gene from authoritative program (v0.6.0) |
| Signature forgery | Private key isolated in WASM memory |
| Parallel session sharing | Each session bound to a unique keypair |
| Brute-forced session IDs | 256-bit random entropy |
| Flooding with fake session IDs | Rate limiter + periodic HashMap eviction |
| Traffic analysis | Uniform `{"status":"ok"}` on all failure paths |
| Malformed mutation programs | Strict parsing, length caps, underflow/unknown-opcode errors |
### Out of Scope
| Threat | Reason |
| ---------------------------------- | ---------------------------------------- |
| Real browser with real human input | Indistinguishable from a legitimate user |
| WASM reverse engineering | Obfuscation is not a security primitive |
| Server-side compromise | Outside the scope of client attestation |
ChronoSeal raises cost and complexity of automated access. It is not a
cryptographic proof of humanity and does not claim to be.
---
## Module Reference
| Path | Purpose |
| ------------------------------------- | -------------------------------------------------------------------- |
| `shared/src/protocol.rs` | Shared types: `InitRequest`, `HeartbeatRequest`, `StackState`, … |
| `shared/src/hashing.rs` | `initial_hash`, `next_chain_hash`, `hash_stack` |
| `shared/src/constants.rs` | All tunable parameters |
| `shared/src/gene.rs` | Gene model, deterministic commitment (`chronoseal/gene/v1`) [v0.6.0] |
| `shared/src/vm_extensions.rs` | Mutation opcode set, shared engine for server/WASM parity [v0.6.0] |
| `server/src/routes/init.rs` | `POST /init` handler |
| `server/src/routes/heartbeat.rs` | `POST /hb` handler |
| `server/src/session.rs` | `create_session`, `verify_heartbeat`, `validate_mutation_parity` |
| `server/src/crypto.rs` | `verify_signature` — BTreeMap canonical JSON |
| `server/src/trust.rs` | `validate_mouse` — speed, distance, pauses |
| `server/src/fingerprint.rs` | `validate` — aspect ratio, DPR, HW concurrency |
| `server/src/vm.rs` | `generate_random_program` |
| `server/src/ratelimit.rs` | `RateLimiter::check`, `evict_stale` |
| `server/src/cleanup.rs` | Background loop: expire sessions + evict rate limiter |
| `server/src/storage.rs` | SQLite init, backend selection, `current_time_ms` |
| `wasm/src/crypto.rs` | `generate_keypair`, `sign_message`, `compute_next_hash` |
| `wasm/src/vm.rs` | `run_program` — stack machine executor |
| `wasm/src/vm_extensions.rs` | `preview_mutation`, `commit_mutation` [v0.6.0] |
| `frontend/heartbeat.js` | Session init, heartbeat loop, chain advancement |
| `frontend/entropy.js` | Mouse event ring buffer, `collectEntropy` |
| `frontend/transport.js` | `sendRequest` fetch wrapper |
+107 -245
View File
@@ -1,32 +1,37 @@
# ChronoSeal — Deployment Guide # ChronoSeal Deployment Guide
ChronoSeal v0.6.0 is designed for production deployment as a Unix-native daemon with hardened systemd support, lightweight WASM client runtime, and flexible backend storage.
## Prerequisites ## Prerequisites
| Tool | Minimum version | Purpose | | Tool | Minimum version | Purpose |
|---|---|---| |---|---|---|
| Rust | 1.87 stable | Server + WASM compilation | | Rust | 1.87 stable | Server and WASM compilation |
| wasm-pack | 0.13 | WASM build and packaging | | wasm-pack | 0.13 | WASM build and packaging |
| Docker + Compose | 24 / 2.x | Container deployment | | Docker | 24.x | Optional container deployment |
| nginx / NPM / HAProxy | any | TLS termination, reverse proxy | | docker-compose | 2.x | Optional local orchestration |
| systemd | 248+ | Service management |
Install Rust: https://rustup.rs Install Rust: [https://rustup.rs](https://rustup.rs)
Install wasm-pack: `cargo install wasm-pack` Install wasm-pack: `cargo install wasm-pack`
--- ---
## Build ## Build Steps
### 1. Build the WASM module ### 1. Build the WASM module
```bash ```bash
rustup target add wasm32-unknown-unknown
cargo install wasm-pack
wasm-pack build wasm --target web --release wasm-pack build wasm --target web --release
rm -rf frontend/pkg
mv wasm/pkg frontend/pkg mv wasm/pkg frontend/pkg
``` ```
This produces `frontend/pkg/antibot_wasm.js` and `frontend/pkg/antibot_wasm_bg.wasm`, This produces the browser runtime assets required by the frontend and the server static file handler.
which are loaded by `frontend/main.js` at runtime.
### 2. Build the server ### 2. Build the server binary
```bash ```bash
cargo build -p server --release cargo build -p server --release
@@ -34,158 +39,109 @@ cargo build -p server --release
Binary output: `target/release/server` Binary output: `target/release/server`
### 3. Build both (convenience script) ### 3. Convenience script
```bash ```bash
bash scripts/build.sh bash scripts/build.sh
``` ```
--- This script builds the WASM module, moves the generated package into `frontend/pkg`, and builds the server binary.
## Running
### Development
```bash
bash scripts/dev.sh
```
Runs the server with `cargo run --release`. The server serves the `frontend/`
directory statically at `/` via tower-http `ServeDir`.
Open `http://localhost:3000` in a browser. Open DevTools console — heartbeats
should appear every 12–25 seconds. No visible UI is rendered; the protection
is entirely silent.
### Production (native binary)
```bash
cargo build -p server --release
sudo cp target/release/server /usr/local/bin/chronoseal
```
Set environment variables before running:
```bash
export RUST_LOG=info # or warn for quieter output
chronoseal
```
The server binds to `0.0.0.0:3000` by default. Place behind a reverse proxy
for TLS — do not expose port 3000 directly.
--- ---
## systemd ## Deploying as a Native Service
### Service file ChronoSeal is intended to run as a proper Unix daemon managed by systemd.
The provided `chronoseal.service` includes hardened systemd sandboxing:
```
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 ### Install
```bash ```bash
# Create a dedicated system user sudo bash scripts/install.sh
sudo useradd --system --no-create-home --shell /usr/sbin/nologin chronoseal
# Install binary and frontend
sudo cp target/release/server /usr/local/bin/chronoseal
sudo mkdir -p /opt/chronoseal/frontend
sudo cp -r frontend/ /opt/chronoseal/frontend/
sudo chown -R chronoseal:chronoseal /opt/chronoseal
# Install and enable service
sudo cp chronoseal.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now chronoseal
``` ```
### Verify This installer should perform the following tasks:
* create a system user for `chronoseal`
* install the server binary into `/usr/local/bin/chronoseal`
* install static frontend assets into `/opt/chronoseal/frontend`
* install `chronoseal.service` into `/etc/systemd/system/`
* enable and start the service
### Verify the service
```bash ```bash
sudo systemctl status chronoseal sudo systemctl status chronoseal
journalctl -u chronoseal -f sudo journalctl -u chronoseal -f
``` ```
### Recommended runtime options
Use structured info-level logging in production:
```bash
export RUST_LOG=info
sudo systemctl restart chronoseal
```
Avoid `RUST_LOG=debug` in production because debug logs can expose internal session identifiers.
--- ---
## Docker ## systemd Integration
### Build and run The supplied `chronoseal.service` is designed for hardened Unix-native operation.
```bash Recommended service options:
docker compose up -d --build
```
### docker-compose.yml overview * `NoNewPrivileges=true`
* `PrivateTmp=true`
* `ProtectSystem=strict`
* `ProtectHome=true`
* `ProtectKernelTunables=true`
* `ProtectKernelModules=true`
* `ProtectControlGroups=true`
* `MemoryDenyWriteExecute=true`
* `RestrictRealtime=true`
* `RestrictSUIDSGID=true`
* `SystemCallArchitectures=native`
```yaml These options reduce the host attack surface and keep the daemon constrained to its required runtime privileges.
services:
chronoseal:
build: .
restart: unless-stopped
ports:
- "3000:3000"
environment:
RUST_LOG: info
tmpfs:
- /tmp
```
The `tmpfs` mount ensures the in-memory SQLite database is never written to
disk, even if Docker's storage driver were to flush the container filesystem.
### Dockerfile stages
The Dockerfile uses a two-stage build:
1. `rust:1.87-bookworm` — compiles the server binary
2. `debian:bookworm-slim` — minimal runtime image with only `ca-certificates`
The WASM module and frontend must be built separately (wasm-pack requires a
browser toolchain not present in the server image) and mounted or copied into
the container at `/opt/chronoseal/frontend/`.
```bash
# Build WASM first
wasm-pack build wasm --target web --release
mv wasm/pkg frontend/pkg
# Then build and run the container
docker compose up -d --build
```
Or mount the pre-built frontend as a volume:
```yaml
volumes:
- ./frontend:/opt/chronoseal/frontend:ro
```
--- ---
## Reverse Proxy ## Configuration
ChronoSeal must be served over HTTPS. The heartbeat payload contains a ChronoSeal reads configuration from a TOML file, environment variables, and CLI overrides. Use `chronoseal config` to validate the effective configuration.
timestamp; if traffic is observable in plaintext, timing attacks become
easier. TLS 1.3 is strongly recommended.
### nginx Example runtime configuration options:
```toml
bind = "0.0.0.0:3000"
pid_file = "/run/chronoseal.pid"
log_level = "info"
db_type = "sqlite-in-memory"
db_path = "/var/lib/chronoseal/chronoseal.db"
```
### Supported `db_type`
* `sqlite-in-memory`
* `sqlite-disk`
* `valkey`
`sqlite-in-memory` is the default and preserves ephemeral session semantics.
`sqlite-disk` will persist session state to a file specified by `db_path`.
`valkey` selects the Valkey-compatible backend mode and may be useful for future deployment scenarios.
---
## Reverse Proxy and TLS
ChronoSeal should be served over HTTPS in production. The heartbeat protocol includes timestamps and entropy data; serving that traffic in plaintext weakens security and allows easier traffic analysis.
### nginx example
```nginx ```nginx
server { server {
@@ -197,7 +153,6 @@ server {
ssl_protocols TLSv1.3; ssl_protocols TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384; ssl_ciphers ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
# Tight timeouts — heartbeat interval is 12–25s
proxy_read_timeout 35s; proxy_read_timeout 35s;
proxy_send_timeout 10s; proxy_send_timeout 10s;
@@ -218,129 +173,36 @@ server {
} }
``` ```
### Nginx Proxy Manager ### Docker deployment
1. Add a new Proxy Host pointing to `http://chronoseal:3000`
2. Enable SSL, Request Let's Encrypt certificate
3. Enable HTTP/2, Force SSL
4. Under Advanced, add:
```
proxy_read_timeout 35s;
proxy_send_timeout 10s;
```
### HAProxy
```haproxy
frontend https_front
bind *:443 ssl crt /etc/haproxy/certs/your.domain.pem alpn h2,http/1.1
default_backend chronoseal_back
backend chronoseal_back
server chronoseal 127.0.0.1:3000 check
timeout connect 5s
timeout server 35s
```
---
## Integration into an Existing Site
ChronoSeal is designed to run as a sidecar — its `/init` and `/hb` endpoints
can be proxied from any existing web server. The frontend assets (`pkg/`) need
to be served from the same origin as the protected page (or CORS must be
configured).
### Option A — Serve everything from ChronoSeal
ChronoSeal serves `frontend/` statically. Put your protected HTML inside
`frontend/` and let ChronoSeal serve it directly.
### Option B — Proxy only the API endpoints
Keep your existing server. Proxy `/init` and `/hb` to ChronoSeal, and serve
the WASM and JS assets from your CDN or existing static file server.
```nginx
# On your existing server:
location ~ ^/(init|hb)$ {
proxy_pass http://127.0.0.1:3000;
}
```
Add to your protected pages:
```html
<script type="module" src="/pkg/antibot_wasm.js"></script>
<script type="module" src="/main.js"></script>
```
---
## Configuration
All parameters are in `shared/src/constants.rs`. Recompile after changes.
| Constant | Default | Notes |
|---|---|---|
| `SESSION_ID_LEN` | 32 bytes | 256-bit entropy — do not reduce |
| `SALT_LEN` | 16 bytes | Per-heartbeat salt |
| `HEARTBEAT_MIN_INTERVAL_MS` | 12 000 ms | Increase to reduce server load |
| `HEARTBEAT_MAX_INTERVAL_MS` | 25 000 ms | Jitter upper bound |
| `EXPIRATION_MINUTES` | 30 min | Session TTL after last heartbeat |
| `RATE_LIMIT_COUNT` | 5 | Max heartbeats per window per session |
| `RATE_LIMIT_WINDOW_SECS` | 10 s | Rate limit window |
| `MAX_TIMESTAMP_DRIFT_MS` | 30 000 ms | Anti-replay window; account for NTP skew |
| `MIN_MOUSE_TOTAL_DIST` | 10.0 px | Lower for low-activity pages |
| `MAX_MOUSE_AVG_SPEED` | 2.0 px/ms | Raise if legitimate users are rejected |
| `MIN_PAUSE_COUNT` | 1 | Minimum natural pause events |
---
## Observability
ChronoSeal uses `tracing` with `tracing-subscriber`. Log levels:
| Level | Events |
|---|---|
| `INFO` | Server start, request method + path + status |
| `WARN` | Heartbeat validation failures (with session ID and reason) |
| `DEBUG` | Rate limit hits |
```bash ```bash
RUST_LOG=info chronoseal # production docker compose up -d --build
RUST_LOG=debug chronoseal # development
RUST_LOG=warn chronoseal # minimal output
``` ```
Log format is plain text to stdout. Pipe to `journald`, `fluentd`, or any The supplied `docker-compose.yml` is intended for local evaluation and development. It mounts `frontend/` and exposes port `3000`.
log aggregator via stdout capture.
Note: build the WASM package before container startup, or mount a pre-built `frontend/pkg/` volume.
--- ---
## Health Check ## Production Best Practices
The server has no dedicated `/health` endpoint. Use a TCP check on port 3000, * Use TLS termination at the perimeter
or a lightweight HTTP check on `GET /` (which serves `index.html`). * Run ChronoSeal behind a reverse proxy or firewall
* Keep `RUST_LOG` at `info` or `warn`
```bash * Use `systemctl` for lifecycle management
# Docker health check (add to docker-compose.yml if needed) * Monitor `chronoseal` metrics with Prometheus
healthcheck: * Place the frontend under the same origin as the protected pages or configure CORS carefully
test: ["CMD", "curl", "-sf", "http://localhost:3000/"]
interval: 30s
timeout: 5s
retries: 3
```
--- ---
## Security Checklist ## Health and Metrics
- [ ] TLS 1.3 enabled, TLS 1.0/1.1 disabled ChronoSeal exposes runtime endpoints for health and metrics.
- [ ] HTTP/2 enabled
- [ ] Port 3000 not exposed to the public internet (only via reverse proxy) * `chronoseal health` — health probe
- [ ] `RUST_LOG=warn` or `info` in production (not `debug` — session IDs appear in logs) * `chronoseal metrics` — Prometheus metrics output
- [ ] systemd service running as `chronoseal` user with hardened sandbox * `chronoseal status` — runtime status report
- [ ] `MemoryDenyWriteExecute=true` in service file (prevents JIT in process) * `chronoseal stats` — runtime statistics
- [ ] CORS `CorsLayer::permissive()` replaced with origin-restricted policy for production
- [ ] Frontend assets served over the same HTTPS origin as protected pages These endpoints are accessible locally from the daemon and may be proxied or scraped by monitoring infrastructure.
+44 -27
View File
@@ -1,41 +1,58 @@
# ChronoSeal Design Philosophy # ChronoSeal Design Philosophy
**"Everything is a File" — Unix-Native Software Design** ChronoSeal is built for operators who value clarity, stability, and Unix-native infrastructure.
ChronoSeal is intentionally built as a **first-class citizen of Linux**. The entire application is designed to behave like a well-engineered native file within the Unix filesystem. ## Core Philosophy
### Why This Philosophy Matters ChronoSeal is a Unix-first, CLI-first cryptographic attestation daemon. It is intentionally designed to feel like infrastructure software such as `nginx`, `redis-server`, or `systemd` itself.
ChronoSeal is designed so that administrators can operate, monitor, configure, and integrate it using the same reliable, transparent, and trusted tools and patterns they already use on Linux systems — without fighting the operating environment. ### Design priorities
### Core Principles * **Unix-native operation** — systemd integration, PID files, structured logs, and predictable lifecycle semantics.
* **CLI as source of truth** — all runtime operations available through the command line.
* **Minimal opacity** — no hidden telemetry, no opaque fingerprinting database.
* **Privacy-first** — ephemeral session state and no persistent user profiling.
* **Deterministic runtime behavior** — shared Rust/WASM implementation for the mutation engine and heartbeat protocol.
* **Incremental cost escalation** — make automation painful to scale without claiming impossible security.
* **Operational transparency** — expose health, metrics, status, and config as first-class artifacts.
- **Everything is a File**: The application must be controllable, inspectable, and composable through standard Unix interfaces (CLI, files, signals, pipes, and environment). ## Execution Model
- **CLI as Source of Truth**: All operations — starting, stopping, configuring, monitoring, and debugging — must be possible from the command line with excellent discoverability.
- **Behave Like a Native File**: Predictable lifecycle management through commands, signals (`SIGHUP`, `SIGTERM`, `SIGUSR1`), logs, configuration files, and standard process semantics.
- **Composability**: Must work naturally with pipes, redirection, scripts, systemd, Ansible, Docker, and orchestration tools.
- **Observability by Default**: All important state and metrics should be accessible as text or structured data.
- **Minimal Friction, Maximum Durability**: One-line installer, world-class `--help`, proper man pages, and decades-long maintainability are non-negotiable.
- **Respect for the OS**: Follows Linux Filesystem Hierarchy Standard (FHS), XDG Base Directory specification, and hardened systemd practices.
### Non-Goals ChronoSeal emphasizes deterministic, stateless request validation with a lightweight server-side session store.
ChronoSeal is **not** designed to be: * The server persists only the small session state required for continuity.
- Cloud-first or vendor-specific * The client executes a deterministic WASM runtime for every heartbeat.
- Browser-first or JavaScript-heavy * The protocol is intentionally ambiguous on rejection to avoid leaking validation rules.
- Dependency-heavy or framework-driven
- GUI-centric (any graphical interface must be a thin wrapper)
- Telemetry-oriented or privacy-invasive
- Optimized for rapid prototyping at the cost of long-term reliability
These non-goals help keep the project focused on stability, simplicity, security, and deep Unix integration. ## Non-Goals
### Development Mindset ChronoSeal does not aim to be:
- Production robustness, security, and long-term sustainability take clear precedence over development speed. * a tracking platform
- Every design decision is evaluated against one question: * a browser fingerprinting database
**“Does this make ChronoSeal feel like it naturally belongs in `/usr/bin/`?”** * a long-term behavioral analytics engine
* a platform for user profiling
* a SaaS or cloud-first service
This philosophy guided the complete refactoring of ChronoSeal and continues to drive all future development. Instead, ChronoSeal aims to be an infrastructure layer that raises attacker cost while leaving legitimate users unobstructed.
**Status**: Core architecture and systemd integration completed. Rich CLI, runtime configuration system, and one-line installer are in active development. ## Operational Assumptions
ChronoSeal assumes:
* the host environment is Linux
* systemd is available for service management
* TLS is used in production
* browser clients can execute WASM
* operators can manage native binaries and configuration files
## Privacy and Trust
The project is designed so that the verification mechanism is:
* ephemeral
* difficult to reverse-engineer at scale
* not based on personal identifiers
* not dependent on long-term user history
These choices reflect the belief that the best anti-automation system is one that can be operated without becoming a surveillance platform.
+40 -252
View File
@@ -1,278 +1,66 @@
# ChronoSeal Privacy & Design Principles # ChronoSeal Privacy Policy
## Privacy-First Browser Attestation Framework ChronoSeal is a privacy-first cryptographic attestation system. It is intentionally designed to avoid long-term profiling, tracking, and persistent identity storage.
ChronoSeal is a lightweight, privacy-first browser attestation framework designed to resist: ## What ChronoSeal Collects
- automated bots ChronoSeal only collects the minimum ephemeral data required to validate a live browser session:
- AI-driven browser automation
- scripted abuse
- browser surveillance ecosystems
Unlike conventional anti-bot systems, ChronoSeal is intentionally designed to operate **without collecting or storing client identity data**. * `session_id` — ephemeral session identifier
* `prev_hash` / `initial_hash` — cryptographic chain state
* `timestamp` — heartbeat timing information
* `entropy_data` — recent mouse event samples for behavioral plausibility
* `stack_state` — VM execution result for heartbeat uniqueness
* `fingerprint` signals — basic browser sanity values such as aspect ratio, DPR, and hardware concurrency
* `mutation_step` / `gene_commitment` — synthetic mutation parity values for protocol continuity
--- ## What ChronoSeal Does Not Store
# Core Philosophy ChronoSeal does not store or persist:
ChronoSeal verifies: * IP addresses as a core artifact
* browser history
* user identifiers
* personal data
* device fingerprint databases
* long-term behavioral profiles
* cross-session tracking records
- session continuity If you need browser telemetry or user profiling, ChronoSeal is not the right tool.
- runtime coherence
- cryptographic synchronization
It does **not** verify: ## Session Ephemerality
- personal identity By default, ChronoSeal uses `sqlite-in-memory` storage. Sessions are ephemeral and are expected to be recreated after process restarts.
- browsing history
- behavioral profiles
- long-term reputation
The framework is built around one principle: Persistent state is only stored when the operator explicitly configures `sqlite-disk` or `valkey`.
> Verify live browser participation without turning users into telemetry. ## Client-Side Key Handling
--- The Ed25519 signing keypair is generated inside the WASM runtime and is never serialized or transmitted in full.
# Privacy-First By Architecture * Private key: stays inside WASM linear memory
* Public key: transmitted once during session initialization
ChronoSeal is intentionally engineered to avoid becoming: This design minimizes the amount of sensitive material exposed outside the browser runtime.
- a tracking platform ## Intentional Silent Rejection
- a fingerprinting database
- a telemetry pipeline
- a surveillance system
## ChronoSeal Does NOT Store ChronoSeal intentionally returns a uniform `{"status":"ok"}` response for invalid heartbeats.
- IP addresses This is a privacy-preserving decision: it avoids emitting detailed rejection reasons that could be used to fingerprint or probe clients.
- Browser history
- Persistent fingerprints
- User profiles
- Behavioral telemetry
- Tracking identifiers
- Device databases
- Long-term session history
- Cross-site correlation data
No client-side personal information is persisted. ## Data Retention
--- Session state is retained only as long as it is needed for heartbeat continuity.
# Stateless Trust Model Expired sessions are purged automatically by cleanup tasks. Ephemeral backend modes do not write state to disk beyond the current process lifetime.
ChronoSeal focuses on: ## Transparency
- ephemeral runtime verification The source code is open and the verification model is documented. Operators can inspect exactly what ChronoSeal stores and validates.
- cryptographic continuity
- synchronized challenge progression
- live execution integrity
The server only validates: ## Summary
- whether the current browser session behaves like a coherent participant *right now* ChronoSeal is designed to provide anti-automation defense without becoming a tracking or surveillance platform.
ChronoSeal does not maintain: It is a privacy-aware, ephemeral attestation layer with strong operational guardrails.
- user identity databases
- reputation systems
- persistent surveillance records
---
# Anti-Bot Without Surveillance
Most modern anti-bot systems rely heavily on:
- fingerprinting
- behavioral tracking
- telemetry aggregation
- centralized analytics
ChronoSeal deliberately rejects this model.
Instead, ChronoSeal uses:
- synchronized cryptographic chains
- WASM-isolated signing
- protocol continuity
- transient verification state
This provides bot resistance while preserving user privacy.
---
# Lightweight By Design
ChronoSeal is intentionally engineered to remain:
- compact
- dependency-light
- operationally simple
- Unix-native
## Current Footprint
### Server Binary
Compiled x86_64 Linux server binary:
- ~8.4 MB
### WASM Runtime
`chronoseal_wasm_bg.wasm`
- ~218 KB
### Full WASM Package
Entire generated WASM package:
- ~720 KB
Includes:
- WASM runtime
- JavaScript glue code
- Type definitions
---
# No Frontend Framework Dependency
ChronoSeal does not depend on:
- React
- Angular
- Vue
- Electron
- Node.js runtime
- Browser bundler ecosystems
The browser runtime uses:
- native ES modules
- direct WebAssembly loading
- lightweight JavaScript glue
This minimizes:
- dependency complexity
- supply-chain risk
- build fragility
- browser overhead
---
# Clean Repository Philosophy
ChronoSeal keeps generated artefacts out of version control.
## What Is NOT Stored In The Repository
| Path | Reason |
|---|---|
| `wasm/pkg/` | Generated build output |
| `frontend/pkg/` | Generated serve-time artefacts |
| `target/` | Standard Rust build artefacts |
Generated binaries change frequently and are reproducible from source.
The repository intentionally stores:
- source code
- architecture
- reproducible build logic only
---
# Unix-Native Operational Model
ChronoSeal is designed as:
- infrastructure software
- not browser-centric SaaS
Core operational principles:
- CLI-first operation
- systemd-native deployment
- structured logs
- explicit configuration
- inspectable runtime behavior
- minimal hidden state
ChronoSeal should feel natural on Linux systems:
- simple to deploy
- easy to audit
- understandable years later
---
# Security Through Operational Asymmetry
ChronoSeal increases attacker cost through:
- synchronization burden
- runtime continuity requirements
- WASM-isolated cryptographic execution
- chained session progression
It does not attempt:
- invasive tracking
- permanent identification
- surveillance-driven scoring
---
# Design Goals
ChronoSeal prioritizes:
- Privacy
- Simplicity
- Transparency
- Operational clarity
- Long-term maintainability
- Minimalism
- Unix-native behavior
- Low deployment friction
---
# Non-Goals
ChronoSeal is intentionally NOT:
- A surveillance platform
- A telemetry collection system
- A browser fingerprinting database
- An analytics engine
- A cloud lock-in service
- A JavaScript-heavy frontend platform
- An advertising or tracking framework
---
# Summary
ChronoSeal is designed to prove:
> “A live browser session is coherently participating right now.”
without storing:
- who the user is
- where they came from
- what they previously did
It is a lightweight, privacy-preserving, Unix-native browser attestation framework focused on:
- anti-bot resistance
- anti-automation
- operational simplicity
without compromising user privacy.
+92 -89
View File
@@ -1,115 +1,118 @@
# ChronoSeal v0.6.0 — Synthetic Gene Mutation System # ChronoSeal v0.6.0 — Refactoring and System Upgrade
## Overview & Motivation ChronoSeal v0.6.0 is a major architecture and protocol update that transforms the project from a lightweight heartbeat service into a mature Unix-native attestation daemon with deterministic mutation parity and pluggable storage backends.
ChronoSeal v0.6.0 introduces a synthetic mutation chain model to strengthen attestation liveness and anti-replay guarantees while preserving privacy-first behavior. The core model combines:
- a primary byte-oriented gene buffer (`Vec<u8>`), and
- a bounded secondary environment map (`Vec<(u16 symbol, u32 quantity)>`).
Each heartbeat now carries deterministic mutation progression evidence (`mutation_step`, `gene_commitment`) that is validated server-side against the exact server-issued mutation order. This design increases attacker workload by coupling cryptographic chain continuity with stateful deterministic mutation parity. ## Summary of Changes
## Architectural Goals * Introduced the **Synthetic Gene Mutation Engine** for deterministic mutation parity across server and WASM.
1. Keep runtime behavior deterministic across server and WASM execution. * Added server-side validation of `mutation_step` and `gene_commitment`.
2. Preserve ephemerality and low operational complexity. * Centralized shared protocol logic in `shared/` for server/WASM parity.
3. Minimize additional latency on the heartbeat path. * Added support for multiple storage backend modes: `sqlite-in-memory`, `sqlite-disk`, and `valkey` compatibility.
4. Improve protocol resistance against replay and mutation tampering. * Hardened runtime architecture with `systemd` readiness, graceful shutdown, PID file support, and structured logging.
5. Maintain a maintainable codebase with explicit invariants and focused modules. * Expanded CLI with rich subcommands and effective runtime configuration.
* Preserved silent rejection semantics while improving anti-replay and liveness guarantees.
## Design Decisions ## Why This Refactor?
1. **Shared mutation engine**
Mutation opcode semantics live in `shared/src/vm_extensions.rs` to guarantee server/client parity from one implementation.
2. **Deterministic gene commitment** The previous model relied on heartbeat continuity and behavioral entropy alone. v0.6.0 strengthens the protocol by adding a second, deterministic state progression channel:
A domain-separated BLAKE3 commitment (`chronoseal/gene/v1`) binds both gene bytes and sorted environment records.
3. **Bounded mutation complexity** * each heartbeat now includes a mutation step and commitment
Mutation program length is capped (`MAX_MUTATION_PROGRAM_BYTES`) and environment cardinality is capped (`MAX_ENV_RECORDS`). * the server authoritatively selects the next mutation program
* the client must preview and commit the same state locally in WASM
* the server rejects any mismatch silently
4. **Strict validation on ingest** This raises the cost of developing a successful automation attack because the attacker must now maintain both a valid chain and a valid mutation progression state.
Environment payloads are validated for sortedness, uniqueness, non-zero quantity, and length constraints.
5. **Protocol-level mutation handshake** ## Core Architecture Changes
`InitResponse` and `Heartbeat` payloads now include mutation step/order and commitment fields.
6. **DB backend control via `db_type`** ### Shared Protocol Code
Server CLI/config now supports:
- `sqlite-in-memory` (default)
- `sqlite-in-disk` (active; uses `db_path`)
- `valkey` (active compatibility mode; currently falls back to in-memory)
## Implementation Plan `shared/` now contains:
1. Add gene model + deterministic commitment in `shared/gene.rs`.
2. Implement v0.6.0 mutation opcode set in shared VM extensions.
3. Persist mutation state per session (`gene`, `environment`, `pending_mutation`, `pending_mutation_step`).
4. Extend protocol schema for mutation fields in init/heartbeat exchange.
5. Validate mutation step + commitment parity before accepting heartbeat updates.
6. Add WASM preview/commit mutation lifecycle mirroring server behavior.
7. Add `db_type` CLI/config flow and runtime backend initialization strategy.
8. Add migration-safe schema extension (column existence checks + index creation).
## Testing Strategy (detailed section) * gene model and commitment hashing
ChronoSeal v0.6.0 test coverage is organized across unit, integration, and randomized/fuzz-style validation. * mutation opcode semantics
* request/response payload structures
* canonical signing support
* VM execution logic shared by server and WASM
1. **Unit, integration, and property tests** Moving mutation semantics into `shared/` eliminates subtle server/client divergence bugs and enables deterministic cross-runtime testing.
- Unit tests for gene invariants and encoding/decoding.
- Unit tests for every mutation opcode with stack-effect assertions.
- Integration tests for full session lifecycle and heartbeat acceptance/rejection paths.
- Table-driven randomized tests and fuzz-style random bytecode tests to validate deterministic failure/success symmetry.
2. **Server-client parity testing** ### Mutation Handshake
- Shared opcode engine parity tests across seeded mutation sequences.
- Multi-step mutation chain test (`test_mutation_chain`) asserting identical server/client final state.
- 10+ heartbeat deterministic simulation tests in session integration suite.
3. **Evasion / attack simulation testing** v0.6.0 adds the following data to the protocol:
- Replay attack simulation.
- Mutation step mismatch rejection.
- Mutation commitment tampering rejection.
- Malformed server mutation payload rejection.
- Stack underflow / unknown opcode / truncated program rejection.
4. **Performance regression testing** * `mutation_step`
- Bounded execution checks through capped program size and bounded record counts. * `mutation_order_b64`
- Timing smoke regression test for mutation execution loops. * `gene_commitment`
- End-to-end heartbeat test coverage to detect behavior regressions on hot paths. * `next_mutation_step`
* `next_mutation_order_b64`
## Security Analysis These fields are now part of the session initialization and heartbeat exchange.
1. **Replay resistance**
Heartbeats are now tied to both chain hash and mutation step progression.
2. **Mutation tampering resistance** ### Server Session State
Server recomputes candidate gene state from authoritative pending mutation program and rejects commitment mismatch.
3. **Protocol ambiguity reduction** The session schema now stores:
Canonical signing payload includes mutation fields, reducing exploitable unsigned state.
4. **Input hardening** * committed gene bytes
Program size limits, stack underflow checks, and strict environment decoding reduce parser abuse and malformed payload amplification. * committed environment records
* pending mutation order
* pending mutation step
5. **Deterministic failure semantics** The server advances this state only after a heartbeat is accepted.
Invalid mutation instructions fail predictably and symmetrically across server and WASM paths.
## Performance Considerations ### Deterministic WASM Preview
1. Mutation instructions are lightweight and mostly O(1); only `INSERT`/`DELETE` are O(n) but bounded by max gene size.
2. Environment operations use sorted-vector binary search with tight upper bound (`MAX_ENV_RECORDS`).
3. Commitment hashing is linear in gene size and record count, both bounded.
4. Shared engine avoids duplicate logic and divergence-induced debugging overhead.
## Migration & Backward Compatibility The WASM runtime exposes:
1. Schema migration is additive; new columns are created when missing.
2. Existing deployments without mutation fields require updated client+server pair for heartbeat compatibility.
3. `db_type` defaults to in-memory to preserve ephemeral behavior.
4. `sqlite-in-disk` is now directly usable via `db_path`.
5. `valkey` currently runs in compatibility mode (in-memory fallback) to avoid startup failure while preserving CLI contract.
## Risks & Mitigations * `init_gene_state()`
1. **Risk: State divergence between server and client** * `preview_gene_commitment()`
Mitigation: shared opcode engine + deterministic seeded parity tests + multi-heartbeat integration tests. * `commit_gene_preview()`
* `discard_gene_preview()`
* `current_gene_commitment()`
2. **Risk: Mutation opcode abuse via malformed programs** This makes the client-side mutation lifecycle explicit and deterministic.
Mitigation: strict parsing, length caps, explicit underflow/unknown-opcode errors.
3. **Risk: Performance regressions** ### Backend Abstraction
Mitigation: bounded structures, smoke timing tests, and focused hot-path validation.
4. **Risk: Backend confusion during `db_type` rollout** The server runtime now supports a configurable `db_type`.
Mitigation: explicit CLI command (`chronoseal db-type`), config output visibility, and clear runtime compatibility behavior.
* `sqlite-in-memory` — default runtime storage with ephemeral session semantics
* `sqlite-disk` — persistent SQLite storage for stateful deployments
* `valkey` — compatibility mode for alternative storage backends
This abstraction makes ChronoSeal easier to operate in both stateless and stateful environments.
### CLI and Service Integration
v0.6.0 improves the CLI surface with operational commands and service introspection.
* `chronoseal run`
* `chronoseal status`
* `chronoseal health`
* `chronoseal config`
* `chronoseal metrics`
* `chronoseal stats`
* `chronoseal db-type`
* `chronoseal completion`
* `chronoseal version`
The runtime now includes PID file handling and graceful termination.
## Testing and Validation
The refactor includes extensive tests for:
* server/WASM parity across mutation sequences
* malformed mutation payload rejection
* replay attack rejection
* mutation step mismatch rejection
* stateful session update semantics
* runtime database mode validation
The codebase now supports deterministic table-driven tests and fuzz-style random program validation.
## Operational Impact
This release makes ChronoSeal suitable for production deployment in Linux environments and for integration into existing web application stacks.
The combination of deterministic mutation parity and shared protocol implementation improves both security and maintainability.
+89 -157
View File
@@ -1,205 +1,137 @@
# ChronoSeal — Threat Model # ChronoSeal Threat Model
ChronoSeal is a cost-raising cryptographic attestation daemon. It increases the burden on automated clients while preserving privacy, determinism, and operational transparency.
## Purpose ## Purpose
This document defines what ChronoSeal is designed to protect against, what ChronoSeal protects web resources by making browser automation and replay attacks more expensive and fragile. It is not intended to be a perfect bot blocker.
it explicitly does not protect against, and the reasoning behind each
design decision in security terms.
ChronoSeal is a **cost-raising mechanism**. It does not claim to make ## Protected Assets
automated access impossible. It makes automated access expensive, complex
to maintain, and operationally fragile at scale.
--- | Asset | Protection focus |
## Assets Being Protected
| Asset | Description |
|---|---| |---|---|
| Web page content | HTML, rendered data, scraped text | | Page content | Prevent automated scraping and replay of protected content |
| API responses | JSON endpoints that serve structured data | | API responses | Reduce scripted access to sensitive endpoints |
| Server compute | CPU and bandwidth consumed by automated clients | | Server compute | Increase attacker resource costs |
| Rate-limited resources | Endpoints with per-user quotas | | Session continuity | Enforce live session progression |
| Behavioral analytics | Metrics polluted by bot traffic | | Behavioral integrity | Validate plausible browser activity |
---
## Attacker Profiles ## Attacker Profiles
### Level 1 — Script Kiddie / Commodity Scraper ### Level 1 — Commodity Scraper
**Tools:** `curl`, `requests`, `scrapy`, simple HTTP clients. * Tools: `curl`, `requests`, headless HTTP clients
**Capability:** No browser environment. Cannot execute JavaScript or WASM. * Capability: no WASM execution, no browser engine
**ChronoSeal response:** Session never initialises. No `session_id` is ever
presented to `/hb`. Content gated behind session validation is never served. ChronoSeal response:
* cannot initialize a session
* no `session_id` is produced
* content remains protected behind the attestation layer
### Level 2 — Headless Browser Operator ### Level 2 — Headless Browser Operator
**Tools:** Playwright, Puppeteer, Selenium, undetected-chromedriver. * Tools: Playwright, Puppeteer, Selenium
**Capability:** Full browser environment. Can execute JavaScript and WASM. * Capability: browser engine available, but automation is not indistinguishable from a real user
Cannot easily synthesise realistic mouse entropy or maintain hash chain state
across concurrent sessions. ChronoSeal response:
**ChronoSeal response:** Mouse entropy validation rejects absent or synthetic
movement. Hash chain requires per-session state synchronisation. Scaling to * mouse entropy and pause checks become active barriers
hundreds of concurrent sessions requires proportional infrastructure. * hash chain continuity requires per-session state tracking
* synthetic heartbeats become expensive to maintain at scale
### Level 3 — Stealth Automation ### Level 3 — Stealth Automation
**Tools:** Puppeteer Stealth, rebrowser-patches, custom CDP clients with * Tools: browser stealth plugins, CDP patching, synthetic event injection
evasion patches. * Capability: can execute JavaScript and WASM, may spoof some browser signals
**Capability:** Patches `navigator.webdriver`, spoofs browser fingerprints,
can inject synthetic mouse events. May partially pass behavioral checks.
**ChronoSeal response:** Ed25519 signature over the full payload (including
behavioral state and VM execution result) means the attacker must also
correctly execute the WASM program and maintain chain continuity. The private
key is generated fresh per page load and never exposed — it cannot be
extracted from a legitimate session and reused.
### Level 4 — Sophisticated Adversary ChronoSeal response:
**Tools:** Full browser farm with real input devices, WASM reverse engineering, * signature, hash chain, and mutation commitment require correct WASM execution
custom chain maintenance infrastructure. * private key is generated per page load and never exposes raw key material
**Capability:** Can pass all current ChronoSeal checks given sufficient * silent rejection hides validation rules from attacker feedback
engineering effort.
**ChronoSeal response:** Significantly increases operational cost. A browser
farm with real input devices costs orders of magnitude more than a commodity
scraper fleet. ChronoSeal is not designed to stop this attacker — no client-
side protection can.
--- ### Level 4 — Sophisticated Operator
* Tools: real browser farms, hardware input devices, custom chain management
* Capability: high engineering investment and real device scale
ChronoSeal response:
* significantly increases operational cost and complexity
* forces a full protocol implementation rather than best-effort scraping
* is not designed to stop such adversaries completely
## Attack Vectors and Mitigations ## Attack Vectors and Mitigations
### Replay Attack ### Replay Attack
**Attack:** Capture a valid heartbeat payload and retransmit it. **Attack:** resend a previously observed heartbeat.
**Mitigation:**
- Timestamp window (±30 seconds): replayed payloads are rejected after 30s. **Mitigations:**
- Hash chain: each heartbeat must present `H(n-1)` matching the server's
stored state. A replayed heartbeat presents a stale hash that no longer * timestamp window enforcement (±30 seconds)
matches after one successful heartbeat has advanced the chain. * chained Blake3 hash continuity
* server-issued salt rotation
* mutation step progression
### Signature Forgery ### Signature Forgery
**Attack:** Construct a valid-looking heartbeat payload without the private key. **Attack:** forge a heartbeat without the private key.
**Mitigation:** Ed25519 with 128-bit security. The private key is generated
inside WASM `thread_local` memory, never serialised, never passed to
JavaScript, never transmitted. Forgery requires breaking Ed25519 or
extracting the key from WASM memory — neither is practical.
### Key Extraction **Mitigations:**
**Attack:** Inspect WASM linear memory to extract the private signing key. * Ed25519 signature over the canonical payload
**Mitigation:** The key is stored in a Rust `thread_local! { RefCell<Option<SigningKey>> }`. * private key generated and stored inside WASM memory only
It has no exported symbol and is not referenced by any exported WASM function * signature verification occurs on every heartbeat
that returns raw memory. An attacker with full DevTools access to the WASM
memory can extract it from one session, but it is useless for other sessions
(fresh keypair per page load) and expires with the session.
### Hash Chain Forgery ### Mutation Tampering
**Attack:** Compute a valid `H(n)` without the server-side salt. **Attack:** send an invalid or stale mutation commitment.
**Mitigation:** Each chain link incorporates `saltₙ₋₁`, which is a 16-byte
random value known only to the server and returned (once) in the heartbeat **Mitigations:**
response. An attacker cannot compute `H(n+1)` without first receiving
`saltₙ` from a successful heartbeat response, which requires a valid signature * server recomputes the gene commitment from server-authored mutation orders
and all other checks to pass. * heartbeat request includes `mutation_step` and `gene_commitment`
* mismatched commitment causes silent rejection
### Session Hijacking ### Session Hijacking
**Attack:** Steal a `session_id` and use it from a different client. **Attack:** steal a valid `session_id` and reuse it.
**Mitigation:** `session_id` alone is insufficient — the attacker also needs
the private key (to produce valid signatures) and the current chain state
(to present the correct `prev_hash`). All three are required simultaneously.
### Enumeration of Validation Rules **Mitigations:**
**Attack:** Send malformed heartbeats and analyse error responses to map * `session_id` alone is insufficient
validation logic. * attacker also needs current `prev_hash` and private key
**Mitigation:** All failure paths return `{"status":"ok"}` with no `next_salt`. * keypair is generated per browser session in WASM
There is no error code, no error message, and no status difference between
a rate limit hit, an invalid signature, a broken chain, and a behavioral
rejection.
### DoS via Session Flooding ### Fingerprint Enumeration
**Attack:** Open thousands of sessions to exhaust the rate limiter's HashMap **Attack:** probe the API with malformed requests to discover validation logic.
memory.
**Mitigation:** Rate limiter entries are evicted every 60 seconds by the
cleanup task. Each entry is a small `(u32, Instant)` tuple; even at 100,000
concurrent fake sessions, the HashMap occupies roughly 10–15 MB, which is
well within normal server memory budgets. Sessions themselves expire after 30
minutes of inactivity and are purged from SQLite.
### Clock Manipulation **Mitigations:**
**Attack:** Manipulate the client's `Date.now()` to bypass the timestamp * all invalid heartbeats return `{"status":"ok"}`
window. * no explicit error messages are exposed
**Mitigation:** The timestamp is included in the signed payload. Manipulating * silent rejection removes oracle behavior
it requires also forging the signature. The server validates against its own
clock — client-side clock manipulation cannot help without the private key.
### Synthetic Mouse Events ## Limitations
**Attack:** Inject programmatic `mousemove` events via `dispatchEvent` or ChronoSeal does not protect against:
CDP input simulation.
**Mitigation:** Synthetic events often fail the pause check (no natural dwell
periods), produce unrealistically uniform speed profiles, or fail the minimum
distance threshold. Generating convincingly human mouse traces at scale
requires either real input devices or sophisticated probabilistic models —
both significantly increase operational cost.
--- * real users intentionally acting as bots
* server-side application vulnerabilities
## What ChronoSeal Does Not Protect Against * full browser farm operators with real input devices
* persistent fingerprinting or identity profiling
| Limitation | Explanation | * pre-signed session payload reuse after a legitimate success if the attacker also has the current salt and key
|---|---|
| Real browsers with real users acting as bots | A human operating a browser manually is indistinguishable from a legitimate visitor. ChronoSeal cannot address this. |
| Server-side vulnerabilities | ChronoSeal is a client attestation layer. It does not protect the server from injection, authentication bypass, or other backend vulnerabilities. |
| Highly resourced nation-state actors | Out of scope for a client-side protection layer. |
| Content visible before session establishment | If the protected content is rendered before the first heartbeat, it can be scraped without a session. Gate content on session validity server-side. |
| Perfect bot prevention | No client-side mechanism can be. WASM can be reverse engineered. ChronoSeal raises cost, not an impenetrable barrier. |
---
## Operational Security Notes ## Operational Security Notes
### Log Level * Do not use `RUST_LOG=debug` in production; it may expose internal identifiers.
* Always serve ChronoSeal traffic over HTTPS.
* Use `sqlite-in-memory` for ephemeral sessions when persistence is not required.
* Use `sqlite-disk` or `valkey` when session state needs to survive restarts.
Do not run with `RUST_LOG=debug` in production. The debug log includes ## Disclosure
`session_id` values, which are sensitive identifiers. Use `warn` or `info`.
### CORS Policy See [SECURITY.md](../SECURITY.md) for the vulnerability disclosure policy.
The default `CorsLayer::permissive()` is suitable for development only.
In production, restrict allowed origins to your own domain:
```rust
CorsLayer::new()
.allow_origin("https://your.domain.com".parse::<HeaderValue>().unwrap())
.allow_methods([Method::POST])
.allow_headers([header::CONTENT_TYPE])
```
### TLS
Serve exclusively over TLS 1.3. The heartbeat payload contains timestamps
and behavioral signals. While each payload is signed and cannot be forged,
plaintext transmission leaks behavioral patterns and timing information that
could assist a sophisticated attacker.
### In-Memory SQLite
All session state is lost on server restart. This is intentional — there is
no persistent state to steal. Clients transparently re-initialise. If your
deployment restarts frequently (e.g. rolling deploys), sessions will be lost
more often; tune `HEARTBEAT_MIN_INTERVAL_MS` and `EXPIRATION_MINUTES`
accordingly so clients recover quickly.
---
## Security Disclosure
See [SECURITY.md](../SECURITY.md) for the vulnerability disclosure policy
and contact details.
+70 -238
View File
@@ -1,301 +1,133 @@
# ChronoSeal — WASM Build Guide # ChronoSeal WASM Build Guide
## Overview ChronoSeal uses a Rust-based WASM runtime to power browser-side attestation logic, signing, hash chaining, VM execution, and mutation commitment preview.
The client-side cryptographic core of ChronoSeal is written in Rust and ## Why WASM
compiled to WebAssembly (WASM). The JavaScript frontend (`heartbeat.js`)
imports functions from this WASM module to generate keypairs, sign heartbeat
payloads, compute hash chain links, and execute the stack machine program.
The import line in `heartbeat.js`: The WASM runtime provides a deterministic, sandboxed environment for the following tasks:
```js * generate Ed25519 keypairs in-browser
import init, { generate_keypair, sign_message, compute_next_hash, run_program } * sign canonical heartbeat payloads
from './pkg/antibot_wasm.js'; * execute randomized VM opcode programs
``` * compute Blake3 hash chain progression
* preview and commit synthetic gene mutations
`./pkg/antibot_wasm.js` is a **generated file**. It does not exist in the This enables server/client parity and prevents the private key from leaving the browser runtime.
repository and must be produced by building the `wasm/` crate before running
the server.
--- ## Build Requirements
## How the WASM Module is Built Install the Rust WASM target and `wasm-pack`:
The tool that compiles Rust to WASM and generates the JavaScript glue is
[`wasm-pack`](https://rustwasm.github.io/wasm-pack/).
When you run:
```bash
wasm-pack build wasm --target web --release
```
wasm-pack does the following in sequence:
1. Compiles `wasm/src/lib.rs` (and its submodules) to a `.wasm` binary using
the `wasm32-unknown-unknown` target.
2. Runs `wasm-bindgen` to inspect every `#[wasm_bindgen]`-annotated function
and struct and generate a JavaScript wrapper for each one.
3. Optionally runs `wasm-opt` (from Binaryen) to size-optimise the binary.
4. Writes all output to `wasm/pkg/`.
---
## Output: `wasm/pkg/`
After a successful build, `wasm/pkg/` contains:
```
wasm/pkg/
├── antibot_wasm.js ← ES module; the file heartbeat.js imports
├── antibot_wasm_bg.wasm ← compiled WASM binary (~300–800 KB release)
├── antibot_wasm_bg.js ← internal memory bridge (do not import directly)
├── antibot_wasm.d.ts ← TypeScript type declarations
├── antibot_wasm_bg.d.ts ← TypeScript declarations for the bg module
└── package.json
```
### `antibot_wasm.js`
This is the public entry point. It contains:
- An `init()` function that fetches and instantiates the `.wasm` binary.
- One JavaScript wrapper function for each `#[wasm_bindgen]` export in
`wasm/src/`:
| Rust export | JS wrapper | Description |
|---|---|---|
| `generate_keypair()` | `generate_keypair()` | Generate Ed25519 keypair; return hex public key |
| `get_public_key()` | `get_public_key()` | Return hex public key, or `""` if not initialised |
| `sign_message(msg)` | `sign_message(msg)` | Sign string; return hex signature, or `""` if not initialised |
| `compute_next_hash(prev, ts, entropy, stack, salt)` | `compute_next_hash(...)` | Compute next Blake3 chain hash |
| `run_program(b64)` | `run_program(b64)` | Execute base64 VM program; return `{ stack, ip }` |
### `antibot_wasm_bg.wasm`
The compiled binary. The `.bg` suffix means "background" — this is the raw
WASM that `antibot_wasm.js` loads internally. You should not reference this
file directly in your HTML.
---
## Step-by-Step Build
### 1. Install the Rust WASM target
```bash ```bash
rustup target add wasm32-unknown-unknown rustup target add wasm32-unknown-unknown
```
This is a one-time step. Without it, the Rust compiler cannot produce WASM
output.
### 2. Install wasm-pack
```bash
cargo install wasm-pack cargo install wasm-pack
``` ```
Or via the installer script:
```bash
curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh
```
Verify: Verify:
```bash ```bash
wasm-pack --version wasm-pack --version
# wasm-pack 0.13.x
``` ```
### 3. Build the WASM module ## Build the WASM Module
From the project root: From the repository root:
```bash ```bash
wasm-pack build wasm --target web --release wasm-pack build wasm --target web --release
```
`--target web` produces an ES module (`import`/`export` syntax) suitable for
use directly in a browser without a bundler. Other targets (`bundler`,
`nodejs`, `no-modules`) produce different output formats and are not
compatible with the ChronoSeal frontend as written.
`--release` enables Rust's release optimisations (inlining, dead code
elimination, size reduction). Omit it during development for faster builds
and better panic messages.
### 4. Move the output to the frontend
```bash
rm -rf frontend/pkg rm -rf frontend/pkg
mv wasm/pkg frontend/pkg mv wasm/pkg frontend/pkg
``` ```
The frontend expects the WASM module at `frontend/pkg/antibot_wasm.js` `--target web` produces an ES module compatible with the existing frontend JavaScript.
because `heartbeat.js` imports from `./pkg/antibot_wasm.js` relative to
the `frontend/` directory, which is where the server's static file handler
is rooted.
--- `--release` enables optimizations for runtime performance and size.
## Using the Build Script ## Output
The convenience script at `scripts/build.sh` performs all steps in order: After a successful build, `frontend/pkg/` contains:
```bash * `antibot_wasm.js`
bash scripts/build.sh * `antibot_wasm_bg.wasm`
``` * `antibot_wasm_bg.js`
* `antibot_wasm.d.ts`
* `antibot_wasm_bg.d.ts`
* `package.json`
This builds the WASM module, moves it to `frontend/pkg/`, and then builds The frontend expects the WASM package under `frontend/pkg/`.
the server binary. Run this for a clean full build before deployment.
For development iteration where you are only changing Rust WASM code: ## Runtime Exports
```bash The WASM module exports the following functions:
wasm-pack build wasm --target web # (omit --release for speed)
rm -rf frontend/pkg && mv wasm/pkg frontend/pkg
```
For development where you are only changing server code: * `generate_keypair()` — generate a new Ed25519 keypair and return public key hex
* `get_public_key()` — return the current public key hex
* `sign_message(msg)` — sign a UTF-8 payload and return the hex signature
* `compute_next_hash(prev, ts, entropy, stack, salt)` — compute the next Blake3 chain hash
* `run_program(b64)` — execute a base64 VM program and return stack state
* `init_gene_state(gene_size)` — initialise the synthetic gene buffer
* `preview_gene_commitment(order_b64)` — preview the next gene commitment from a mutation order
* `commit_gene_preview()` — commit the previewed mutation after successful heartbeat
* `discard_gene_preview()` — discard the previewed mutation after rejection or error
* `current_gene_commitment()` — return the current committed gene commitment
```bash ## Browser Integration
cargo build -p server
```
--- The frontend imports the generated module like this:
## How `heartbeat.js` Loads the Module
`heartbeat.js` uses a standard ES module dynamic import pattern:
```js ```js
import init, { generate_keypair, sign_message, compute_next_hash, run_program } import init, {
from './pkg/antibot_wasm.js'; generate_keypair,
sign_message,
export async function initHeartbeat() { compute_next_hash,
// 1. Fetch and instantiate the .wasm binary run_program,
await init(); init_gene_state,
preview_gene_commitment,
// 2. Generate keypair — private key stored in WASM memory only commit_gene_preview,
const pubKeyHex = generate_keypair(); discard_gene_preview,
current_gene_commitment
// 3. Send public key to server, receive session_id and chain seed } from './pkg/antibot_wasm.js';
// ...
}
``` ```
`init()` is the default export from `antibot_wasm.js`. It fetches `await init()` must be called before invoking any other exported function.
`antibot_wasm_bg.wasm` (from the same `pkg/` directory) via `fetch()`,
compiles it in the browser's WASM engine, and links it to the JS glue
layer. After `await init()` returns, all the named exports
(`generate_keypair`, `sign_message`, etc.) are ready to call.
The `init()` call must complete before any other WASM function is called. ## Deployment Note
Calling `sign_message()` or `compute_next_hash()` before `await init()`
returns will produce an empty string (the module is not yet instantiated).
--- The `.wasm` binary must be served with the correct MIME type:
## Serving the WASM Binary
Browsers require WASM files to be served with the correct MIME type:
``` ```
Content-Type: application/wasm Content-Type: application/wasm
``` ```
Most web servers set this automatically for `.wasm` files. If you see the The built-in Axum static file handler already sets the appropriate MIME type for `.wasm` files.
error:
``` ## Build Script
WebAssembly.instantiate(): Response has unsupported MIME type
```
Add the MIME type to your server configuration: Use the convenience script:
**nginx:**
```nginx
types {
application/wasm wasm;
}
```
**Apache `.htaccess`:**
```apache
AddType application/wasm .wasm
```
The Axum `ServeDir` handler used by ChronoSeal's built-in static server
sets the correct MIME type automatically via `tower-http`.
---
## What Is Not in the Repository
| Path | Why excluded |
|---|---|
| `wasm/pkg/` | Generated build output — changes on every build |
| `frontend/pkg/` | Same generated output, moved to serve location |
| `target/` | Standard Rust build artefacts |
Both `wasm/pkg/` and `frontend/pkg/` are listed in `.gitignore`. Committing
them would bloat the repository (the `.wasm` binary alone is 300–800 KB),
create noisy diffs on every rebuild, and give a false impression that the
WASM module is pre-built and ready to use without a build step.
---
## Troubleshooting
### `wasm32-unknown-unknown` target not found
```
error[E0463]: can't find crate for `std`
```
Fix:
```bash ```bash
rustup target add wasm32-unknown-unknown bash scripts/build.sh
``` ```
### `wasm-pack` not found This builds the WASM package, moves it into `frontend/pkg/`, and builds the server binary.
## Recommended Development Flow
* For WASM-only changes:
```bash ```bash
cargo install wasm-pack wasm-pack build wasm --target web
rm -rf frontend/pkg
mv wasm/pkg frontend/pkg
``` ```
### `wasm-opt` not found (warning, not an error) * For server-only changes:
wasm-pack prints a warning if `wasm-opt` is not installed. The build still
succeeds; the binary is just not size-optimised.
```bash ```bash
# On Debian/Ubuntu/Arch cargo build -p server
sudo apt install binaryen # Debian/Ubuntu
sudo pacman -S binaryen # Arch
``` ```
### `antibot_wasm_bg.wasm` fetch fails (404) ## Notes
The `.wasm` file is not being served from `frontend/pkg/`. Verify: Generated files in `wasm/pkg/` and `frontend/pkg/` are not tracked in source control.
They are build artifacts and should be regenerated as part of the release workflow.
```bash
ls /mnt/Programs/ChronoSeal/frontend/pkg/
# Should list: antibot_wasm.js antibot_wasm_bg.wasm ...
```
If the directory is empty or missing, re-run the build steps above.
### MIME type error in browser
See the "Serving the WASM Binary" section above.
### `sign_message` or `generate_keypair` returns empty string
The WASM keypair has not been initialised. Ensure `await init()` and
`generate_keypair()` are called (and awaited) before any other WASM
function. Check the browser console for any errors during `init()`.
+1
View File
@@ -30,3 +30,4 @@ hex = "0.4"
base64 = "0.22" base64 = "0.22"
rand = "0.8" rand = "0.8"
ed25519-dalek = "2" ed25519-dalek = "2"
valkey = "0.0.0-alpha5"
+3 -9
View File
@@ -5,16 +5,10 @@ pub async fn cleanup_loop(state: Arc<AppState>) {
loop { loop {
tokio::time::sleep(std::time::Duration::from_secs(60)).await; tokio::time::sleep(std::time::Duration::from_secs(60)).await;
// Evict expired sessions from SQLite. // Evict expired sessions from the configured storage backend.
{ {
if let Ok(conn) = state.db_pool.get() { if let Err(err) = state.db_pool.delete_expired_sessions() {
let now = crate::storage::current_time_ms(); tracing::error!("Failed to evict expired sessions: {}", err);
let _ = conn.execute(
"DELETE FROM sessions WHERE expires_at < ?1",
rusqlite::params![now],
);
} else {
tracing::error!("Failed to get database connection from pool for cleanup");
} }
} }
+4
View File
@@ -123,6 +123,10 @@ pub struct RunArgs {
/// Optional structured JSON log file. /// Optional structured JSON log file.
#[arg(long, env = "CHRONOSEAL_LOG_FILE")] #[arg(long, env = "CHRONOSEAL_LOG_FILE")]
pub log_file: Option<PathBuf>, pub log_file: Option<PathBuf>,
/// Number of mutation rounds to execute per program.
#[arg(long, env = "CHRONOSEAL_MUTATION_ROUNDS")]
pub mutation_rounds: Option<u8>,
} }
#[derive(Debug, Clone, Args)] #[derive(Debug, Clone, Args)]
+26
View File
@@ -46,6 +46,7 @@ pub struct Config {
pub min_pause_count: u32, pub min_pause_count: u32,
pub require_mouse_activity: bool, pub require_mouse_activity: bool,
pub gene_size: usize, pub gene_size: usize,
pub mutation_rounds: u8,
} }
impl Default for Config { impl Default for Config {
@@ -68,6 +69,7 @@ impl Default for Config {
min_pause_count: 1, min_pause_count: 1,
require_mouse_activity: true, require_mouse_activity: true,
gene_size: shared::constants::DEFAULT_GENE_SIZE, gene_size: shared::constants::DEFAULT_GENE_SIZE,
mutation_rounds: shared::constants::DEFAULT_MUTATION_ROUNDS,
} }
} }
} }
@@ -118,6 +120,9 @@ impl Config {
if let Some(log_file) = &args.log_file { if let Some(log_file) = &args.log_file {
self.log_file = Some(log_file.clone()); self.log_file = Some(log_file.clone());
} }
if let Some(mutation_rounds) = args.mutation_rounds {
self.mutation_rounds = mutation_rounds;
}
} }
pub fn validate(&self) -> Result<(), ConfigError> { pub fn validate(&self) -> Result<(), ConfigError> {
@@ -132,6 +137,11 @@ impl Config {
size: self.gene_size, size: self.gene_size,
}); });
} }
if !(1..=shared::constants::MAX_MUTATION_ROUNDS).contains(&self.mutation_rounds) {
return Err(ConfigError::InvalidMutationRounds {
rounds: self.mutation_rounds,
});
}
Ok(()) Ok(())
} }
@@ -214,6 +224,11 @@ impl Config {
self.gene_size = val; self.gene_size = val;
} }
} }
if let Ok(value) = env::var("CHRONOSEAL_MUTATION_ROUNDS") {
if let Ok(val) = value.parse() {
self.mutation_rounds = val;
}
}
} }
} }
@@ -234,6 +249,9 @@ pub enum ConfigError {
InvalidGeneSize { InvalidGeneSize {
size: usize, size: usize,
}, },
InvalidMutationRounds {
rounds: u8,
},
} }
impl std::fmt::Display for ConfigError { impl std::fmt::Display for ConfigError {
@@ -253,6 +271,13 @@ impl std::fmt::Display for ConfigError {
shared::constants::MAX_GENE_SIZE shared::constants::MAX_GENE_SIZE
) )
} }
Self::InvalidMutationRounds { rounds } => {
write!(
f,
"invalid mutation rounds {rounds}; expected 1..={}",
shared::constants::MAX_MUTATION_ROUNDS
)
}
} }
} }
} }
@@ -316,6 +341,7 @@ mod tests {
db_path: None, db_path: None,
frontend_dir: None, frontend_dir: None,
log_file: None, log_file: None,
mutation_rounds: None,
}; };
cfg.apply_run_args(&args); cfg.apply_run_args(&args);
assert_eq!(cfg.db_type, DbType::SqliteInDisk); assert_eq!(cfg.db_type, DbType::SqliteInDisk);
+6
View File
@@ -14,6 +14,9 @@ pub enum SessionError {
#[error("Database error: {0}")] #[error("Database error: {0}")]
Database(#[from] rusqlite::Error), Database(#[from] rusqlite::Error),
#[error("Storage error: {0}")]
Storage(String),
#[error("R2D2 pool error: {0}")] #[error("R2D2 pool error: {0}")]
Pool(#[from] r2d2::Error), Pool(#[from] r2d2::Error),
@@ -48,6 +51,9 @@ pub enum VerificationError {
#[error("Database error: {0}")] #[error("Database error: {0}")]
Database(#[from] rusqlite::Error), Database(#[from] rusqlite::Error),
#[error("Storage error: {0}")]
Storage(String),
#[error("Hex decoding error: {0}")] #[error("Hex decoding error: {0}")]
Hex(#[from] hex::FromHexError), Hex(#[from] hex::FromHexError),
+8 -20
View File
@@ -29,22 +29,7 @@ pub async fn handler(
} }
let config = state.get_config(); let config = state.get_config();
let conn = match state.db_pool.get() { match crate::session::verify_heartbeat(&state.db_pool, &config, &payload) {
Ok(c) => c,
Err(e) => {
tracing::error!("Db pool error: {}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(HeartbeatResponse {
status: "error".into(),
next_salt: None,
next_mutation_step: None,
next_mutation_order_b64: None,
}),
);
}
};
match crate::session::verify_heartbeat(&conn, &config, &payload) {
Ok(result) => ( Ok(result) => (
StatusCode::OK, StatusCode::OK,
Json(HeartbeatResponse { Json(HeartbeatResponse {
@@ -145,7 +130,11 @@ mod tests {
hardware_concurrency: 8, hardware_concurrency: 8,
}, },
mutation_step, mutation_step,
gene_commitment: shared::gene::commitment_hex(&candidate), gene_commitment: shared::gene::commitment_hex_with_context(
&candidate,
&init.session_id,
mutation_step,
),
signature: String::new(), signature: String::new(),
}; };
sign_request(sk, &mut req); sign_request(sk, &mut req);
@@ -157,7 +146,7 @@ mod tests {
) -> (Arc<AppState>, InitResponse, SigningKey) { ) -> (Arc<AppState>, InitResponse, SigningKey) {
let pool = crate::storage::init_pool(Path::new(":memory:")).unwrap(); let pool = crate::storage::init_pool(Path::new(":memory:")).unwrap();
let state = Arc::new(AppState { let state = Arc::new(AppState {
db_pool: pool, db_pool: pool.clone(),
rate_limiter: tokio::sync::Mutex::new(crate::ratelimit::RateLimiter::new()), rate_limiter: tokio::sync::Mutex::new(crate::ratelimit::RateLimiter::new()),
config: std::sync::RwLock::new(config.clone()), config: std::sync::RwLock::new(config.clone()),
}); });
@@ -165,8 +154,7 @@ mod tests {
let mut rng = rand::thread_rng(); let mut rng = rand::thread_rng();
let sk = SigningKey::generate(&mut rng); let sk = SigningKey::generate(&mut rng);
let pk_hex = hex::encode(sk.verifying_key().to_bytes()); let pk_hex = hex::encode(sk.verifying_key().to_bytes());
let conn = state.db_pool.get().unwrap(); let init = crate::session::create_session(&pool, &config, &pk_hex).unwrap();
let init = crate::session::create_session(&conn, &config, &pk_hex).unwrap();
(state, init, sk) (state, init, sk)
} }
+1 -2
View File
@@ -9,7 +9,6 @@ pub async fn handler(
Json(payload): Json<InitRequest>, Json(payload): Json<InitRequest>,
) -> Result<Json<InitResponse>, SessionError> { ) -> Result<Json<InitResponse>, SessionError> {
let config = state.get_config(); let config = state.get_config();
let conn = state.db_pool.get()?; let resp = crate::session::create_session(&state.db_pool, &config, &payload.public_key)?;
let resp = crate::session::create_session(&conn, &config, &payload.public_key)?;
Ok(Json(resp)) Ok(Json(resp))
} }
+18 -31
View File
@@ -204,14 +204,7 @@ pub fn db_type_report() -> DbTypeReport {
} }
fn init_db_pool(config: &Config) -> Result<storage::DbPool, Box<dyn std::error::Error>> { fn init_db_pool(config: &Config) -> Result<storage::DbPool, Box<dyn std::error::Error>> {
match config.db_type { storage::DbPool::init(config)
crate::config::DbType::SqliteInMemory => storage::init_pool(Path::new(":memory:")),
crate::config::DbType::SqliteInDisk => storage::init_pool(&config.db_path),
crate::config::DbType::Valkey => {
warn!("db_type=valkey selected; using sqlite-in-memory compatibility mode in v0.6.0");
storage::init_pool(Path::new(":memory:"))
}
}
} }
pub fn probe_health(config: &Config) -> HealthReport { pub fn probe_health(config: &Config) -> HealthReport {
@@ -278,11 +271,9 @@ async fn health_handler() -> impl IntoResponse {
async fn stats_handler( async fn stats_handler(
axum::extract::State(state): axum::extract::State<Arc<session::AppState>>, axum::extract::State(state): axum::extract::State<Arc<session::AppState>>,
) -> Result<Json<StoreStats>, (StatusCode, String)> { ) -> Result<Json<StoreStats>, (StatusCode, String)> {
let db = state state
.db_pool .db_pool
.get() .stats()
.map_err(|err| (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()))?;
storage::stats(&db)
.map(Json) .map(Json)
.map_err(|err| (StatusCode::INTERNAL_SERVER_ERROR, err.to_string())) .map_err(|err| (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()))
} }
@@ -290,11 +281,9 @@ async fn stats_handler(
async fn metrics_handler( async fn metrics_handler(
axum::extract::State(state): axum::extract::State<Arc<session::AppState>>, axum::extract::State(state): axum::extract::State<Arc<session::AppState>>,
) -> Result<String, (StatusCode, String)> { ) -> Result<String, (StatusCode, String)> {
let db = state state
.db_pool .db_pool
.get() .stats()
.map_err(|err| (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()))?;
storage::stats(&db)
.map(|stats| { .map(|stats| {
format!( format!(
"# HELP chronoseal_sessions Active ChronoSeal sessions\n# TYPE chronoseal_sessions gauge\nchronoseal_sessions {}\n# HELP chronoseal_expired_sessions Expired sessions not yet removed\n# TYPE chronoseal_expired_sessions gauge\nchronoseal_expired_sessions {}\n# HELP chronoseal_max_chain_length Maximum heartbeat chain length\n# TYPE chronoseal_max_chain_length gauge\nchronoseal_max_chain_length {}\n", "# HELP chronoseal_sessions Active ChronoSeal sessions\n# TYPE chronoseal_sessions gauge\nchronoseal_sessions {}\n# HELP chronoseal_expired_sessions Expired sessions not yet removed\n# TYPE chronoseal_expired_sessions gauge\nchronoseal_expired_sessions {}\n# HELP chronoseal_max_chain_length Maximum heartbeat chain length\n# TYPE chronoseal_max_chain_length gauge\nchronoseal_max_chain_length {}\n",
@@ -430,6 +419,7 @@ mod tests {
min_pause_count: 0, min_pause_count: 0,
require_mouse_activity: false, require_mouse_activity: false,
gene_size: shared::constants::DEFAULT_GENE_SIZE, gene_size: shared::constants::DEFAULT_GENE_SIZE,
mutation_rounds: shared::constants::DEFAULT_MUTATION_ROUNDS,
} }
} }
@@ -445,11 +435,10 @@ mod tests {
fn test_init_db_pool_sqlite_in_memory() { fn test_init_db_pool_sqlite_in_memory() {
let config = base_config(); let config = base_config();
let pool = init_db_pool(&config).unwrap(); let pool = init_db_pool(&config).unwrap();
let conn = pool.get().unwrap(); let stats = pool.stats().unwrap();
let count: u64 = conn assert_eq!(stats.sessions, 0);
.query_row("SELECT COUNT(*) FROM sessions", [], |row| row.get(0)) assert_eq!(stats.expired_sessions, 0);
.unwrap(); assert_eq!(stats.max_chain_length, 0);
assert_eq!(count, 0);
} }
#[test] #[test]
@@ -459,11 +448,10 @@ mod tests {
config.db_path = std::path::PathBuf::from("/tmp/chronoseal-db-type-disk.sqlite"); config.db_path = std::path::PathBuf::from("/tmp/chronoseal-db-type-disk.sqlite");
let _ = std::fs::remove_file(&config.db_path); let _ = std::fs::remove_file(&config.db_path);
let pool = init_db_pool(&config).unwrap(); let pool = init_db_pool(&config).unwrap();
let conn = pool.get().unwrap(); let stats = pool.stats().unwrap();
let count: u64 = conn assert_eq!(stats.sessions, 0);
.query_row("SELECT COUNT(*) FROM sessions", [], |row| row.get(0)) assert_eq!(stats.expired_sessions, 0);
.unwrap(); assert_eq!(stats.max_chain_length, 0);
assert_eq!(count, 0);
} }
#[test] #[test]
@@ -471,10 +459,9 @@ mod tests {
let mut config = base_config(); let mut config = base_config();
config.db_type = crate::config::DbType::Valkey; config.db_type = crate::config::DbType::Valkey;
let pool = init_db_pool(&config).unwrap(); let pool = init_db_pool(&config).unwrap();
let conn = pool.get().unwrap(); let stats = pool.stats().unwrap();
let count: u64 = conn assert_eq!(stats.sessions, 0);
.query_row("SELECT COUNT(*) FROM sessions", [], |row| row.get(0)) assert_eq!(stats.expired_sessions, 0);
.unwrap(); assert_eq!(stats.max_chain_length, 0);
assert_eq!(count, 0);
} }
} }
+112 -145
View File
@@ -15,7 +15,6 @@ impl AppState {
} }
use crate::{crypto, fingerprint, storage, trust, vm}; use crate::{crypto, fingerprint, storage, trust, vm};
use rusqlite::params;
use shared::{ use shared::{
gene::{self, GeneState}, gene::{self, GeneState},
protocol::{HeartbeatRequest, InitResponse}, protocol::{HeartbeatRequest, InitResponse},
@@ -30,7 +29,7 @@ pub struct HeartbeatVerificationResult {
} }
pub fn create_session( pub fn create_session(
conn: &rusqlite::Connection, db: &storage::DbPool,
config: &crate::config::Config, config: &crate::config::Config,
pub_key_hex: &str, pub_key_hex: &str,
) -> Result<InitResponse, crate::errors::SessionError> { ) -> Result<InitResponse, crate::errors::SessionError> {
@@ -56,25 +55,23 @@ pub fn create_session(
let initial_mutation = vm_extensions::generate_order(1, config.gene_size); let initial_mutation = vm_extensions::generate_order(1, config.gene_size);
let initial_mutation_b64 = vm_extensions::encode_order_b64(&initial_mutation); let initial_mutation_b64 = vm_extensions::encode_order_b64(&initial_mutation);
conn.execute( let record = storage::SessionRecord {
"INSERT INTO sessions ( session_id: session_id.clone(),
session_id, public_key, salt, last_hash, chain_length, created_at, last_seen, expires_at, public_key: pub_key,
gene, environment, pending_mutation, pending_mutation_step salt: salt.to_vec(),
) VALUES (?1, ?2, ?3, ?4, 1, ?5, ?6, ?7, ?8, ?9, ?10, ?11)", last_hash: initial_hash.clone(),
params![ chain_length: 1,
session_id, created_at: now,
pub_key, last_seen: now,
salt.to_vec(),
initial_hash,
now,
now,
expires_at, expires_at,
gene_state.gene, gene: gene_state.gene,
environment_blob, environment: environment_blob,
initial_mutation.program, pending_mutation: initial_mutation.program,
initial_mutation.step, pending_mutation_step: initial_mutation.step,
], };
)?;
db.insert_session(&record)
.map_err(|err| crate::errors::SessionError::Storage(err.to_string()))?;
Ok(InitResponse { Ok(InitResponse {
session_id, session_id,
@@ -91,85 +88,55 @@ pub fn create_session(
} }
pub fn verify_heartbeat( pub fn verify_heartbeat(
conn: &rusqlite::Connection, db: &storage::DbPool,
config: &crate::config::Config, config: &crate::config::Config,
req: &HeartbeatRequest, req: &HeartbeatRequest,
) -> Result<HeartbeatVerificationResult, crate::errors::VerificationError> { ) -> Result<HeartbeatVerificationResult, crate::errors::VerificationError> {
let mut stmt = conn.prepare( let session = db
"SELECT public_key, salt, last_hash, expires_at, gene, environment, pending_mutation, pending_mutation_step .load_session(&req.session_id)
FROM sessions WHERE session_id = ?1", .map_err(|e| crate::errors::VerificationError::Storage(e.to_string()))?;
)?; let session = session.ok_or(crate::errors::VerificationError::SessionNotFound)?;
let (
pub_key,
salt,
stored_last_hash,
expires_at,
gene_blob,
environment_blob,
pending_mutation,
pending_step,
): (
Vec<u8>,
Vec<u8>,
Vec<u8>,
u64,
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)?,
row.get(4)?,
row.get(5)?,
row.get(6)?,
row.get(7)?,
))
})
.map_err(|e| {
if matches!(e, rusqlite::Error::QueryReturnedNoRows) {
crate::errors::VerificationError::SessionNotFound
} else {
crate::errors::VerificationError::Database(e)
}
})?;
let now = storage::current_time_ms(); let now = storage::current_time_ms();
if now > expires_at { if now > session.expires_at {
return Err(crate::errors::VerificationError::Expired); return Err(crate::errors::VerificationError::Expired);
} }
// 1. Verify signature // 1. Verify signature
crypto::verify_signature(&pub_key, req) crypto::verify_signature(&session.public_key, req)
.map_err(|e| crate::errors::VerificationError::Signature(e.to_string()))?; .map_err(|e| crate::errors::VerificationError::Signature(e.to_string()))?;
// 2. Check chain continuity // 2. Check chain continuity
let prev_hash_bytes = hex::decode(&req.prev_hash)?; let prev_hash_bytes = hex::decode(&req.prev_hash)?;
if stored_last_hash != prev_hash_bytes { if session.last_hash != prev_hash_bytes {
return Err(crate::errors::VerificationError::ChainBroken); return Err(crate::errors::VerificationError::ChainBroken);
} }
// 3. Mutation step and deterministic mutation parity // 3. Mutation step and deterministic mutation parity
if req.mutation_step != pending_step { if req.mutation_step != session.pending_mutation_step {
return Err(crate::errors::VerificationError::MutationStepMismatch { return Err(crate::errors::VerificationError::MutationStepMismatch {
expected: pending_step, expected: session.pending_mutation_step,
got: req.mutation_step, got: req.mutation_step,
}); });
} }
let environment = gene::decode_environment(&environment_blob) let environment = gene::decode_environment(&session.environment)
.map_err(|e| crate::errors::VerificationError::GeneState(e.to_string()))?; .map_err(|e| crate::errors::VerificationError::GeneState(e.to_string()))?;
let server_state = GeneState { let server_state = GeneState {
gene: gene_blob, gene: session.gene.clone(),
environment, environment,
}; };
let candidate_state = vm_extensions::apply_program_clone(&server_state, &pending_mutation) let candidate_state = vm_extensions::apply_program_clone_with_rounds(
&server_state,
&session.pending_mutation,
config.mutation_rounds,
)
.map_err(|e| crate::errors::VerificationError::MutationProgram(e.to_string()))?; .map_err(|e| crate::errors::VerificationError::MutationProgram(e.to_string()))?;
let expected_gene_commitment = gene::commitment_hex(&candidate_state); let expected_gene_commitment = gene::commitment_hex_with_context(
&candidate_state,
&req.session_id,
req.mutation_step,
);
if req.gene_commitment != expected_gene_commitment { if req.gene_commitment != expected_gene_commitment {
return Err(crate::errors::VerificationError::MutationCommitmentMismatch); return Err(crate::errors::VerificationError::MutationCommitmentMismatch);
} }
@@ -192,11 +159,11 @@ pub fn verify_heartbeat(
req.timestamp, req.timestamp,
&req.entropy_data, &req.entropy_data,
&req.stack_state, &req.stack_state,
&salt, &session.salt,
); );
// 7. Prepare next mutation order and salt // 7. Prepare next mutation order and salt
let next_step = pending_step + 1; let next_step = session.pending_mutation_step + 1;
let next_mutation = vm_extensions::generate_order(next_step, candidate_state.gene.len()); let next_mutation = vm_extensions::generate_order(next_step, candidate_state.gene.len());
let next_mutation_b64 = vm_extensions::encode_order_b64(&next_mutation); let next_mutation_b64 = vm_extensions::encode_order_b64(&next_mutation);
@@ -205,28 +172,22 @@ pub fn verify_heartbeat(
let next_environment_blob = gene::encode_environment(&candidate_state.environment) let next_environment_blob = gene::encode_environment(&candidate_state.environment)
.map_err(|e| crate::errors::VerificationError::GeneState(e.to_string()))?; .map_err(|e| crate::errors::VerificationError::GeneState(e.to_string()))?;
conn.execute( let update_record = storage::SessionRecord {
"UPDATE sessions SET session_id: req.session_id.clone(),
last_hash=?1, public_key: session.public_key,
salt=?2, salt: next_salt.to_vec(),
chain_length=chain_length+1, last_hash: new_hash.clone(),
last_seen=?3, chain_length: session.chain_length + 1,
gene=?4, created_at: session.created_at,
environment=?5, last_seen: now,
pending_mutation=?6, expires_at: session.expires_at,
pending_mutation_step=?7 gene: candidate_state.gene,
WHERE session_id=?8", environment: next_environment_blob,
params![ pending_mutation: next_mutation.program,
new_hash, pending_mutation_step: next_step,
next_salt.to_vec(), };
now, db.update_session(&update_record)
candidate_state.gene, .map_err(|e| crate::errors::VerificationError::Storage(e.to_string()))?;
next_environment_blob,
next_mutation.program,
next_step,
req.session_id
],
)?;
Ok(HeartbeatVerificationResult { Ok(HeartbeatVerificationResult {
next_salt_hex, next_salt_hex,
@@ -239,6 +200,7 @@ pub fn verify_heartbeat(
mod tests { mod tests {
use super::*; use super::*;
use ed25519_dalek::{Signer, SigningKey}; use ed25519_dalek::{Signer, SigningKey};
use rusqlite::params;
use shared::protocol::{EntropyData, Fingerprint, HeartbeatRequest, MouseEvent, StackState}; use shared::protocol::{EntropyData, Fingerprint, HeartbeatRequest, MouseEvent, StackState};
use std::path::Path; use std::path::Path;
@@ -310,13 +272,13 @@ mod tests {
} }
fn create_test_session( fn create_test_session(
conn: &rusqlite::Connection, db: &storage::DbPool,
config: &crate::config::Config, config: &crate::config::Config,
) -> (InitResponse, SigningKey) { ) -> (InitResponse, SigningKey) {
let mut rng = rand::thread_rng(); let mut rng = rand::thread_rng();
let sk = SigningKey::generate(&mut rng); let sk = SigningKey::generate(&mut rng);
let pk_hex = hex::encode(sk.verifying_key().to_bytes()); let pk_hex = hex::encode(sk.verifying_key().to_bytes());
let init = create_session(conn, config, &pk_hex).unwrap(); let init = create_session(db, config, &pk_hex).unwrap();
(init, sk) (init, sk)
} }
@@ -355,7 +317,11 @@ mod tests {
stack_state: stack.clone(), stack_state: stack.clone(),
fingerprint: test_fingerprint(), fingerprint: test_fingerprint(),
mutation_step: client.pending_mutation_step, mutation_step: client.pending_mutation_step,
gene_commitment: gene::commitment_hex(&candidate_state), gene_commitment: gene::commitment_hex_with_context(
&candidate_state,
&client.session_id,
client.pending_mutation_step,
),
signature: String::new(), signature: String::new(),
}; };
sign_request(&client.signing_key, &mut req); sign_request(&client.signing_key, &mut req);
@@ -382,28 +348,22 @@ mod tests {
client.committed_gene_state = candidate_state; client.committed_gene_state = candidate_state;
} }
fn load_server_gene_state(conn: &rusqlite::Connection, session_id: &str) -> GeneState { fn load_server_gene_state(db: &storage::DbPool, session_id: &str) -> GeneState {
let (gene_blob, env_blob): (Vec<u8>, Vec<u8>) = conn let session = db.load_session(session_id).unwrap().unwrap();
.query_row(
"SELECT gene, environment FROM sessions WHERE session_id=?1",
[session_id],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.unwrap();
GeneState { GeneState {
gene: gene_blob, gene: session.gene,
environment: gene::decode_environment(&env_blob).unwrap(), environment: gene::decode_environment(&session.environment).unwrap(),
} }
} }
fn run_successful_heartbeat( fn run_successful_heartbeat(
conn: &rusqlite::Connection, db: &storage::DbPool,
config: &crate::config::Config, config: &crate::config::Config,
client: &mut SimulatedClient, client: &mut SimulatedClient,
) -> HeartbeatRequest { ) -> HeartbeatRequest {
let timestamp = storage::current_time_ms(); let timestamp = storage::current_time_ms();
let (req, candidate_state, entropy, stack) = build_request(client, timestamp); let (req, candidate_state, entropy, stack) = build_request(client, timestamp);
let result = verify_heartbeat(conn, config, &req).unwrap(); let result = verify_heartbeat(db, config, &req).unwrap();
apply_successful_response(client, &req, candidate_state, &entropy, &stack, &result); apply_successful_response(client, &req, candidate_state, &entropy, &stack, &result);
req req
} }
@@ -411,20 +371,19 @@ mod tests {
#[test] #[test]
fn test_session_lifecycle_and_verification() { fn test_session_lifecycle_and_verification() {
let pool = storage::init_pool(Path::new(":memory:")).unwrap(); let pool = storage::init_pool(Path::new(":memory:")).unwrap();
let conn = pool.get().unwrap();
let config = test_config(); let config = test_config();
let (init, signing_key) = create_test_session(&conn, &config); let (init, signing_key) = create_test_session(&pool, &config);
assert_eq!(init.gene_size, config.gene_size as u32); assert_eq!(init.gene_size, config.gene_size as u32);
assert!(!init.mutation_order_b64.is_empty()); assert!(!init.mutation_order_b64.is_empty());
assert_eq!(init.mutation_step, 1); assert_eq!(init.mutation_step, 1);
let mut client = client_from_init(&init, signing_key); let mut client = client_from_init(&init, signing_key);
for _ in 0..5 { for _ in 0..5 {
run_successful_heartbeat(&conn, &config, &mut client); run_successful_heartbeat(&pool, &config, &mut client);
} }
let stats = storage::stats(&conn).unwrap(); let stats = pool.stats().unwrap();
assert_eq!(stats.sessions, 1); assert_eq!(stats.sessions, 1);
assert_eq!(stats.max_chain_length, 6); assert_eq!(stats.max_chain_length, 6);
} }
@@ -432,14 +391,13 @@ mod tests {
#[test] #[test]
fn test_deterministic_server_client_parity_across_many_heartbeats() { fn test_deterministic_server_client_parity_across_many_heartbeats() {
let pool = storage::init_pool(Path::new(":memory:")).unwrap(); let pool = storage::init_pool(Path::new(":memory:")).unwrap();
let conn = pool.get().unwrap();
let config = test_config(); let config = test_config();
let (init, signing_key) = create_test_session(&conn, &config); let (init, signing_key) = create_test_session(&pool, &config);
let mut client = client_from_init(&init, signing_key); let mut client = client_from_init(&init, signing_key);
for _ in 0..12 { for _ in 0..12 {
run_successful_heartbeat(&conn, &config, &mut client); run_successful_heartbeat(&pool, &config, &mut client);
let server_state = load_server_gene_state(&conn, &client.session_id); let server_state = load_server_gene_state(&pool, &client.session_id);
assert_eq!(server_state, client.committed_gene_state); assert_eq!(server_state, client.committed_gene_state);
} }
} }
@@ -447,14 +405,13 @@ mod tests {
#[test] #[test]
fn test_replay_attack_is_rejected() { fn test_replay_attack_is_rejected() {
let pool = storage::init_pool(Path::new(":memory:")).unwrap(); let pool = storage::init_pool(Path::new(":memory:")).unwrap();
let conn = pool.get().unwrap();
let config = test_config(); let config = test_config();
let (init, signing_key) = create_test_session(&conn, &config); let (init, signing_key) = create_test_session(&pool, &config);
let mut client = client_from_init(&init, signing_key); let mut client = client_from_init(&init, signing_key);
let timestamp = storage::current_time_ms(); let timestamp = storage::current_time_ms();
let (req, candidate_state, entropy, stack) = build_request(&client, timestamp); let (req, candidate_state, entropy, stack) = build_request(&client, timestamp);
let result = verify_heartbeat(&conn, &config, &req).unwrap(); let result = verify_heartbeat(&pool, &config, &req).unwrap();
apply_successful_response( apply_successful_response(
&mut client, &mut client,
&req, &req,
@@ -464,7 +421,7 @@ mod tests {
&result, &result,
); );
let replay = verify_heartbeat(&conn, &config, &req); let replay = verify_heartbeat(&pool, &config, &req);
assert!(matches!( assert!(matches!(
replay.unwrap_err(), replay.unwrap_err(),
crate::errors::VerificationError::ChainBroken crate::errors::VerificationError::ChainBroken
@@ -474,9 +431,8 @@ mod tests {
#[test] #[test]
fn test_mutation_step_mismatch_is_rejected() { fn test_mutation_step_mismatch_is_rejected() {
let pool = storage::init_pool(Path::new(":memory:")).unwrap(); let pool = storage::init_pool(Path::new(":memory:")).unwrap();
let conn = pool.get().unwrap();
let config = test_config(); let config = test_config();
let (init, signing_key) = create_test_session(&conn, &config); let (init, signing_key) = create_test_session(&pool, &config);
let client = client_from_init(&init, signing_key); let client = client_from_init(&init, signing_key);
let timestamp = storage::current_time_ms(); let timestamp = storage::current_time_ms();
@@ -484,7 +440,7 @@ mod tests {
req.mutation_step += 1; req.mutation_step += 1;
sign_request(&client.signing_key, &mut req); sign_request(&client.signing_key, &mut req);
let err = verify_heartbeat(&conn, &config, &req).unwrap_err(); let err = verify_heartbeat(&pool, &config, &req).unwrap_err();
assert!(matches!( assert!(matches!(
err, err,
crate::errors::VerificationError::MutationStepMismatch { .. } crate::errors::VerificationError::MutationStepMismatch { .. }
@@ -494,9 +450,8 @@ mod tests {
#[test] #[test]
fn test_mutation_commitment_tamper_is_rejected() { fn test_mutation_commitment_tamper_is_rejected() {
let pool = storage::init_pool(Path::new(":memory:")).unwrap(); let pool = storage::init_pool(Path::new(":memory:")).unwrap();
let conn = pool.get().unwrap();
let config = test_config(); let config = test_config();
let (init, signing_key) = create_test_session(&conn, &config); let (init, signing_key) = create_test_session(&pool, &config);
let client = client_from_init(&init, signing_key); let client = client_from_init(&init, signing_key);
let timestamp = storage::current_time_ms(); let timestamp = storage::current_time_ms();
@@ -504,7 +459,7 @@ mod tests {
req.gene_commitment = "00".repeat(32); req.gene_commitment = "00".repeat(32);
sign_request(&client.signing_key, &mut req); sign_request(&client.signing_key, &mut req);
let err = verify_heartbeat(&conn, &config, &req).unwrap_err(); let err = verify_heartbeat(&pool, &config, &req).unwrap_err();
assert!(matches!( assert!(matches!(
err, err,
crate::errors::VerificationError::MutationCommitmentMismatch crate::errors::VerificationError::MutationCommitmentMismatch
@@ -514,9 +469,12 @@ mod tests {
#[test] #[test]
fn test_malformed_server_mutation_program_is_rejected() { fn test_malformed_server_mutation_program_is_rejected() {
let pool = storage::init_pool(Path::new(":memory:")).unwrap(); let pool = storage::init_pool(Path::new(":memory:")).unwrap();
let conn = pool.get().unwrap(); let conn = match &pool {
storage::DbPool::Sqlite(pool) => pool.get().unwrap(),
_ => panic!("expected sqlite pool for test"),
};
let config = test_config(); let config = test_config();
let (init, signing_key) = create_test_session(&conn, &config); let (init, signing_key) = create_test_session(&pool, &config);
let client = client_from_init(&init, signing_key); let client = client_from_init(&init, signing_key);
conn.execute( conn.execute(
@@ -525,9 +483,18 @@ mod tests {
) )
.unwrap(); .unwrap();
let updated: Vec<u8> = conn
.query_row(
"SELECT pending_mutation FROM sessions WHERE session_id=?1",
params![client.session_id.clone()],
|row| row.get(0),
)
.unwrap();
assert_eq!(updated, vec![0xFFu8]);
let timestamp = storage::current_time_ms(); let timestamp = storage::current_time_ms();
let (req, _, _, _) = build_request(&client, timestamp); let (req, _, _, _) = build_request(&client, timestamp);
let err = verify_heartbeat(&conn, &config, &req).unwrap_err(); let err = verify_heartbeat(&pool, &config, &req).unwrap_err();
assert!(matches!( assert!(matches!(
err, err,
crate::errors::VerificationError::MutationProgram(_) crate::errors::VerificationError::MutationProgram(_)
@@ -537,9 +504,12 @@ mod tests {
#[test] #[test]
fn test_expired_session_is_rejected() { fn test_expired_session_is_rejected() {
let pool = storage::init_pool(Path::new(":memory:")).unwrap(); let pool = storage::init_pool(Path::new(":memory:")).unwrap();
let conn = pool.get().unwrap(); let conn = match &pool {
storage::DbPool::Sqlite(pool) => pool.get().unwrap(),
_ => panic!("expected sqlite pool for test"),
};
let config = test_config(); let config = test_config();
let (init, signing_key) = create_test_session(&conn, &config); let (init, signing_key) = create_test_session(&pool, &config);
let client = client_from_init(&init, signing_key); let client = client_from_init(&init, signing_key);
conn.execute( conn.execute(
@@ -550,16 +520,15 @@ mod tests {
let timestamp = storage::current_time_ms(); let timestamp = storage::current_time_ms();
let (req, _, _, _) = build_request(&client, timestamp); let (req, _, _, _) = build_request(&client, timestamp);
let err = verify_heartbeat(&conn, &config, &req).unwrap_err(); let err = verify_heartbeat(&pool, &config, &req).unwrap_err();
assert!(matches!(err, crate::errors::VerificationError::Expired)); assert!(matches!(err, crate::errors::VerificationError::Expired));
} }
#[test] #[test]
fn test_create_session_rejects_invalid_public_key_length() { fn test_create_session_rejects_invalid_public_key_length() {
let pool = storage::init_pool(Path::new(":memory:")).unwrap(); let pool = storage::init_pool(Path::new(":memory:")).unwrap();
let conn = pool.get().unwrap();
let config = test_config(); let config = test_config();
let err = create_session(&conn, &config, "00ff").unwrap_err(); let err = create_session(&pool, &config, "00ff").unwrap_err();
assert!(matches!( assert!(matches!(
err, err,
crate::errors::SessionError::InvalidPublicKeyLength crate::errors::SessionError::InvalidPublicKeyLength
@@ -569,19 +538,18 @@ mod tests {
#[test] #[test]
fn test_stale_mutation_step_after_success_is_rejected() { fn test_stale_mutation_step_after_success_is_rejected() {
let pool = storage::init_pool(Path::new(":memory:")).unwrap(); let pool = storage::init_pool(Path::new(":memory:")).unwrap();
let conn = pool.get().unwrap();
let config = test_config(); let config = test_config();
let (init, signing_key) = create_test_session(&conn, &config); let (init, signing_key) = create_test_session(&pool, &config);
let mut client = client_from_init(&init, signing_key); let mut client = client_from_init(&init, signing_key);
run_successful_heartbeat(&conn, &config, &mut client); run_successful_heartbeat(&pool, &config, &mut client);
let timestamp = storage::current_time_ms(); let timestamp = storage::current_time_ms();
let (mut req, _, _, _) = build_request(&client, timestamp); let (mut req, _, _, _) = build_request(&client, timestamp);
req.mutation_step -= 1; req.mutation_step -= 1;
sign_request(&client.signing_key, &mut req); sign_request(&client.signing_key, &mut req);
let err = verify_heartbeat(&conn, &config, &req).unwrap_err(); let err = verify_heartbeat(&pool, &config, &req).unwrap_err();
assert!(matches!( assert!(matches!(
err, err,
crate::errors::VerificationError::MutationStepMismatch { .. } crate::errors::VerificationError::MutationStepMismatch { .. }
@@ -591,15 +559,14 @@ mod tests {
#[test] #[test]
fn test_repeated_simulation_keeps_server_and_client_commitments_equal() { fn test_repeated_simulation_keeps_server_and_client_commitments_equal() {
let pool = storage::init_pool(Path::new(":memory:")).unwrap(); let pool = storage::init_pool(Path::new(":memory:")).unwrap();
let conn = pool.get().unwrap();
let mut config = test_config(); let mut config = test_config();
config.gene_size = 128; config.gene_size = 128;
let (init, signing_key) = create_test_session(&conn, &config); let (init, signing_key) = create_test_session(&pool, &config);
let mut client = client_from_init(&init, signing_key); let mut client = client_from_init(&init, signing_key);
for _ in 0..10 { for _ in 0..10 {
run_successful_heartbeat(&conn, &config, &mut client); run_successful_heartbeat(&pool, &config, &mut client);
let server_state = load_server_gene_state(&conn, &client.session_id); let server_state = load_server_gene_state(&pool, &client.session_id);
assert_eq!( assert_eq!(
gene::commitment(&server_state), gene::commitment(&server_state),
gene::commitment(&client.committed_gene_state) gene::commitment(&client.committed_gene_state)
+276 -15
View File
@@ -1,7 +1,9 @@
use rusqlite::Connection; use crate::config::Config;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::path::Path; use std::path::Path;
use std::sync::{Arc, Mutex};
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
use valkey::Client as ValkeyClient;
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StoreStats { pub struct StoreStats {
@@ -10,9 +12,214 @@ pub struct StoreStats {
pub max_chain_length: u64, pub max_chain_length: u64,
} }
pub type DbPool = r2d2::Pool<r2d2_sqlite::SqliteConnectionManager>; #[derive(Debug, Clone)]
pub enum DbPool {
Sqlite(r2d2::Pool<r2d2_sqlite::SqliteConnectionManager>),
Valkey(ValkeyStore),
}
#[derive(Debug, Clone)]
pub struct ValkeyStore {
client: Arc<Mutex<ValkeyClient>>,
index_key: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionRecord {
pub session_id: String,
pub public_key: Vec<u8>,
pub salt: Vec<u8>,
pub last_hash: Vec<u8>,
pub chain_length: u64,
pub created_at: u64,
pub last_seen: u64,
pub expires_at: u64,
pub gene: Vec<u8>,
pub environment: Vec<u8>,
pub pending_mutation: Vec<u8>,
pub pending_mutation_step: u64,
}
impl DbPool {
pub fn init(config: &Config) -> Result<Self, Box<dyn std::error::Error>> {
match config.db_type {
crate::config::DbType::SqliteInMemory => {
let pool = init_sqlite_pool(Path::new(":memory:"))?;
Ok(DbPool::Sqlite(pool))
}
crate::config::DbType::SqliteInDisk => {
let pool = init_sqlite_pool(&config.db_path)?;
Ok(DbPool::Sqlite(pool))
}
crate::config::DbType::Valkey => {
let addr = std::env::var("CHRONOSEAL_VALKEY_ADDR").unwrap_or_else(|_| "127.0.0.1:6666".to_string());
match ValkeyClient::connect(addr) {
Ok(client) => Ok(DbPool::Valkey(ValkeyStore {
client: Arc::new(Mutex::new(client)),
index_key: "sessions:ids".to_string(),
})),
Err(err) => {
tracing::warn!("valkey connection failed, falling back to sqlite-in-memory: {err}");
let pool = init_sqlite_pool(Path::new(":memory:"))?;
Ok(DbPool::Sqlite(pool))
}
}
}
}
}
pub fn insert_session(&self, record: &SessionRecord) -> Result<(), Box<dyn std::error::Error>> {
match self {
DbPool::Sqlite(pool) => {
let conn = pool.get()?;
let mut stmt = conn.prepare(
"INSERT INTO sessions (
session_id, public_key, salt, last_hash, chain_length,
created_at, last_seen, expires_at, gene, environment,
pending_mutation, pending_mutation_step
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
)?;
stmt.execute(rusqlite::params![
record.session_id,
&record.public_key,
&record.salt,
&record.last_hash,
record.chain_length,
record.created_at,
record.last_seen,
record.expires_at,
&record.gene,
&record.environment,
&record.pending_mutation,
record.pending_mutation_step,
])?;
Ok(())
}
DbPool::Valkey(store) => store.insert_session(record),
}
}
pub fn load_session(&self, session_id: &str) -> Result<Option<SessionRecord>, Box<dyn std::error::Error>> {
match self {
DbPool::Sqlite(pool) => {
let conn = pool.get()?;
let mut stmt = conn.prepare(
"SELECT session_id, public_key, salt, last_hash, chain_length, created_at, last_seen, expires_at, gene, environment, pending_mutation, pending_mutation_step
FROM sessions WHERE session_id = ?1",
)?;
let row = stmt.query_row([session_id], |row| {
Ok(SessionRecord {
session_id: row.get(0)?,
public_key: row.get(1)?,
salt: row.get(2)?,
last_hash: row.get(3)?,
chain_length: row.get(4)?,
created_at: row.get(5)?,
last_seen: row.get(6)?,
expires_at: row.get(7)?,
gene: row.get(8)?,
environment: row.get(9)?,
pending_mutation: row.get(10)?,
pending_mutation_step: row.get(11)?,
})
});
match row {
Ok(rec) => Ok(Some(rec)),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(err) => Err(Box::new(err)),
}
}
DbPool::Valkey(store) => store.load_session(session_id),
}
}
pub fn update_session(&self, record: &SessionRecord) -> Result<(), Box<dyn std::error::Error>> {
match self {
DbPool::Sqlite(pool) => {
let conn = pool.get()?;
conn.execute(
"UPDATE sessions SET
public_key=?1,
salt=?2,
last_hash=?3,
chain_length=?4,
created_at=?5,
last_seen=?6,
expires_at=?7,
gene=?8,
environment=?9,
pending_mutation=?10,
pending_mutation_step=?11
WHERE session_id=?12",
rusqlite::params![
&record.public_key,
&record.salt,
&record.last_hash,
record.chain_length,
record.created_at,
record.last_seen,
record.expires_at,
&record.gene,
&record.environment,
&record.pending_mutation,
record.pending_mutation_step,
&record.session_id,
],
)?;
Ok(())
}
DbPool::Valkey(store) => store.insert_session(record),
}
}
pub fn delete_expired_sessions(&self) -> Result<(), Box<dyn std::error::Error>> {
match self {
DbPool::Sqlite(pool) => {
let conn = pool.get()?;
conn.execute(
"DELETE FROM sessions WHERE expires_at < ?1",
rusqlite::params![current_time_ms()],
)?;
Ok(())
}
DbPool::Valkey(store) => store.purge_expired_sessions(),
}
}
pub fn stats(&self) -> Result<StoreStats, Box<dyn std::error::Error>> {
match self {
DbPool::Sqlite(pool) => {
let conn = pool.get()?;
let now = current_time_ms();
let sessions = conn.query_row("SELECT COUNT(*) FROM sessions", [], |row| row.get(0))?;
let expired_sessions = conn.query_row(
"SELECT COUNT(*) FROM sessions WHERE expires_at < ?1",
[now],
|row| row.get(0),
)?;
let max_chain_length = conn.query_row(
"SELECT COALESCE(MAX(chain_length), 0) FROM sessions",
[],
|row| row.get(0),
)?;
Ok(StoreStats {
sessions,
expired_sessions,
max_chain_length,
})
}
DbPool::Valkey(store) => store.stats(),
}
}
}
#[cfg(test)]
pub fn init_pool(path: &Path) -> Result<DbPool, Box<dyn std::error::Error>> { pub fn init_pool(path: &Path) -> Result<DbPool, Box<dyn std::error::Error>> {
let pool = init_sqlite_pool(path)?;
Ok(DbPool::Sqlite(pool))
}
fn init_sqlite_pool(path: &Path) -> Result<r2d2::Pool<r2d2_sqlite::SqliteConnectionManager>, Box<dyn std::error::Error>> {
let manager = if path == Path::new(":memory:") { let manager = if path == Path::new(":memory:") {
r2d2_sqlite::SqliteConnectionManager::memory() r2d2_sqlite::SqliteConnectionManager::memory()
} else { } else {
@@ -21,7 +228,6 @@ pub fn init_pool(path: &Path) -> Result<DbPool, Box<dyn std::error::Error>> {
} }
r2d2_sqlite::SqliteConnectionManager::file(path) r2d2_sqlite::SqliteConnectionManager::file(path)
}; };
let pool = r2d2::Pool::new(manager)?; let pool = r2d2::Pool::new(manager)?;
let conn = pool.get()?; let conn = pool.get()?;
init_schema(&conn)?; init_schema(&conn)?;
@@ -89,24 +295,79 @@ fn ensure_column(
Ok(()) Ok(())
} }
pub fn stats(conn: &Connection) -> Result<StoreStats, rusqlite::Error> { impl ValkeyStore {
fn session_key(&self, session_id: &str) -> String {
format!("session:{}", session_id)
}
fn load_session(&self, session_id: &str) -> Result<Option<SessionRecord>, Box<dyn std::error::Error>> {
let mut client = self.client.lock().unwrap();
if let Some(payload) = client.get(&self.session_key(session_id))? {
let record = serde_json::from_str(&payload)?;
Ok(Some(record))
} else {
Ok(None)
}
}
fn insert_session(&self, record: &SessionRecord) -> Result<(), Box<dyn std::error::Error>> {
let mut client = self.client.lock().unwrap();
let value = serde_json::to_string(record)?;
client.set(&self.session_key(&record.session_id), &value)?;
let existing = client.get(&self.index_key)?;
let mut ids = existing.unwrap_or_default();
if !ids.split('\n').any(|id| id == record.session_id) {
if !ids.is_empty() {
ids.push('\n');
}
ids.push_str(&record.session_id);
client.set(&self.index_key, &ids)?;
}
Ok(())
}
fn purge_expired_sessions(&self) -> Result<(), Box<dyn std::error::Error>> {
let mut client = self.client.lock().unwrap();
let ids = client.get(&self.index_key)?.unwrap_or_default();
let now = current_time_ms(); let now = current_time_ms();
let sessions = conn.query_row("SELECT COUNT(*) FROM sessions", [], |row| row.get(0))?; let mut remaining: Vec<String> = Vec::new();
let expired_sessions = conn.query_row( for id in ids.split('\n').filter(|id| !id.is_empty()) {
"SELECT COUNT(*) FROM sessions WHERE expires_at < ?1", if let Some(payload) = client.get(&self.session_key(id))? {
[now], if let Ok(record) = serde_json::from_str::<SessionRecord>(&payload) {
|row| row.get(0), if record.expires_at > now {
)?; remaining.push(id.to_string());
let max_chain_length = conn.query_row( }
"SELECT COALESCE(MAX(chain_length), 0) FROM sessions", }
[], }
|row| row.get(0), }
)?; client.set(&self.index_key, &remaining.join("\n"))?;
Ok(())
}
fn stats(&self) -> Result<StoreStats, Box<dyn std::error::Error>> {
let mut client = self.client.lock().unwrap();
let ids = client.get(&self.index_key)?.unwrap_or_default();
let now = current_time_ms();
let mut sessions = 0;
let mut expired_sessions = 0;
let mut max_chain_length = 0;
for id in ids.split('\n').filter(|id| !id.is_empty()) {
if let Some(payload) = client.get(&self.session_key(id))? {
if let Ok(record) = serde_json::from_str::<SessionRecord>(&payload) {
sessions += 1;
if record.expires_at < now {
expired_sessions += 1;
}
max_chain_length = max_chain_length.max(record.chain_length);
}
}
}
Ok(StoreStats { Ok(StoreStats {
sessions, sessions,
expired_sessions, expired_sessions,
max_chain_length, max_chain_length,
}) })
}
} }
pub fn current_time_ms() -> u64 { pub fn current_time_ms() -> u64 {
+1
View File
@@ -11,3 +11,4 @@ hex = "0.4"
base64 = "0.22" base64 = "0.22"
rand = "0.8" rand = "0.8"
ed25519-dalek = { version = "2", features = ["rand_core"] } ed25519-dalek = { version = "2", features = ["rand_core"] }
tracing = "0.1"
+6
View File
@@ -4,3 +4,9 @@ pub const DEFAULT_GENE_SIZE: usize = 512;
pub const MAX_GENE_SIZE: usize = 4096; pub const MAX_GENE_SIZE: usize = 4096;
pub const MAX_ENV_RECORDS: usize = 48; pub const MAX_ENV_RECORDS: usize = 48;
pub const MAX_MUTATION_PROGRAM_BYTES: usize = 256; pub const MAX_MUTATION_PROGRAM_BYTES: usize = 256;
pub const DEFAULT_MUTATION_ROUNDS: u8 = 4;
pub const MIN_MUTATION_ROUNDS: u8 = 3;
pub const MAX_MUTATION_ROUNDS: u8 = 10;
pub const MAX_MUTATION_INSTRUCTION_BUDGET: usize = 2048;
pub const HASH_OPCODE_INSTRUCTION_COST: usize = 16;
pub const SOFT_CAP_DURATION_MS: u128 = 50;
+13
View File
@@ -197,6 +197,19 @@ pub fn commitment_hex(state: &GeneState) -> String {
hex::encode(commitment(state)) hex::encode(commitment(state))
} }
pub fn commitment_with_context(state: &GeneState, session_id: &str, step: u64) -> [u8; 32] {
let mut h = blake3::Hasher::new();
h.update(b"chronoseal/gene/v1");
h.update(session_id.as_bytes());
h.update(&step.to_le_bytes());
h.update(&commitment(state));
*h.finalize().as_bytes()
}
pub fn commitment_hex_with_context(state: &GeneState, session_id: &str, step: u64) -> String {
hex::encode(commitment_with_context(state, session_id, step))
}
fn validate_environment(records: &[EnvironmentRecord]) -> Result<(), GeneError> { fn validate_environment(records: &[EnvironmentRecord]) -> Result<(), GeneError> {
if records.len() > MAX_ENV_RECORDS { if records.len() > MAX_ENV_RECORDS {
return Err(GeneError::TooManyEnvironmentRecords { len: records.len() }); return Err(GeneError::TooManyEnvironmentRecords { len: records.len() });
+128 -21
View File
@@ -1,11 +1,15 @@
use crate::{ use crate::{
constants::{MAX_GENE_SIZE, MAX_MUTATION_PROGRAM_BYTES}, constants::{
HASH_OPCODE_INSTRUCTION_COST, MAX_GENE_SIZE, MAX_MUTATION_PROGRAM_BYTES,
MAX_MUTATION_INSTRUCTION_BUDGET, DEFAULT_MUTATION_ROUNDS, SOFT_CAP_DURATION_MS,
},
gene::{ gene::{
add_env_quantity, get_env_quantity, sub_env_quantity, validate_state, GeneError, GeneState, add_env_quantity, get_env_quantity, sub_env_quantity, validate_state, GeneError, GeneState,
}, },
}; };
use rand::Rng; use rand::Rng;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::time::Instant;
// Stack-machine mutation opcodes (v0.6.0). // Stack-machine mutation opcodes (v0.6.0).
// //
@@ -107,48 +111,65 @@ pub fn generate_order_with_rng<R: Rng + ?Sized>(
step: u64, step: u64,
gene_size: usize, gene_size: usize,
) -> MutationOrder { ) -> MutationOrder {
let mut program = Vec::with_capacity(96); let mut program = Vec::with_capacity(128);
let mut stack_depth: i32 = 0; let mut stack_depth: i32 = 0;
let mut estimated_gene_len = gene_size.clamp(1, MAX_GENE_SIZE); let mut estimated_gene_len = gene_size.clamp(1, MAX_GENE_SIZE);
let ops = rng.gen_range(8usize..=18usize); let ops = rng.gen_range(20usize..=36usize);
let mut hash_ops_needed = rng.gen_range(2..=3);
for _ in 0..ops { for idx in 0..ops {
let op = if stack_depth <= 0 { let remaining = ops - idx;
let op = if hash_ops_needed > 0 && remaining <= hash_ops_needed {
OP_FINALIZE_GENE_HASH
} else if stack_depth <= 0 {
rng.gen_range(0u8..3u8) rng.gen_range(0u8..3u8)
} else { } else {
rng.gen_range(0u8..10u8) match rng.gen_range(0u8..12u8) {
0..=1 => OP_GENE_LOAD,
2..=3 => OP_TRANSCRIBE,
4 => OP_FINALIZE_GENE_HASH,
5 => OP_GENE_STORE,
6 => OP_MUTATE_POINT,
7 => OP_INSERT,
8 => OP_DELETE,
9 => OP_APPLY_MUTAGEN,
10 => OP_CONSUME,
_ => OP_PRODUCE,
}
}; };
match op { match op {
// Pushers OP_GENE_LOAD => {
0 => {
program.push(OP_GENE_LOAD); program.push(OP_GENE_LOAD);
push_u16(&mut program, rng.r#gen::<u16>()); push_u16(&mut program, rng.r#gen::<u16>());
stack_depth += 1; stack_depth += 1;
} }
1 => { OP_TRANSCRIBE => {
program.push(OP_TRANSCRIBE); program.push(OP_TRANSCRIBE);
push_u16(&mut program, rng.r#gen::<u16>()); push_u16(&mut program, rng.r#gen::<u16>());
program.push(rng.gen_range(1u8..=16u8)); program.push(rng.gen_range(1u8..=16u8));
stack_depth += 1; stack_depth += 1;
} }
2 => { OP_FINALIZE_GENE_HASH => {
program.push(OP_FINALIZE_GENE_HASH); program.push(OP_FINALIZE_GENE_HASH);
stack_depth += 1; stack_depth += 1;
if hash_ops_needed > 0 {
hash_ops_needed -= 1;
} }
// Consumers }
3 => { OP_GENE_STORE => {
if stack_depth > 0 { if stack_depth > 0 {
program.push(OP_GENE_STORE); program.push(OP_GENE_STORE);
push_u16(&mut program, rng.r#gen::<u16>()); push_u16(&mut program, rng.r#gen::<u16>());
stack_depth -= 1; stack_depth -= 1;
} }
} }
4 => { OP_MUTATE_POINT => {
program.push(OP_MUTATE_POINT); program.push(OP_MUTATE_POINT);
push_u16(&mut program, rng.r#gen::<u16>()); push_u16(&mut program, rng.r#gen::<u16>());
program.push(rng.r#gen::<u8>()); program.push(rng.r#gen::<u8>());
} }
5 => { OP_INSERT => {
if stack_depth > 0 && estimated_gene_len < MAX_GENE_SIZE { if stack_depth > 0 && estimated_gene_len < MAX_GENE_SIZE {
program.push(OP_INSERT); program.push(OP_INSERT);
push_u16(&mut program, rng.r#gen::<u16>()); push_u16(&mut program, rng.r#gen::<u16>());
@@ -156,7 +177,7 @@ pub fn generate_order_with_rng<R: Rng + ?Sized>(
estimated_gene_len += 1; estimated_gene_len += 1;
} }
} }
6 => { OP_DELETE => {
program.push(OP_DELETE); program.push(OP_DELETE);
push_u16(&mut program, rng.r#gen::<u16>()); push_u16(&mut program, rng.r#gen::<u16>());
stack_depth += 1; stack_depth += 1;
@@ -164,7 +185,7 @@ pub fn generate_order_with_rng<R: Rng + ?Sized>(
estimated_gene_len -= 1; estimated_gene_len -= 1;
} }
} }
7 => { OP_APPLY_MUTAGEN => {
if stack_depth > 0 { if stack_depth > 0 {
program.push(OP_APPLY_MUTAGEN); program.push(OP_APPLY_MUTAGEN);
push_u16(&mut program, rng.r#gen::<u16>()); push_u16(&mut program, rng.r#gen::<u16>());
@@ -172,35 +193,121 @@ pub fn generate_order_with_rng<R: Rng + ?Sized>(
stack_depth -= 1; stack_depth -= 1;
} }
} }
8 => { OP_CONSUME => {
if stack_depth > 0 { if stack_depth > 0 {
program.push(OP_CONSUME); program.push(OP_CONSUME);
push_u16(&mut program, rng.r#gen::<u16>()); push_u16(&mut program, rng.r#gen::<u16>());
} }
} }
_ => { OP_PRODUCE => {
if stack_depth > 0 { if stack_depth > 0 {
program.push(OP_PRODUCE); program.push(OP_PRODUCE);
push_u16(&mut program, rng.r#gen::<u16>()); push_u16(&mut program, rng.r#gen::<u16>());
} }
} }
_ => {}
} }
} }
while hash_ops_needed > 0 && program.len() + 1 <= MAX_MUTATION_PROGRAM_BYTES {
program.push(OP_FINALIZE_GENE_HASH);
hash_ops_needed -= 1;
}
MutationOrder { step, program } MutationOrder { step, program }
} }
pub fn apply_program_clone(state: &GeneState, program: &[u8]) -> Result<GeneState, MutationError> { pub fn apply_program_clone(state: &GeneState, program: &[u8]) -> Result<GeneState, MutationError> {
apply_program_clone_with_rounds(state, program, DEFAULT_MUTATION_ROUNDS)
}
pub fn apply_program_clone_with_rounds(
state: &GeneState,
program: &[u8],
rounds: u8,
) -> Result<GeneState, MutationError> {
let mut next = state.clone(); let mut next = state.clone();
apply_program(&mut next, program)?; execute_program_with_rounds(&mut next, program, rounds)?;
Ok(next) Ok(next)
} }
pub fn apply_program(state: &mut GeneState, program: &[u8]) -> Result<(), MutationError> { pub fn apply_program_with_rounds(
let _ = execute_program(state, program)?; state: &mut GeneState,
program: &[u8],
rounds: u8,
) -> Result<(), MutationError> {
let _ = execute_program_with_rounds(state, program, rounds)?;
Ok(()) Ok(())
} }
pub fn apply_program(state: &mut GeneState, program: &[u8]) -> Result<(), MutationError> {
let _ = execute_program_with_rounds(state, program, DEFAULT_MUTATION_ROUNDS)?;
Ok(())
}
pub fn execute_program_with_rounds(
state: &mut GeneState,
program: &[u8],
rounds: u8,
) -> Result<ExecutionTrace, MutationError> {
if state.gene.is_empty() {
return Err(MutationError::EmptyGene);
}
validate_state(state)?;
if program.len() > MAX_MUTATION_PROGRAM_BYTES {
return Err(MutationError::ProgramTooLong { len: program.len() });
}
let program_cost = estimate_program_cost(program);
let max_rounds = std::cmp::max(1, MAX_MUTATION_INSTRUCTION_BUDGET / program_cost);
let actual_rounds = std::cmp::min(rounds as usize, max_rounds) as u8;
let start = Instant::now();
let mut trace = None;
for _round in 0..actual_rounds {
trace = Some(execute_program(state, program)?);
}
let elapsed = start.elapsed();
tracing::debug!(rounds = actual_rounds, requested_rounds = rounds, elapsed_ms = elapsed.as_millis(), program_len = program.len(), "mutation execution");
if actual_rounds < rounds {
tracing::debug!(requested_rounds = rounds, executed_rounds = actual_rounds, "mutation soft cap reduced mutation rounds to preserve host responsiveness");
}
if elapsed.as_millis() > SOFT_CAP_DURATION_MS {
tracing::debug!(elapsed_ms = elapsed.as_millis(), "mutation execution exceeded soft cap duration");
}
Ok(trace.unwrap_or_else(|| ExecutionTrace {
final_ip: 0,
final_stack: Vec::new(),
final_gene_commitment_hex: crate::gene::commitment_hex(state),
}))
}
fn estimate_program_cost(program: &[u8]) -> usize {
let mut ip = 0;
let mut cost = 0;
while ip < program.len() {
let opcode = program[ip];
ip += 1;
cost += if opcode == OP_FINALIZE_GENE_HASH {
HASH_OPCODE_INSTRUCTION_COST
} else {
1
};
ip += match opcode {
OP_GENE_LOAD | OP_GENE_STORE | OP_INSERT | OP_DELETE | OP_CONSUME | OP_PRODUCE => 2,
OP_MUTATE_POINT | OP_TRANSCRIBE => 3,
OP_APPLY_MUTAGEN => 4,
OP_FINALIZE_GENE_HASH => 0,
_ => 0,
}
}
cost.max(1)
}
pub fn execute_program( pub fn execute_program(
state: &mut GeneState, state: &mut GeneState,
program: &[u8], program: &[u8],
+1
View File
@@ -18,3 +18,4 @@ getrandom = { version = "0.2", features = ["js"] }
hex = "0.4" hex = "0.4"
base64 = "0.22" base64 = "0.22"
serde-wasm-bindgen = "0.6" serde-wasm-bindgen = "0.6"
tracing = "0.1"
+37 -23
View File
@@ -1,4 +1,5 @@
use std::cell::RefCell; use std::cell::RefCell;
use std::time::Instant;
use wasm_bindgen::prelude::*; use wasm_bindgen::prelude::*;
thread_local! { thread_local! {
@@ -17,24 +18,27 @@ pub fn init_gene_state(gene_size: u32) -> bool {
} }
#[wasm_bindgen] #[wasm_bindgen]
pub fn preview_gene_commitment(order_b64: &str) -> String { pub fn preview_gene_commitment(order_b64: &str, session_id: &str, mutation_step: u64, rounds: u8) -> String {
let order = match shared::vm_extensions::decode_order_b64(0, order_b64) { let order = match shared::vm_extensions::decode_order_b64(mutation_step, order_b64) {
Ok(order) => order, Ok(order) => order,
Err(_) => return String::new(), Err(_) => return String::new(),
}; };
let start = Instant::now();
let candidate = GENE_STATE.with(|slot| { let candidate = GENE_STATE.with(|slot| {
let state = slot.borrow(); let state = slot.borrow();
let Some(current) = state.as_ref() else { let Some(current) = state.as_ref() else {
return None; return None;
}; };
shared::vm_extensions::apply_program_clone(current, &order.program).ok() shared::vm_extensions::apply_program_clone_with_rounds(current, &order.program, if rounds == 0 { shared::constants::DEFAULT_MUTATION_ROUNDS } else { rounds }).ok()
}); });
let elapsed = start.elapsed();
tracing::debug!(session_id = %session_id, mutation_step = mutation_step, elapsed_ms = elapsed.as_millis(), "wasm mutation preview execution");
let Some(candidate) = candidate else { let Some(candidate) = candidate else {
return String::new(); return String::new();
}; };
let commitment = shared::gene::commitment_hex(&candidate); let commitment = shared::gene::commitment_hex_with_context(&candidate, session_id, mutation_step);
PREVIEW_STATE.with(|slot| *slot.borrow_mut() = Some(candidate)); PREVIEW_STATE.with(|slot| *slot.borrow_mut() = Some(candidate));
commitment commitment
} }
@@ -55,11 +59,11 @@ pub fn discard_gene_preview() {
} }
#[wasm_bindgen] #[wasm_bindgen]
pub fn current_gene_commitment() -> String { pub fn current_gene_commitment(session_id: &str, mutation_step: u64) -> String {
GENE_STATE.with(|slot| { GENE_STATE.with(|slot| {
slot.borrow() slot.borrow()
.as_ref() .as_ref()
.map(shared::gene::commitment_hex) .map(|state| shared::gene::commitment_hex_with_context(state, session_id, mutation_step))
.unwrap_or_default() .unwrap_or_default()
}) })
} }
@@ -77,7 +81,7 @@ mod tests {
#[test] #[test]
fn test_init_gene_state_success() { fn test_init_gene_state_success() {
assert!(init_gene_state(64)); assert!(init_gene_state(64));
let commitment = current_gene_commitment(); let commitment = current_gene_commitment("deadbeef", 1);
assert_eq!(commitment.len(), 64); assert_eq!(commitment.len(), 64);
} }
@@ -90,43 +94,48 @@ mod tests {
fn test_preview_requires_initialized_state() { fn test_preview_requires_initialized_state() {
discard_gene_preview(); discard_gene_preview();
GENE_STATE.with(|slot| *slot.borrow_mut() = None); GENE_STATE.with(|slot| *slot.borrow_mut() = None);
let c = preview_gene_commitment(&order_b64(vec![ let c = preview_gene_commitment(
&order_b64(vec![
shared::vm_extensions::OP_MUTATE_POINT, shared::vm_extensions::OP_MUTATE_POINT,
0, 0,
0, 0,
1, 1,
])); ]),
"deadbeef",
1,
0,
);
assert!(c.is_empty()); assert!(c.is_empty());
} }
#[test] #[test]
fn test_preview_rejects_invalid_order() { fn test_preview_rejects_invalid_order() {
init_gene_state(16); init_gene_state(16);
let c = preview_gene_commitment("***bad-base64***"); let c = preview_gene_commitment("***bad-base64***", "deadbeef", 1, 0);
assert!(c.is_empty()); assert!(c.is_empty());
} }
#[test] #[test]
fn test_commit_applies_preview() { fn test_commit_applies_preview() {
init_gene_state(16); init_gene_state(16);
let before = current_gene_commitment(); let before = current_gene_commitment("deadbeef", 1);
let order = order_b64(vec![shared::vm_extensions::OP_MUTATE_POINT, 0, 0, 1]); let order = order_b64(vec![shared::vm_extensions::OP_MUTATE_POINT, 0, 0, 1]);
let preview = preview_gene_commitment(&order); let preview = preview_gene_commitment(&order, "deadbeef", 1, 0);
assert_ne!(preview, before); assert_ne!(preview, before);
assert!(commit_gene_preview()); assert!(commit_gene_preview());
let after = current_gene_commitment(); let after = current_gene_commitment("deadbeef", 1);
assert_eq!(preview, after); assert_eq!(preview, after);
} }
#[test] #[test]
fn test_discard_preview_keeps_committed_state() { fn test_discard_preview_keeps_committed_state() {
init_gene_state(16); init_gene_state(16);
let before = current_gene_commitment(); let before = current_gene_commitment("deadbeef", 1);
let order = order_b64(vec![shared::vm_extensions::OP_MUTATE_POINT, 0, 0, 0xFF]); let order = order_b64(vec![shared::vm_extensions::OP_MUTATE_POINT, 0, 0, 0xFF]);
let preview = preview_gene_commitment(&order); let preview = preview_gene_commitment(&order, "deadbeef", 1, 0);
assert_ne!(preview, before); assert_ne!(preview, before);
discard_gene_preview(); discard_gene_preview();
let after = current_gene_commitment(); let after = current_gene_commitment("deadbeef", 1);
assert_eq!(before, after); assert_eq!(before, after);
} }
@@ -161,11 +170,11 @@ mod tests {
}; };
let b64 = shared::vm_extensions::encode_order_b64(&order); let b64 = shared::vm_extensions::encode_order_b64(&order);
let preview = preview_gene_commitment(&b64); let preview = preview_gene_commitment(&b64, "deadbeef", 3, 0);
let mut expected = shared::gene::new_state(16).unwrap(); let mut expected = shared::gene::new_state(16).unwrap();
shared::vm_extensions::apply_program(&mut expected, &order.program).unwrap(); shared::vm_extensions::apply_program_with_rounds(&mut expected, &order.program, shared::constants::DEFAULT_MUTATION_ROUNDS).unwrap();
assert_eq!(preview, shared::gene::commitment_hex(&expected)); assert_eq!(preview, shared::gene::commitment_hex_with_context(&expected, "deadbeef", 3));
} }
#[test] #[test]
@@ -178,13 +187,18 @@ mod tests {
let order = shared::vm_extensions::generate_order_with_rng(&mut rng, step + 1, 64); let order = shared::vm_extensions::generate_order_with_rng(&mut rng, step + 1, 64);
let b64 = shared::vm_extensions::encode_order_b64(&order); let b64 = shared::vm_extensions::encode_order_b64(&order);
let preview = preview_gene_commitment(&b64); let preview = preview_gene_commitment(&b64, "deadbeef", step + 1, 0);
shared::vm_extensions::apply_program(&mut expected, &order.program).unwrap(); shared::vm_extensions::apply_program_with_rounds(
let expected_commitment = shared::gene::commitment_hex(&expected); &mut expected,
&order.program,
shared::constants::DEFAULT_MUTATION_ROUNDS,
)
.unwrap();
let expected_commitment = shared::gene::commitment_hex_with_context(&expected, "deadbeef", step + 1);
assert_eq!(preview, expected_commitment); assert_eq!(preview, expected_commitment);
assert!(commit_gene_preview()); assert!(commit_gene_preview());
assert_eq!(current_gene_commitment(), expected_commitment); assert_eq!(current_gene_commitment("deadbeef", step + 1), expected_commitment);
} }
} }
} }