Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3679e6808b | ||
|
|
fc2d693518 | ||
|
|
0ed3cb444d | ||
|
|
2b8afd54e0 | ||
|
|
6067746898 | ||
|
|
3d2a1a0ad7 | ||
|
|
815d29af4c | ||
|
|
b2835f454f | ||
|
|
4a64a57347 | ||
|
|
9d52828bb6 | ||
|
|
19666bd608 | ||
|
|
089a834f96 |
No files matched your search
@@ -5,3 +5,5 @@ dist/
|
||||
*.log
|
||||
.env
|
||||
.idea/
|
||||
|
||||
.antigravitycli/
|
||||
Generated
+12
@@ -269,6 +269,7 @@ dependencies = [
|
||||
"tracing",
|
||||
"tracing-appender",
|
||||
"tracing-subscriber",
|
||||
"valkey",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -285,6 +286,7 @@ dependencies = [
|
||||
"serde-wasm-bindgen",
|
||||
"serde_json",
|
||||
"shared",
|
||||
"tracing",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
@@ -1303,6 +1305,7 @@ dependencies = [
|
||||
"rand 0.8.6",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1749,6 +1752,15 @@ dependencies = [
|
||||
"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]]
|
||||
name = "valuable"
|
||||
version = "0.1.1"
|
||||
|
||||
-21
@@ -1,21 +0,0 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Sunil Purushottam Thakare
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
+260
-140
@@ -1,20 +1,58 @@
|
||||
# ChronoSeal — API Reference
|
||||
# ChronoSeal API Reference
|
||||
|
||||
ChronoSeal exposes a small HTTP API for browser attestation, heartbeat verification, health checks, metrics, and runtime statistics.
|
||||
|
||||
This document describes the wire format and acceptance semantics. The internal state model is covered in [ARCHITECTURE.md](ARCHITECTURE.md).
|
||||
|
||||
## Base URL
|
||||
|
||||
All endpoints are relative to the server root. In development: `http://localhost:3000`.
|
||||
In production: your HTTPS domain via reverse proxy.
|
||||
All paths are relative to the ChronoSeal server root.
|
||||
|
||||
---
|
||||
- Development default: `http://127.0.0.1:3000`
|
||||
- Production: the HTTPS origin or reverse-proxy path used by the protected site
|
||||
|
||||
## Endpoints
|
||||
Production deployments should use HTTPS. The daemon itself can run behind a local reverse proxy.
|
||||
|
||||
### `POST /init`
|
||||
## Content Type
|
||||
|
||||
Initialise a new session. Called once per page load, immediately after the
|
||||
WASM module generates an Ed25519 keypair.
|
||||
JSON endpoints expect:
|
||||
|
||||
#### Request
|
||||
```http
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
Responses are JSON except `/metrics`, which returns Prometheus text format.
|
||||
|
||||
## Endpoint Summary
|
||||
|
||||
| Method | Path | Purpose |
|
||||
|---|---|---|
|
||||
| `POST` | `/init` | Create a browser attestation session |
|
||||
| `POST` | `/hb` | Submit and verify a signed heartbeat |
|
||||
| `GET` | `/health` | Return daemon health |
|
||||
| `GET` | `/stats` | Return storage/session statistics |
|
||||
| `GET` | `/metrics` | Return Prometheus-compatible metrics |
|
||||
| `GET` | `/` | Serve static frontend assets from `frontend_dir` |
|
||||
|
||||
## Data Types
|
||||
|
||||
Common encodings:
|
||||
|
||||
| Value | Encoding |
|
||||
|---|---|
|
||||
| Ed25519 public key | 32 raw bytes encoded as 64 hex characters |
|
||||
| Ed25519 signature | 64 raw bytes encoded as 128 hex characters |
|
||||
| `session_id` | 32 random bytes encoded as 64 hex characters |
|
||||
| `salt` | 16 random bytes encoded as 32 hex characters |
|
||||
| `initial_hash`, `prev_hash`, `gene_commitment` | 32-byte digest encoded as 64 hex characters |
|
||||
| `opcodes_b64`, `mutation_order_b64` | standard base64 |
|
||||
| timestamps | Unix time in milliseconds unless otherwise stated |
|
||||
|
||||
## `POST /init`
|
||||
|
||||
Creates a new attestation session.
|
||||
|
||||
### Request
|
||||
|
||||
```http
|
||||
POST /init
|
||||
@@ -27,19 +65,26 @@ Content-Type: application/json
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `public_key` | `string` | Hex-encoded 32-byte Ed25519 verifying key generated by the WASM module |
|
||||
| Field | Type | Required | Description |
|
||||
|---|---|---:|---|
|
||||
| `public_key` | string | yes | Browser-generated Ed25519 public key as 64 hex characters |
|
||||
|
||||
#### Response `200 OK`
|
||||
The private key is generated and retained by the browser WASM runtime. It is not sent to the server.
|
||||
|
||||
### Successful Response
|
||||
|
||||
```http
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"session_id": "64-char hex string (32 bytes)",
|
||||
"salt": "32-char hex string (16 bytes)",
|
||||
"opcodes_b64": "base64-encoded VM program (8–16 opcodes)",
|
||||
"initial_hash": "64-char hex string (32 bytes Blake3)",
|
||||
"expires_at": 1234567890123,
|
||||
"session_id": "64-char hex string",
|
||||
"salt": "32-char hex string",
|
||||
"opcodes_b64": "base64-encoded VM program",
|
||||
"initial_hash": "64-char hex string",
|
||||
"expires_at": 1234567890123,
|
||||
"heartbeat_min_interval_ms": 12000,
|
||||
"heartbeat_max_interval_ms": 25000,
|
||||
"gene_size": 512,
|
||||
@@ -50,29 +95,28 @@ Content-Type: application/json
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `session_id` | `string` | Opaque session identifier; include in every heartbeat |
|
||||
| `salt` | `string` | Initial salt; used to compute `H(0)` and first `H(1)` |
|
||||
| `opcodes_b64` | `string` | Base64 VM program; execute with `run_program()` on every heartbeat |
|
||||
| `initial_hash` | `string` | `H(0) = Blake3(session_id ║ pub_key ║ salt)`; the first `prev_hash` |
|
||||
| `expires_at` | `number` | Unix timestamp in milliseconds; session expires after 30 minutes of inactivity |
|
||||
| `heartbeat_min_interval_ms` | `number` | Lower bound for randomized heartbeat scheduling |
|
||||
| `heartbeat_max_interval_ms` | `number` | Upper bound for randomized heartbeat scheduling |
|
||||
| `gene_size` | `number` | Initial synthetic gene size used by server and WASM (default 512) |
|
||||
| `mutation_step` | `number` | Server-issued mutation order step expected on next heartbeat |
|
||||
| `mutation_order_b64` | `string` | Base64-encoded mutation opcode program for the current step |
|
||||
| `session_id` | string | Opaque session identifier |
|
||||
| `salt` | string | Current server salt for the first heartbeat hash computation |
|
||||
| `opcodes_b64` | string | Randomized VM program executed by the browser runtime |
|
||||
| `initial_hash` | string | Initial chain head used as `prev_hash` for the first heartbeat |
|
||||
| `expires_at` | number | Session expiration timestamp in milliseconds |
|
||||
| `heartbeat_min_interval_ms` | number | Minimum heartbeat delay recommended by the server |
|
||||
| `heartbeat_max_interval_ms` | number | Maximum heartbeat delay recommended by the server |
|
||||
| `gene_size` | number | Initial synthetic gene buffer size |
|
||||
| `mutation_step` | number | Mutation step expected on the first heartbeat |
|
||||
| `mutation_order_b64` | string | Server-authored mutation order for the first heartbeat |
|
||||
|
||||
#### Error
|
||||
### Error Behavior
|
||||
|
||||
Returns `500 Internal Server Error` only on server-side failures (DB errors,
|
||||
invalid public key length). No meaningful error body is returned.
|
||||
`/init` uses normal route-level error handling for invalid payloads or server failures. Invalid public key length, invalid configured gene size, or storage failure can prevent session creation.
|
||||
|
||||
---
|
||||
Unlike `/hb`, initialization failures are not part of the silent heartbeat rejection model.
|
||||
|
||||
### `POST /hb`
|
||||
## `POST /hb`
|
||||
|
||||
Submit a heartbeat. Called every 12–25 seconds with uniform random jitter.
|
||||
Submits one heartbeat for an existing session.
|
||||
|
||||
#### Request
|
||||
### Request
|
||||
|
||||
```http
|
||||
POST /hb
|
||||
@@ -81,13 +125,12 @@ Content-Type: application/json
|
||||
|
||||
```json
|
||||
{
|
||||
"session_id": "64-char hex",
|
||||
"prev_hash": "64-char hex",
|
||||
"timestamp": 1234567890123,
|
||||
"session_id": "64-char hex",
|
||||
"prev_hash": "64-char hex",
|
||||
"timestamp": 1234567890123,
|
||||
"entropy_data": {
|
||||
"events": [
|
||||
{ "x": 412.0, "y": 308.5, "t": 1234.567 },
|
||||
{ "x": 415.2, "y": 310.1, "t": 1285.123 }
|
||||
{ "x": 412.0, "y": 308.5, "t": 1234.567 }
|
||||
]
|
||||
},
|
||||
"stack_state": {
|
||||
@@ -95,74 +138,96 @@ Content-Type: application/json
|
||||
"ip": 42
|
||||
},
|
||||
"fingerprint": {
|
||||
"aspectRatio": "1.7777777778",
|
||||
"devicePixelRatio": "2",
|
||||
"aspectRatio": "1.7777777778",
|
||||
"devicePixelRatio": "2",
|
||||
"hardwareConcurrency": 8
|
||||
},
|
||||
"mutation_step": 1,
|
||||
"gene_commitment": "64-char hex Blake3 commitment",
|
||||
"signature": "128-char hex Ed25519 signature"
|
||||
"gene_commitment": "64-char hex",
|
||||
"signature": "128-char hex"
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `session_id` | `string` | Session ID from `/init` |
|
||||
| `prev_hash` | `string` | Hash chain head from previous heartbeat (or `initial_hash` for the first) |
|
||||
| `timestamp` | `number` | `Date.now()` in milliseconds; must be within ±30s of server time |
|
||||
| `entropy_data.events` | `array` | Mouse events since previous heartbeat; each has `x`, `y` (px), `t` (performance.now ms) |
|
||||
| `stack_state.stack` | `array` | `u32[]` result of executing the VM program |
|
||||
| `stack_state.ip` | `number` | Instruction pointer after execution |
|
||||
| `fingerprint.aspectRatio` | `string` | `(screen.width / screen.height).toFixed(10)` |
|
||||
| `fingerprint.devicePixelRatio` | `string` | `String(window.devicePixelRatio)` |
|
||||
| `fingerprint.hardwareConcurrency` | `number` | `navigator.hardwareConcurrency \|\| 1` |
|
||||
| `mutation_step` | `number` | Must match server-side pending mutation step |
|
||||
| `gene_commitment` | `string` | Commitment of the locally previewed candidate gene after applying `mutation_order_b64` |
|
||||
| `signature` | `string` | Hex-encoded 64-byte Ed25519 signature over the canonical payload |
|
||||
| Field | Type | Required | Description |
|
||||
|---|---|---:|---|
|
||||
| `session_id` | string | yes | Session ID from `/init` |
|
||||
| `prev_hash` | string | yes | Current browser view of the accepted hash-chain head |
|
||||
| `timestamp` | number | yes | Browser wall-clock timestamp in milliseconds |
|
||||
| `entropy_data.events` | array | yes | Mouse samples since the previous heartbeat |
|
||||
| `entropy_data.events[].x` | number | yes | Mouse x coordinate |
|
||||
| `entropy_data.events[].y` | number | yes | Mouse y coordinate |
|
||||
| `entropy_data.events[].t` | number | yes | Event timestamp in milliseconds relative to the browser sampling window |
|
||||
| `stack_state.stack` | array | yes | VM stack output as unsigned 32-bit values |
|
||||
| `stack_state.ip` | number | yes | VM instruction pointer as an unsigned 16-bit value |
|
||||
| `fingerprint.aspectRatio` | string | yes | Screen aspect ratio; server accepts numeric strings in range `0.5..=3.0` |
|
||||
| `fingerprint.devicePixelRatio` | string | yes | Device pixel ratio; server accepts numeric strings in range `(0, 5]` |
|
||||
| `fingerprint.hardwareConcurrency` | number | yes | Positive hardware concurrency value |
|
||||
| `mutation_step` | number | yes | Mutation step currently expected by the server |
|
||||
| `gene_commitment` | string | yes | Context-bound commitment produced by the WASM mutation preview |
|
||||
| `signature` | string | yes | 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
|
||||
alphabetically. Nested object keys follow their natural serialisation order.
|
||||
The signature covers a canonical JSON object with sorted top-level keys:
|
||||
|
||||
```json
|
||||
{
|
||||
"entropyData": { "events": [{ "t": …, "x": …, "y": … }] },
|
||||
"fingerprint": { "aspectRatio": "…", "devicePixelRatio": "…", "hardwareConcurrency": … },
|
||||
"geneCommitment":"…",
|
||||
"mutationStep": …,
|
||||
"prevHash": "…",
|
||||
"sessionId": "…",
|
||||
"stackState": { "ip": …, "stack": […] },
|
||||
"timestamp": …
|
||||
"entropyData": { "events": [{ "t": 1234.567, "x": 412.0, "y": 308.5 }] },
|
||||
"fingerprint": {
|
||||
"aspectRatio": "1.7777777778",
|
||||
"devicePixelRatio": "2",
|
||||
"hardwareConcurrency": 8
|
||||
},
|
||||
"geneCommitment": "64-char hex",
|
||||
"mutationStep": 1,
|
||||
"prevHash": "64-char hex",
|
||||
"sessionId": "64-char hex",
|
||||
"stackState": { "ip": 42, "stack": [2971406957, 1234567890] },
|
||||
"timestamp": 1234567890123
|
||||
}
|
||||
```
|
||||
|
||||
Note: field names in the signing payload use camelCase (`sessionId`,
|
||||
`prevHash`, `entropyData`, `stackState`, `mutationStep`, `geneCommitment`)
|
||||
while the request body uses snake_case (`session_id`, `prev_hash`,
|
||||
`entropy_data`, `stack_state`, `mutation_step`, `gene_commitment`).
|
||||
Important details:
|
||||
|
||||
#### Response `200 OK` — Accepted
|
||||
- The transport payload uses snake_case for several fields.
|
||||
- The signed payload uses camelCase names.
|
||||
- Top-level keys must be serialized deterministically in lexical order.
|
||||
- The `signature` field is not part of the signed payload.
|
||||
- Nested serialization must match the server's `serde_json` representation.
|
||||
|
||||
The server reconstructs the canonical message from the received request before verifying the Ed25519 signature.
|
||||
|
||||
### Accepted Response
|
||||
|
||||
```http
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"next_salt": "32-char hex string (16 bytes)",
|
||||
"status": "ok",
|
||||
"next_salt": "32-char hex string",
|
||||
"next_mutation_step": 2,
|
||||
"next_mutation_order_b64": "base64-encoded mutation program"
|
||||
}
|
||||
```
|
||||
|
||||
The client must:
|
||||
1. Preview commitment locally from `mutation_order_b64` and send it in the heartbeat.
|
||||
2. Capture `sentSalt = currentSalt` before updating.
|
||||
3. Set `currentSalt = next_salt`.
|
||||
4. Compute `prevHash = compute_next_hash(prevHash, timestamp, entropyJson, stackStateJson, sentSalt)`.
|
||||
5. Commit the previewed gene state.
|
||||
6. Replace pending mutation values with `next_mutation_step` and `next_mutation_order_b64`.
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `status` | string | Always `ok` |
|
||||
| `next_salt` | string | Server salt for the next heartbeat |
|
||||
| `next_mutation_step` | number | Mutation step expected on the next heartbeat |
|
||||
| `next_mutation_order_b64` | string | Server-authored mutation order for the next heartbeat |
|
||||
|
||||
#### Response `200 OK` — Rejected
|
||||
Clients should treat the heartbeat as accepted only when all next-state fields are present.
|
||||
|
||||
### Rejected Response
|
||||
|
||||
```http
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -170,77 +235,132 @@ The client must:
|
||||
}
|
||||
```
|
||||
|
||||
`next_salt`, `next_mutation_step`, and `next_mutation_order_b64` are absent.
|
||||
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.
|
||||
Rejected heartbeats omit:
|
||||
|
||||
The client should log a warning and continue scheduling heartbeats (they will
|
||||
continue to fail until the page is reloaded and a new session is established).
|
||||
- `next_salt`
|
||||
- `next_mutation_step`
|
||||
- `next_mutation_order_b64`
|
||||
|
||||
---
|
||||
This response shape is intentional. The server does not reveal which validation stage failed.
|
||||
|
||||
## Validation Rules (Server-Side)
|
||||
### Heartbeat Validation Order
|
||||
|
||||
Heartbeats are rejected (silently) if any of the following checks fail:
|
||||
The server currently validates heartbeats in this order:
|
||||
|
||||
| Check | Condition for rejection |
|
||||
|---|---|
|
||||
| Rate limit | > 5 requests per 10-second window for this `session_id` |
|
||||
| Session not found | `session_id` not in SQLite |
|
||||
| Session expired | `current_time_ms > expires_at` |
|
||||
| Signature invalid | Ed25519 verification fails against stored public key |
|
||||
| Hash chain broken | `hex(prev_hash) ≠ stored last_hash` |
|
||||
| Mutation step mismatch | `mutation_step ≠ pending_mutation_step` |
|
||||
| Mutation commitment mismatch | `gene_commitment` does not match server-computed candidate commitment |
|
||||
| Timestamp drift | `\|server_now_ms - timestamp\| > 30 000` |
|
||||
| Insufficient mouse events | `events.len() < 3` |
|
||||
| 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` |
|
||||
1. Rate-limit check in the route handler.
|
||||
2. Load session by `session_id`.
|
||||
3. Check session expiration.
|
||||
4. Verify Ed25519 signature.
|
||||
5. Compare `prev_hash` with stored `last_hash`.
|
||||
6. Compare `mutation_step` with stored `pending_mutation_step`.
|
||||
7. Apply stored pending mutation to a cloned gene state.
|
||||
8. Compare expected and submitted `gene_commitment`.
|
||||
9. Enforce timestamp drift.
|
||||
10. Validate mouse entropy.
|
||||
11. Validate fingerprint fields.
|
||||
12. Compute next hash-chain head.
|
||||
13. Generate next mutation order and salt.
|
||||
14. Persist advanced session state.
|
||||
|
||||
---
|
||||
Any failure after route-level JSON decoding returns the silent rejection body.
|
||||
|
||||
## Hash Chain Specification
|
||||
## `GET /health`
|
||||
|
||||
```
|
||||
H(0) = Blake3( session_id_bytes ║ pub_key_bytes ║ salt₀_bytes )
|
||||
Returns a basic health response.
|
||||
|
||||
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) ) )
|
||||
)
|
||||
```http
|
||||
GET /health
|
||||
```
|
||||
|
||||
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).
|
||||
```json
|
||||
{
|
||||
"status": "healthy"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
## `GET /stats`
|
||||
|
||||
## WASM API
|
||||
Returns storage-derived session statistics.
|
||||
|
||||
The WASM module (`chronoseal_wasm`) exports the following functions to JavaScript:
|
||||
```http
|
||||
GET /stats
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"sessions": 1,
|
||||
"expired_sessions": 0,
|
||||
"max_chain_length": 4
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `sessions` | number | Stored session count |
|
||||
| `expired_sessions` | number | Expired sessions not yet purged |
|
||||
| `max_chain_length` | number | Highest stored heartbeat chain length |
|
||||
|
||||
## `GET /metrics`
|
||||
|
||||
Returns Prometheus-compatible text.
|
||||
|
||||
```http
|
||||
GET /metrics
|
||||
```
|
||||
|
||||
```text
|
||||
# HELP chronoseal_sessions Active ChronoSeal sessions
|
||||
# TYPE chronoseal_sessions gauge
|
||||
chronoseal_sessions 1
|
||||
# HELP chronoseal_expired_sessions Expired sessions not yet removed
|
||||
# TYPE chronoseal_expired_sessions gauge
|
||||
chronoseal_expired_sessions 0
|
||||
# HELP chronoseal_max_chain_length Maximum heartbeat chain length
|
||||
# TYPE chronoseal_max_chain_length gauge
|
||||
chronoseal_max_chain_length 4
|
||||
```
|
||||
|
||||
## Client State Rules
|
||||
|
||||
After `/init`, the client stores:
|
||||
|
||||
- `session_id`
|
||||
- `initial_hash` as the first `prev_hash`
|
||||
- current `salt`
|
||||
- VM opcode program
|
||||
- committed gene state
|
||||
- pending mutation step
|
||||
- pending mutation order
|
||||
|
||||
On accepted `/hb`:
|
||||
|
||||
1. Commit the local gene preview.
|
||||
2. Compute the next local hash using the old salt that was active when the heartbeat was sent.
|
||||
3. Replace current salt with `next_salt`.
|
||||
4. Replace pending mutation step and order with server-provided values.
|
||||
|
||||
On rejected `/hb`:
|
||||
|
||||
1. Discard the local gene preview.
|
||||
2. Do not advance hash-chain state.
|
||||
3. Do not advance mutation state.
|
||||
4. Treat the session as suspect or restart attestation.
|
||||
|
||||
## WASM Runtime Exports
|
||||
|
||||
The generated `chronoseal_wasm` package exposes:
|
||||
|
||||
| Function | Signature | Description |
|
||||
|---|---|---|
|
||||
| `generate_keypair()` | `() → string` | Generate Ed25519 keypair; return hex public key. Private key stored in WASM memory. |
|
||||
| `get_public_key()` | `() → string` | Return hex public key, or `""` if not initialised. |
|
||||
| `sign_message(msg)` | `(string) → string` | Sign UTF-8 string; return hex signature, or `""` if not initialised. |
|
||||
| `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. |
|
||||
| `run_program(b64)` | `(string) → JsValue` | Execute base64 VM program; return `{ stack: u32[], ip: number }`. |
|
||||
| `init_gene_state(gene_size)` | `(u32) → bool` | Initialise synthetic gene state in WASM memory. |
|
||||
| `preview_gene_commitment(order_b64)` | `(string) → string` | Apply mutation order on preview state and return commitment hex. |
|
||||
| `commit_gene_preview()` | `() → bool` | Commit previewed mutation state after accepted heartbeat. |
|
||||
| `discard_gene_preview()` | `() → void` | Discard previewed mutation state after rejection/error. |
|
||||
| `current_gene_commitment()` | `() → string` | Return current committed gene commitment hex. |
|
||||
| `generate_keypair()` | `() -> string` | Generate an Ed25519 keypair and return public key hex |
|
||||
| `get_public_key()` | `() -> string` | Return current public key hex, or `""` if no keypair exists |
|
||||
| `sign_message(msg)` | `(string) -> string` | Sign a UTF-8 payload and return hex signature, or `""` on failure |
|
||||
| `compute_next_hash(prev, ts, entropy, stack, salt)` | `(string, u64, string, string, string) -> string` | Compute next Blake3 chain hash |
|
||||
| `run_program(b64)` | `(string) -> JsValue` | Execute a base64 VM program and return stack state |
|
||||
| `init_gene_state(gene_size)` | `(u32) -> bool` | Initialize the browser gene state |
|
||||
| `preview_gene_commitment(order_b64, session_id, mutation_step, rounds)` | `(string, string, u64, u8) -> string` | Preview next mutation commitment |
|
||||
| `commit_gene_preview()` | `() -> bool` | Commit the preview after accepted heartbeat |
|
||||
| `discard_gene_preview()` | `() -> void` | Discard preview after rejection or error |
|
||||
| `current_gene_commitment(session_id, mutation_step)` | `(string, u64) -> string` | Return current committed gene commitment |
|
||||
|
||||
String-returning functions return `""` on error rather than panicking. Callers
|
||||
must check for empty strings and boolean return values before use.
|
||||
String-returning functions use `""` to signal failure. Callers must handle empty strings explicitly.
|
||||
+477
-327
@@ -1,383 +1,533 @@
|
||||
# ChronoSeal — Architecture
|
||||
# ChronoSeal Architecture
|
||||
|
||||
> Note (v0.6.0): Synthetic Gene Mutation flow and mutation handshake updates are documented in [REFRACTORING-v0.6.0.md](REFRACTORING-v0.6.0.md) and [API.md](API.md).
|
||||
ChronoSeal is a Unix-native browser attestation daemon. It validates browser session continuity by combining signed heartbeats, Blake3 hash-chain progression, deterministic VM execution, behavioral sanity checks, and a shared Synthetic Gene Mutation Engine that runs on both the server and the browser WASM runtime.
|
||||
|
||||
## Overview
|
||||
This document describes the system architecture, state model, validation pipeline, trust boundaries, and operational assumptions. The API wire format is documented separately in [API.md](API.md), and deployment guidance is documented in [DEPLOYMENT.md](DEPLOYMENT.md).
|
||||
|
||||
ChronoSeal is a stateless, cryptographic browser attestation framework. Its
|
||||
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.
|
||||
## Architectural Goals
|
||||
|
||||
The design is inspired by the heartbeat model used in embedded IoT firmware:
|
||||
a device that stops sending signed, chained attestations is assumed to be
|
||||
offline or compromised. ChronoSeal applies the same principle to browser
|
||||
sessions.
|
||||
ChronoSeal is designed as infrastructure software rather than a consumer-facing widget. The main goals are:
|
||||
|
||||
---
|
||||
- Keep the server small, inspectable, and operable as a normal Unix daemon.
|
||||
- Use deterministic client/server computation so the server can verify browser-side progression without trusting browser claims blindly.
|
||||
- Make replay, stale state reuse, and incomplete automation expensive.
|
||||
- Preserve privacy by using short-lived session state instead of persistent identity tracking.
|
||||
- Avoid attacker feedback oracles by returning indistinguishable success-shaped responses for rejected heartbeats.
|
||||
- Keep browser integration lightweight: static JavaScript plus a Rust-generated WASM package.
|
||||
|
||||
## Design Principles
|
||||
ChronoSeal does not attempt to prove that a human is present. It attempts to prove that a client is maintaining the expected live browser-side cryptographic and mutation state.
|
||||
|
||||
**Stateless per request.** The server carries no per-request state beyond what
|
||||
is stored in SQLite keyed on `session_id`. Every HTTP request is independently
|
||||
verifiable.
|
||||
## System Context
|
||||
|
||||
**Silent failure.** Validation failures never return an error status or an
|
||||
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
|
||||
WASM module and never serialised, never exposed to the JavaScript environment,
|
||||
and never transmitted. It exists only in WASM linear memory for the lifetime
|
||||
of the page.
|
||||
|
||||
**Layered validation.** A heartbeat must pass five independent checks: session
|
||||
existence, expiry, signature, hash chain, and behavioral signals. Bypassing
|
||||
one layer is not sufficient.
|
||||
|
||||
**Cost asymmetry.** Each heartbeat requires a real browser environment, mouse
|
||||
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.
|
||||
|
||||
### High-Level Design
|
||||
|
||||
- **Core**: Rust + Axum (async web framework)
|
||||
- **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
|
||||
- **Deployment**: Static musl binary, systemd service, optional Docker
|
||||
|
||||
### Key Components
|
||||
|
||||
- `shared/` — Types, constants, crypto primitives used by server and WASM
|
||||
- `server/` — Axum routes, session management, trust engine, rate limiting, cleanup tasks
|
||||
- `wasm/` — Client-side proof generation
|
||||
- `frontend/` — Static assets served by the application
|
||||
|
||||
### Unix-Native Design Decisions
|
||||
|
||||
- Runs as a proper systemd service with strict sandboxing
|
||||
- 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 will be fully runtime (no recompile needed)
|
||||
|
||||
### Design Goal
|
||||
|
||||
ChronoSeal should feel as natural to use as `nginx` or `redis-server` on a Linux system.
|
||||
---
|
||||
|
||||
## Component Map
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Browser │
|
||||
│ │
|
||||
│ ┌─────────────┐ ┌──────────────┐ ┌─────────────┐ │
|
||||
│ │ entropy.js │ │ heartbeat.js │ │ transport.js│ │
|
||||
│ │ │ │ │ │ │ │
|
||||
│ │ mousemove │──►│ orchestrates │──►│ fetch POST │ │
|
||||
│ │ event ring │ │ init + HB │ │ /init /hb │ │
|
||||
│ └─────────────┘ └──────┬───────┘ └─────────────┘ │
|
||||
│ │ │
|
||||
│ ┌──────▼───────────────────────┐ │
|
||||
│ │ WASM Module (antibot_wasm) │ │
|
||||
│ │ │ │
|
||||
│ │ crypto.rs vm.rs │ │
|
||||
│ │ ├ generate_keypair() │ │
|
||||
│ │ ├ sign_message() │ │
|
||||
│ │ ├ compute_next_hash() │ │
|
||||
│ │ └ run_program() │ │
|
||||
│ └──────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
│ HTTPS
|
||||
┌─────────────────────────▼───────────────────────────────┐
|
||||
│ Server (Axum) │
|
||||
│ │
|
||||
│ routes/init.rs routes/heartbeat.rs │
|
||||
│ │ │ │
|
||||
│ └──────────┬───────────────┘ │
|
||||
│ ▼ │
|
||||
│ session.rs │
|
||||
│ ├ create_session() │
|
||||
│ └ verify_heartbeat() │
|
||||
│ │ │
|
||||
│ ┌──────────┼──────────────┐ │
|
||||
│ ▼ ▼ ▼ │
|
||||
│ crypto.rs trust.rs fingerprint.rs │
|
||||
│ (sig verify) (mouse (aspect ratio, │
|
||||
│ speed) DPR, HW conc.) │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ shared::hashing (Blake3 hash chain) │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ storage.rs (in-memory SQLite) │
|
||||
│ │
|
||||
│ ratelimit.rs cleanup.rs vm.rs middleware.rs │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```text
|
||||
Protected browser origin
|
||||
|
|
||||
| static files and API calls
|
||||
v
|
||||
+------------------------------+
|
||||
| Browser |
|
||||
| - frontend JavaScript |
|
||||
| - chronoseal_wasm runtime |
|
||||
| - Ed25519 session key |
|
||||
| - VM and gene state |
|
||||
+---------------+--------------+
|
||||
|
|
||||
| POST /init
|
||||
| POST /hb
|
||||
v
|
||||
+------------------------------+
|
||||
| ChronoSeal daemon |
|
||||
| - Axum HTTP routes |
|
||||
| - session verifier |
|
||||
| - storage abstraction |
|
||||
| - metrics and health |
|
||||
+---------------+--------------+
|
||||
|
|
||||
| SessionRecord
|
||||
v
|
||||
+------------------------------+
|
||||
| Storage backend |
|
||||
| - sqlite-in-memory |
|
||||
| - sqlite-in-disk |
|
||||
| - valkey |
|
||||
+------------------------------+
|
||||
```
|
||||
|
||||
---
|
||||
ChronoSeal can serve the frontend files itself or sit behind a reverse proxy. TLS termination should happen before traffic reaches the daemon in production.
|
||||
|
||||
## Session Lifecycle
|
||||
## Workspace Components
|
||||
|
||||
### 1. Initialisation — `POST /init`
|
||||
The repository is a Rust workspace with three runtime crates and one static frontend directory.
|
||||
|
||||
```
|
||||
Client Server
|
||||
│ │
|
||||
│ generate Ed25519 keypair (in WASM) │
|
||||
│ pub_key = verifying_key.to_bytes() │
|
||||
│ │
|
||||
├─── { public_key: hex(pub_key) } ──────►│
|
||||
│ │ session_id = rand::random::<[u8;32]>()
|
||||
│ │ salt₀ = rand::random::<[u8;16]>()
|
||||
│ │ H(0) = Blake3(session_id║pub_key║salt₀)
|
||||
│ │ opcodes = generate_random_program(8..=16)
|
||||
│ │ INSERT INTO sessions …
|
||||
│ │
|
||||
│◄── { session_id, salt, opcodes_b64, │
|
||||
│ initial_hash, expires_at } ───────┤
|
||||
│ │
|
||||
│ prevHash = initial_hash │
|
||||
│ currentSalt = salt │
|
||||
│ opcodesB64 = opcodes_b64 │
|
||||
### `shared/`
|
||||
|
||||
`shared/` contains protocol and deterministic runtime code used by both the server and WASM crates.
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- wire protocol structs for `/init` and `/hb`
|
||||
- Blake3 hash-chain helpers
|
||||
- synthetic gene state representation
|
||||
- environment encoding and validation
|
||||
- mutation program generation, encoding, decoding, and execution
|
||||
- deterministic VM extension opcode semantics
|
||||
|
||||
Important files:
|
||||
|
||||
| File | Responsibility |
|
||||
|---|---|
|
||||
| `protocol.rs` | `InitRequest`, `InitResponse`, `HeartbeatRequest`, `HeartbeatResponse`, and supporting payload types |
|
||||
| `hashing.rs` | initial and next hash-chain computation |
|
||||
| `gene.rs` | gene state, environment records, validation, and context-bound commitment |
|
||||
| `vm_extensions.rs` | mutation order generation, opcode interpreter, execution tracing, and tests |
|
||||
| `constants.rs` | protocol and execution bounds |
|
||||
|
||||
`shared/` is the determinism boundary. Any logic that must agree between server and browser belongs here rather than in server-only or frontend-only code.
|
||||
|
||||
### `server/`
|
||||
|
||||
`server/` builds the `chronoseal` binary. It owns daemon lifecycle, HTTP routing, session verification, storage, metrics, configuration, and CLI behavior.
|
||||
|
||||
Important files:
|
||||
|
||||
| File | Responsibility |
|
||||
|---|---|
|
||||
| `main.rs` | CLI command dispatch |
|
||||
| `cli.rs` | command, flag, and environment variable definitions |
|
||||
| `config.rs` | defaults, TOML loading, environment overrides, validation |
|
||||
| `runtime.rs` | daemon startup, Axum router, health, metrics, stats, graceful shutdown |
|
||||
| `routes/init.rs` | `POST /init` handler |
|
||||
| `routes/heartbeat.rs` | `POST /hb` handler and silent rejection response shape |
|
||||
| `session.rs` | session creation, heartbeat verification, state advancement |
|
||||
| `crypto.rs` | canonical signing payload and Ed25519 signature verification |
|
||||
| `storage.rs` | `DbPool`, SQLite, Valkey compatibility, session persistence, stats |
|
||||
| `trust.rs` | mouse entropy validation |
|
||||
| `fingerprint.rs` | browser signal validation |
|
||||
| `ratelimit.rs` | per-session rate limiting |
|
||||
| `cleanup.rs` | expired session removal |
|
||||
|
||||
The server treats the browser as untrusted. Browser-supplied values are accepted only after signature, continuity, timing, behavioral, and mutation checks pass.
|
||||
|
||||
### `wasm/`
|
||||
|
||||
`wasm/` compiles to the browser runtime package with `wasm-pack --target web`.
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- generate and hold the browser-local Ed25519 keypair
|
||||
- sign canonical heartbeat payloads
|
||||
- compute hash-chain values used by the browser integration
|
||||
- execute randomized VM programs
|
||||
- maintain committed and preview synthetic gene state
|
||||
- preview mutation commitments before a heartbeat is submitted
|
||||
- commit or discard preview state after server response
|
||||
|
||||
Important files:
|
||||
|
||||
| File | Responsibility |
|
||||
|---|---|
|
||||
| `crypto.rs` | key generation, public key export, message signing |
|
||||
| `vm.rs` | base VM program execution |
|
||||
| `vm_extensions.rs` | gene initialization, mutation preview, commit, discard, current commitment |
|
||||
|
||||
The WASM runtime is not a trusted execution environment. It is useful because it forces a browser client to implement the same state transitions as the server and makes simple HTTP automation insufficient.
|
||||
|
||||
### `frontend/`
|
||||
|
||||
`frontend/` contains static JavaScript and browser assets. It loads `frontend/pkg/chronoseal_wasm.js`, calls `/init`, periodically sends `/hb`, and coordinates browser-side state transitions.
|
||||
|
||||
The frontend is intentionally thin. Durable protocol rules live in Rust, not in handwritten JavaScript.
|
||||
|
||||
## Runtime Topology
|
||||
|
||||
The daemon builds a single Axum application with:
|
||||
|
||||
| Route | Method | Purpose |
|
||||
|---|---|---|
|
||||
| `/init` | `POST` | create a new attestation session |
|
||||
| `/hb` | `POST` | verify and advance a heartbeat |
|
||||
| `/health` | `GET` | health probe |
|
||||
| `/metrics` | `GET` | Prometheus-compatible metrics |
|
||||
| `/stats` | `GET` | storage/session statistics |
|
||||
| `/` | `GET` | static frontend assets from `frontend_dir` |
|
||||
|
||||
Shared runtime state is held in `AppState`:
|
||||
|
||||
- `db_pool`: storage backend handle
|
||||
- `rate_limiter`: process-local rate limiter
|
||||
- `config`: runtime configuration snapshot behind an `RwLock`
|
||||
|
||||
Configuration is resolved in this order:
|
||||
|
||||
1. CLI flags
|
||||
2. `CHRONOSEAL_*` environment variables
|
||||
3. TOML configuration file
|
||||
4. built-in defaults
|
||||
|
||||
## Session State Model
|
||||
|
||||
The server persists one `SessionRecord` per active session.
|
||||
|
||||
| Field | Meaning |
|
||||
|---|---|
|
||||
| `session_id` | random 32-byte session identifier encoded as hex |
|
||||
| `public_key` | browser-generated Ed25519 verifying key |
|
||||
| `salt` | current server salt for hash-chain progression |
|
||||
| `last_hash` | current accepted hash-chain head |
|
||||
| `chain_length` | number of accepted chain states including initialization |
|
||||
| `created_at` | creation timestamp in milliseconds |
|
||||
| `last_seen` | timestamp of last accepted heartbeat |
|
||||
| `expires_at` | session expiration timestamp in milliseconds |
|
||||
| `gene` | committed synthetic gene byte buffer |
|
||||
| `environment` | encoded environment records |
|
||||
| `pending_mutation` | server-issued mutation program for the next heartbeat |
|
||||
| `pending_mutation_step` | mutation step expected on the next heartbeat |
|
||||
|
||||
The committed server state advances only after a heartbeat passes all validation checks. Failed heartbeats do not update `last_hash`, `salt`, `gene`, `environment`, `pending_mutation`, or `pending_mutation_step`.
|
||||
|
||||
## Initialization Flow
|
||||
|
||||
```text
|
||||
Browser/WASM Server
|
||||
------------ ------
|
||||
generate_keypair()
|
||||
public key
|
||||
|
|
||||
| POST /init { public_key }
|
||||
v
|
||||
validate public key length
|
||||
create GeneState
|
||||
generate session_id
|
||||
generate salt
|
||||
compute initial_hash
|
||||
generate VM opcodes
|
||||
generate mutation step 1
|
||||
persist SessionRecord
|
||||
^
|
||||
| InitResponse
|
||||
|
|
||||
store session_id, salt,
|
||||
initial_hash, opcodes,
|
||||
gene_size, mutation order
|
||||
```
|
||||
|
||||
### 2. Heartbeat — `POST /hb`
|
||||
Initialization creates the first server-side commitment state but does not prove liveness. Liveness begins with accepted heartbeats.
|
||||
|
||||
Fired every 12–25 seconds with uniform random jitter.
|
||||
The initial response contains:
|
||||
|
||||
```
|
||||
Client Server
|
||||
│ │
|
||||
│ stackState = run_program(opcodesB64) │
|
||||
│ events = collectEntropy(lastTime) │
|
||||
│ ts = Date.now() │
|
||||
│ │
|
||||
│ signable = { │
|
||||
│ entropyData, fingerprint, │ ← keys sorted alphabetically
|
||||
│ prevHash, sessionId, │
|
||||
│ stackState, timestamp │
|
||||
│ } │
|
||||
│ sig = sign_message( │
|
||||
│ JSON.stringify(signable, keys.sort))│
|
||||
│ │
|
||||
├─── { session_id, prev_hash, timestamp, │
|
||||
│ entropy_data, stack_state, │
|
||||
│ fingerprint, signature } ────────►│
|
||||
│ │ 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. Compute H(n), rotate salt
|
||||
│ │ 9. UPDATE sessions …
|
||||
│ │
|
||||
│◄── { status: "ok", next_salt } ────────┤
|
||||
│ │
|
||||
│ sentSalt = currentSalt ◄── captured BEFORE rotation
|
||||
│ currentSalt = next_salt │
|
||||
│ prevHash = compute_next_hash( │
|
||||
│ prevHash, ts, entropy, │
|
||||
│ stackState, sentSalt) │
|
||||
- `session_id`
|
||||
- `salt`
|
||||
- `opcodes_b64`
|
||||
- `initial_hash`
|
||||
- `expires_at`
|
||||
- heartbeat interval bounds
|
||||
- `gene_size`
|
||||
- `mutation_step`
|
||||
- `mutation_order_b64`
|
||||
|
||||
## Heartbeat Flow
|
||||
|
||||
```text
|
||||
Browser/WASM Server
|
||||
------------ ------
|
||||
execute VM program
|
||||
collect entropy and fingerprint data
|
||||
preview pending gene mutation
|
||||
build canonical signing payload
|
||||
sign with Ed25519 private key
|
||||
|
|
||||
| POST /hb HeartbeatRequest
|
||||
v
|
||||
load session
|
||||
check expiration
|
||||
verify signature
|
||||
check hash continuity
|
||||
check mutation step
|
||||
apply pending mutation
|
||||
compare gene commitment
|
||||
check timestamp drift
|
||||
validate mouse entropy
|
||||
validate fingerprint
|
||||
compute next hash
|
||||
generate next mutation
|
||||
generate next salt
|
||||
persist advanced state
|
||||
^
|
||||
| accepted: status + next salt + next mutation
|
||||
| rejected: { "status": "ok" }
|
||||
|
|
||||
commit preview on accepted response
|
||||
discard or stop on rejected response
|
||||
```
|
||||
|
||||
### 3. Failure Path
|
||||
Accepted heartbeats return `next_salt`, `next_mutation_step`, and `next_mutation_order_b64`.
|
||||
|
||||
On any validation failure the server returns `{"status":"ok"}` with no
|
||||
`next_salt`. The client logs a warning and continues scheduling heartbeats.
|
||||
The chain is broken — subsequent heartbeats will also fail silently.
|
||||
No error is surfaced to the page or its visitors.
|
||||
|
||||
---
|
||||
|
||||
## Cryptographic Protocol
|
||||
|
||||
### Key Generation
|
||||
|
||||
```
|
||||
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
|
||||
|
||||
```
|
||||
H(0) = Blake3( session_id ║ pub_key ║ salt₀ )
|
||||
|
||||
H(n) = Blake3(
|
||||
saltₙ₋₁ ← server-side only, rotated each heartbeat
|
||||
║ H(n-1) ← must match stored last_hash
|
||||
║ timestamp_u64_le
|
||||
║ 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.
|
||||
|
||||
### Canonical Signing Payload
|
||||
|
||||
The signed message is a JSON object with top-level keys sorted alphabetically,
|
||||
serialised with no extra whitespace:
|
||||
Rejected heartbeats return only:
|
||||
|
||||
```json
|
||||
{
|
||||
"entropyData": { "events": [{"t":…,"x":…,"y":…}] },
|
||||
"fingerprint": { "aspectRatio":"…","devicePixelRatio":"…","hardwareConcurrency":… },
|
||||
"prevHash": "hex…",
|
||||
"sessionId": "hex…",
|
||||
"stackState": { "ip":…,"stack":[…] },
|
||||
"timestamp": 1234567890123
|
||||
"status": "ok"
|
||||
}
|
||||
```
|
||||
|
||||
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.
|
||||
This silent rejection behavior is part of the security model. It prevents the API from acting as an oracle for signature, timing, mutation, or behavior failures.
|
||||
|
||||
### Hashing Algorithm
|
||||
## Verification Pipeline
|
||||
|
||||
Blake3 is used throughout: hash chain links, entropy data digest, stack state
|
||||
digest, and the VM HASH opcode. Blake3 is chosen for speed in WASM,
|
||||
resistance to length-extension attacks, and a clean Rust API.
|
||||
Heartbeat verification occurs in `server/src/session.rs`.
|
||||
|
||||
---
|
||||
The current validation order is:
|
||||
|
||||
## Stack Machine
|
||||
1. Load the session by `session_id`.
|
||||
2. Reject if the session is missing.
|
||||
3. Reject if `now > expires_at`.
|
||||
4. Verify the Ed25519 signature over the canonical payload.
|
||||
5. Decode and compare `prev_hash` with the stored `last_hash`.
|
||||
6. Compare request `mutation_step` with stored `pending_mutation_step`.
|
||||
7. Decode the stored gene environment.
|
||||
8. Apply the stored `pending_mutation` to a cloned server gene state.
|
||||
9. Compute the expected `gene_commitment` with session and step context.
|
||||
10. Compare the request `gene_commitment` with the expected commitment.
|
||||
11. Enforce timestamp drift bounds.
|
||||
12. Validate mouse entropy.
|
||||
13. Validate browser fingerprint fields.
|
||||
14. Compute the next hash-chain value.
|
||||
15. Generate the next mutation order.
|
||||
16. Generate the next salt.
|
||||
17. Persist the advanced session state.
|
||||
|
||||
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.
|
||||
The verifier performs state mutation only after validation succeeds. This preserves replay resistance and avoids desynchronizing the server after invalid requests.
|
||||
|
||||
### Instruction Set
|
||||
## Canonical Signing Boundary
|
||||
|
||||
| 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 heartbeat signature covers a canonical JSON payload built from:
|
||||
|
||||
The generator ensures ≥ 2 items on the stack before any binary opcode.
|
||||
NOT (0x08) does not change depth. HASH resets depth to 1.
|
||||
- `entropyData`
|
||||
- `fingerprint`
|
||||
- `geneCommitment`
|
||||
- `mutationStep`
|
||||
- `prevHash`
|
||||
- `sessionId`
|
||||
- `stackState`
|
||||
- `timestamp`
|
||||
|
||||
---
|
||||
The server constructs this payload using a `BTreeMap`, which orders top-level keys deterministically before serializing. The transport request uses snake_case field names, while the signed payload uses camelCase names that match the browser-side canonical message.
|
||||
|
||||
## Behavioral Validation
|
||||
The signature does not cover the `signature` field itself.
|
||||
|
||||
### Mouse Entropy
|
||||
## Hash-Chain Boundary
|
||||
|
||||
Every heartbeat includes the mouse events collected since the previous
|
||||
heartbeat. Server checks:
|
||||
Each accepted heartbeat advances a Blake3 hash chain.
|
||||
|
||||
| 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) |
|
||||
Inputs include:
|
||||
|
||||
### Browser Fingerprint
|
||||
- previous hash-chain head
|
||||
- heartbeat timestamp
|
||||
- entropy data
|
||||
- VM stack state
|
||||
- current server salt
|
||||
|
||||
| Signal | Valid range |
|
||||
|---|---|
|
||||
| `aspectRatio` (width / height) | 0.5 – 3.0 |
|
||||
| `devicePixelRatio` | 0 < dpr ≤ 5.0 |
|
||||
| `hardwareConcurrency` | ≥ 1 |
|
||||
The server stores only the current accepted head as `last_hash`. A replayed heartbeat with an old `prev_hash` fails because the stored `last_hash` has already advanced.
|
||||
|
||||
---
|
||||
The salt rotates after every accepted heartbeat. The next salt is returned only on acceptance, so rejected clients do not receive the material needed for the next valid chain step.
|
||||
|
||||
## Rate Limiting
|
||||
## Synthetic Gene Mutation Engine
|
||||
|
||||
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.
|
||||
The Synthetic Gene Mutation Engine provides an additional deterministic continuity check.
|
||||
|
||||
---
|
||||
Core concepts:
|
||||
|
||||
## SQLite Schema
|
||||
- `GeneState`: committed gene byte buffer plus environment records.
|
||||
- `MutationOrder`: mutation step plus encoded mutation program.
|
||||
- `pending_mutation`: the server-authored program expected on the next heartbeat.
|
||||
- `gene_commitment`: context-bound commitment over the candidate gene state, `session_id`, and `mutation_step`.
|
||||
|
||||
```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
|
||||
);
|
||||
The server and WASM runtime both execute the same mutation semantics from `shared/vm_extensions.rs`.
|
||||
|
||||
Mutation lifecycle:
|
||||
|
||||
1. Server stores a pending mutation program and step.
|
||||
2. Browser previews that mutation against its committed gene state.
|
||||
3. Browser sends the resulting `gene_commitment`.
|
||||
4. Server applies the same mutation to a clone of its committed gene state.
|
||||
5. Server compares the expected commitment with the browser commitment.
|
||||
6. On success, server commits the candidate state and issues the next mutation.
|
||||
7. Browser commits its preview only after receiving an accepted response.
|
||||
|
||||
This design prevents a client from advancing mutation state independently of the server. The mutation order is server-authored, step-bound, and accepted only once.
|
||||
|
||||
## Behavioral Trust Checks
|
||||
|
||||
ChronoSeal includes lightweight behavioral checks. These checks are not a complete human verification system; they are an automation cost signal.
|
||||
|
||||
Current checks include:
|
||||
|
||||
- minimum mouse activity, when enabled
|
||||
- minimum total mouse movement distance
|
||||
- maximum average mouse speed
|
||||
- minimum pause count
|
||||
- timestamp drift bound
|
||||
- basic fingerprint field validation
|
||||
|
||||
The checks are intentionally bounded and configurable. They should be treated as one layer in the attestation pipeline, not as the primary security primitive.
|
||||
|
||||
## Storage Architecture
|
||||
|
||||
Storage is abstracted by `DbPool`.
|
||||
|
||||
| Backend | `db_type` | Characteristics |
|
||||
|---|---|---|
|
||||
| SQLite memory | `sqlite-in-memory` | default, process-local, ephemeral |
|
||||
| SQLite disk | `sqlite-in-disk` | persisted SQLite file at `db_path` |
|
||||
| Valkey | `valkey` | Valkey-compatible session store |
|
||||
|
||||
The storage layer must support:
|
||||
|
||||
- insert session
|
||||
- load session
|
||||
- update session
|
||||
- delete expired sessions
|
||||
- report statistics
|
||||
|
||||
`valkey` mode reads `CHRONOSEAL_VALKEY_ADDR`, defaulting to `127.0.0.1:6666`. If connection setup fails, the current implementation logs a warning and falls back to in-memory SQLite.
|
||||
|
||||
## Metrics and Observability
|
||||
|
||||
ChronoSeal exposes two operational surfaces:
|
||||
|
||||
- CLI commands: `status`, `health`, `metrics`, `stats`, `config check`
|
||||
- HTTP endpoints: `/health`, `/metrics`, `/stats`
|
||||
|
||||
The metrics endpoint reports storage-derived counters including:
|
||||
|
||||
- active sessions
|
||||
- expired sessions
|
||||
- maximum observed chain length
|
||||
|
||||
The daemon uses structured tracing and can log to journald through normal systemd operation. Operators should avoid debug logging in production because internal identifiers may appear in logs.
|
||||
|
||||
## Trust Boundaries
|
||||
|
||||
### Browser Boundary
|
||||
|
||||
The browser is untrusted. It may lie about entropy, fingerprint values, VM output, mutation commitment, timing, and session identifiers.
|
||||
|
||||
Mitigation:
|
||||
|
||||
- signature verification binds payloads to the browser session key
|
||||
- hash-chain checks reject stale state
|
||||
- mutation commitment checks reject incorrect gene progression
|
||||
- timing and behavioral checks reject implausible requests
|
||||
|
||||
### WASM Boundary
|
||||
|
||||
WASM code runs in the browser and is therefore not trusted as secure enclave code.
|
||||
|
||||
Mitigation:
|
||||
|
||||
- the server independently recomputes critical deterministic state
|
||||
- private key custody raises automation cost but is not treated as hardware-backed secrecy
|
||||
- failures do not reveal detailed reasons to callers
|
||||
|
||||
### Storage Boundary
|
||||
|
||||
Storage is trusted for session continuity. If storage is lost, sessions cannot continue. If storage is tampered with, attestation integrity can be affected.
|
||||
|
||||
Mitigation:
|
||||
|
||||
- use proper filesystem permissions for SQLite disk mode
|
||||
- deploy Valkey on a trusted network or protected socket
|
||||
- keep ChronoSeal behind normal host and service hardening
|
||||
|
||||
### Network Boundary
|
||||
|
||||
ChronoSeal expects production traffic to be protected by TLS. Plaintext deployment weakens confidentiality and makes traffic analysis easier.
|
||||
|
||||
Mitigation:
|
||||
|
||||
- terminate TLS at a reverse proxy or load balancer
|
||||
- keep `/init` and `/hb` same-origin with protected content when possible
|
||||
- avoid exposing internal metrics broadly
|
||||
|
||||
## Failure Semantics
|
||||
|
||||
ChronoSeal intentionally separates transport success from attestation success.
|
||||
|
||||
| Failure class | HTTP behavior | State mutation |
|
||||
|---|---|---|
|
||||
| malformed route-level request | normal HTTP error handling | no session advancement |
|
||||
| invalid heartbeat semantics | `200 OK` with `{"status":"ok"}` | no session advancement |
|
||||
| rejected heartbeat | `200 OK` with `{"status":"ok"}` | no session advancement |
|
||||
| accepted heartbeat | `200 OK` with next-state fields | session state advances from the verifier's perspective |
|
||||
|
||||
This ambiguity reduces attacker feedback. Application integrations must check for the presence of `next_salt`, `next_mutation_step`, and `next_mutation_order_b64` rather than treating any `status: ok` as an accepted heartbeat.
|
||||
|
||||
## Invariants
|
||||
|
||||
The architecture relies on these invariants:
|
||||
|
||||
- A session has exactly one expected `pending_mutation_step` at a time.
|
||||
- A pending mutation is consumed only by an accepted heartbeat.
|
||||
- `last_hash` changes only after a heartbeat passes verification.
|
||||
- `salt` changes only after a heartbeat passes verification.
|
||||
- `gene` and `environment` change only after mutation commitment validation succeeds.
|
||||
- The next mutation order is generated only from an accepted candidate state.
|
||||
- Rejected heartbeats do not reveal the failed validation stage.
|
||||
- Browser-side preview state is committed only after an accepted heartbeat response.
|
||||
|
||||
Breaking these invariants can introduce replay acceptance, client/server desynchronization, or oracle behavior.
|
||||
|
||||
## Concurrency Notes
|
||||
|
||||
ChronoSeal currently verifies a heartbeat by loading a session, computing candidate state, and writing the updated record back to storage. The intended operational model is one live heartbeat stream per browser session.
|
||||
|
||||
Concurrent heartbeats for the same `session_id` should naturally collapse to at most one accepted progression because both requests present the same `prev_hash` and `mutation_step`; after the first accepted update, the second request becomes stale. Storage backends must preserve update visibility strongly enough for this assumption to hold.
|
||||
|
||||
## Deployment Shape
|
||||
|
||||
Typical production topology:
|
||||
|
||||
```text
|
||||
Internet
|
||||
|
|
||||
v
|
||||
TLS reverse proxy
|
||||
|
|
||||
v
|
||||
chronoseal daemon on 127.0.0.1:3000
|
||||
|
|
||||
v
|
||||
SQLite disk or Valkey storage
|
||||
```
|
||||
|
||||
In-memory SQLite — all sessions lost on server restart by design.
|
||||
Clients re-initialise transparently on the next page load.
|
||||
Recommended deployment properties:
|
||||
|
||||
---
|
||||
- run under systemd with a dedicated service user
|
||||
- bind to localhost behind a reverse proxy unless direct exposure is required
|
||||
- serve over HTTPS
|
||||
- keep debug logs disabled
|
||||
- monitor `/health`, `/metrics`, and `/stats`
|
||||
- use `sqlite-in-memory` for ephemeral local sessions
|
||||
- use `sqlite-in-disk` or `valkey` when sessions must survive process restarts
|
||||
|
||||
## Threat Model
|
||||
## Limitations
|
||||
|
||||
### In Scope
|
||||
ChronoSeal is not:
|
||||
|
||||
| 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 |
|
||||
| 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 |
|
||||
- a user authentication system
|
||||
- a CAPTCHA
|
||||
- a fraud scoring engine
|
||||
- a hardware attestation system
|
||||
- a persistent identity framework
|
||||
- a complete defense against fully resourced browser farms
|
||||
|
||||
### Out of Scope
|
||||
It is a protocol layer that makes browser automation and replay more expensive by requiring correct, continuous, stateful execution.
|
||||
|
||||
| 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 |
|
||||
## Related Documents
|
||||
|
||||
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 |
|
||||
| `server/src/routes/init.rs` | `POST /init` handler |
|
||||
| `server/src/routes/heartbeat.rs` | `POST /hb` handler |
|
||||
| `server/src/session.rs` | `create_session`, `verify_heartbeat` |
|
||||
| `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, `current_time_ms` |
|
||||
| `wasm/src/crypto.rs` | `generate_keypair`, `sign_message`, `compute_next_hash` |
|
||||
| `wasm/src/vm.rs` | `run_program` — stack machine executor |
|
||||
| `frontend/heartbeat.js` | Session init, heartbeat loop, chain advancement |
|
||||
| `frontend/entropy.js` | Mouse event ring buffer, `collectEntropy` |
|
||||
| `frontend/transport.js` | `sendRequest` fetch wrapper |
|
||||
- [API Reference](API.md)
|
||||
- [Deployment Guide](DEPLOYMENT.md)
|
||||
- [Threat Model](THREAT_MODEL.md)
|
||||
- [WASM Build Guide](WASM_BUILD.md)
|
||||
- [Design Philosophy](DESIGN-PHILOSOPHY.md)
|
||||
- [Privacy Policy](PRIVACY%20POLICY.md)
|
||||
@@ -0,0 +1,120 @@
|
||||
# ChronoSeal vs Popular Anti-Bot Systems (2026)
|
||||
|
||||
ChronoSeal is a **self-hosted, cryptographic attestation daemon**. This document compares it honestly with leading commercial solutions.
|
||||
|
||||
## Quick Comparison
|
||||
|
||||
| Solution | Type | Core Method | Privacy | Self-Hosted | Crypto Strength | Behavioral Analysis | Cost | Best For |
|
||||
|----------------------------|-------------------|--------------------------------------|---------|-------------|-----------------|---------------------|---------------|------------------------------|
|
||||
| **ChronoSeal** | Self-hosted Daemon| Ed25519 + Blake3 + **Gene Mutation** | Excellent | Yes | Very High | Light + Tunable | Free | Privacy + Control |
|
||||
| Cloudflare Bot Management | Cloud Edge | JS Challenges + ML Fingerprinting | Medium | No | Medium | Strong | Freemium | Easy mass protection |
|
||||
| Akamai Bot Manager | Enterprise Edge | Behavioral + Device Fingerprinting | Low | Hybrid | Medium | Very Strong | Very High | Large enterprises |
|
||||
| HUMAN (PerimeterX) | Cloud SaaS | Behavioral Biometrics + ML | Low | No | Medium | Very Strong | Enterprise | Sophisticated bot defense |
|
||||
| DataDome | Cloud SaaS | Real-time ML + Behavioral | Medium | No | Medium | Strong | Enterprise | E-commerce scraping |
|
||||
| reCAPTCHA v3 | Google Service | Risk scoring + invisible challenges | Poor | No | Low | Medium | Free → Paid | Simple bot filtering |
|
||||
| Kasada | Cloud SaaS | Proof-of-Work + Behavioral | Medium | No | High | Strong | Enterprise | Advanced automation |
|
||||
|
||||
## Detailed Analysis
|
||||
|
||||
### 1. ChronoSeal (v0.6.0)
|
||||
|
||||
**Strengths:**
|
||||
- Strongest **cryptographic foundation** (Ed25519 signatures + Blake3 hash chain + Synthetic Gene Mutation Engine)
|
||||
- Fully **deterministic** server ↔ WASM parity
|
||||
- Completely **invisible** to users with silent rejection
|
||||
- Excellent **privacy** — no third-party tracking or fingerprint databases
|
||||
- Highly **tunable** mutation strength (`gene_size` + `mutation_rounds`)
|
||||
- Full control and auditability
|
||||
|
||||
**Weaknesses:**
|
||||
- Requires self-hosting and maintenance
|
||||
- No global threat intelligence network like Cloudflare
|
||||
|
||||
---
|
||||
|
||||
### 2. Cloudflare Bot Management
|
||||
|
||||
**Strengths:**
|
||||
- Extremely easy to deploy
|
||||
- Excellent scale and global threat intelligence
|
||||
- Good detection rates
|
||||
|
||||
**Weaknesses vs ChronoSeal:**
|
||||
- Relies heavily on fingerprinting and JS challenges
|
||||
- Sends data to Cloudflare (privacy impact)
|
||||
- Less transparent and auditable
|
||||
- Vendor lock-in
|
||||
|
||||
**Winner:** ChronoSeal for privacy-conscious teams
|
||||
|
||||
---
|
||||
|
||||
### 3. Enterprise Solutions (Akamai, HUMAN, DataDome, Kasada)
|
||||
|
||||
**Strengths:**
|
||||
- Sophisticated ML + behavioral analysis
|
||||
- Large threat intelligence databases
|
||||
- Professional support
|
||||
|
||||
**Weaknesses vs ChronoSeal:**
|
||||
- Extremely expensive
|
||||
- Black-box systems (limited visibility)
|
||||
- Heavy data collection (privacy concerns)
|
||||
- Vendor dependency
|
||||
|
||||
**Winner:** ChronoSeal for teams wanting transparency and control
|
||||
|
||||
---
|
||||
|
||||
### 4. reCAPTCHA v3
|
||||
|
||||
**Strengths:**
|
||||
- Free tier available
|
||||
- Easy integration
|
||||
|
||||
**Weaknesses:**
|
||||
- Heavy Google tracking
|
||||
- Increasingly bypassed
|
||||
- Poor privacy
|
||||
|
||||
**Winner:** ChronoSeal by a large margin
|
||||
|
||||
---
|
||||
|
||||
## When to Choose ChronoSeal
|
||||
|
||||
**Choose ChronoSeal if you want:**
|
||||
|
||||
- Maximum privacy
|
||||
- Strong cryptographic guarantees
|
||||
- Full control over your infrastructure
|
||||
- Tunable defense strength
|
||||
- No third-party data sharing
|
||||
- Open source transparency
|
||||
|
||||
**Choose Commercial Solutions if you want:**
|
||||
|
||||
- Zero maintenance
|
||||
- Massive global threat intelligence
|
||||
- Enterprise support & SLAs
|
||||
- Quick deployment at huge scale
|
||||
|
||||
## Technical Differentiation
|
||||
|
||||
ChronoSeal’s unique advantage is the **Synthetic Gene Mutation Engine** — a deterministic, server-controlled mutation sequence that both server and browser WASM must execute in sync. This creates a second synchronized state channel that is extremely difficult for automation to maintain at scale.
|
||||
|
||||
No commercial solution currently offers equivalent cryptographic + mutation-based attestation in a self-hosted package.
|
||||
|
||||
---
|
||||
|
||||
## Conclusion
|
||||
|
||||
**ChronoSeal** is currently one of the strongest **open-source/self-hosted** anti-bot solutions available. It trades ease-of-use and global scale for **privacy, transparency, cryptographic strength, and control**.
|
||||
|
||||
It is particularly well-suited for:
|
||||
- Privacy-focused organizations
|
||||
- High-value content platforms
|
||||
- Teams that want to avoid vendor lock-in
|
||||
- Developers who value auditability
|
||||
|
||||
---
|
||||
+240
-256
@@ -1,205 +1,244 @@
|
||||
# ChronoSeal — Deployment Guide
|
||||
# ChronoSeal Deployment Guide
|
||||
|
||||
## Prerequisites
|
||||
ChronoSeal is intended to run as a small Unix daemon behind TLS, with static browser assets served either by the daemon or by the same protected origin. This guide covers native, service, and container deployment.
|
||||
|
||||
| Tool | Minimum version | Purpose |
|
||||
|---|---|---|
|
||||
| Rust | 1.87 stable | Server + WASM compilation |
|
||||
| wasm-pack | 0.13 | WASM build and packaging |
|
||||
| Docker + Compose | 24 / 2.x | Container deployment |
|
||||
| nginx / NPM / HAProxy | any | TLS termination, reverse proxy |
|
||||
## Deployment Model
|
||||
|
||||
Install Rust: https://rustup.rs
|
||||
Install wasm-pack: `cargo install wasm-pack`
|
||||
Typical production topology:
|
||||
|
||||
---
|
||||
```text
|
||||
Internet
|
||||
|
|
||||
v
|
||||
TLS reverse proxy
|
||||
|
|
||||
v
|
||||
chronoseal daemon on 127.0.0.1:3000
|
||||
|
|
||||
v
|
||||
sqlite-in-disk or valkey storage
|
||||
```
|
||||
|
||||
For local evaluation, the daemon can bind directly to `0.0.0.0:3000` or `127.0.0.1:3000`.
|
||||
|
||||
## Requirements
|
||||
|
||||
| Tool | Minimum | Purpose |
|
||||
|---|---:|---|
|
||||
| Rust | 1.87 stable | Build server and shared crates |
|
||||
| `wasm32-unknown-unknown` target | current stable | Compile WASM runtime |
|
||||
| `wasm-pack` | 0.13 | Generate browser WASM package |
|
||||
| systemd | 248+ | Native service management |
|
||||
| Docker | 24.x | Optional container image |
|
||||
| Docker Compose | 2.x | Optional local orchestration |
|
||||
|
||||
Install Rust from rustup, then install the WASM tooling:
|
||||
|
||||
```bash
|
||||
rustup target add wasm32-unknown-unknown
|
||||
cargo install wasm-pack
|
||||
```
|
||||
|
||||
## Build
|
||||
|
||||
### 1. Build the WASM module
|
||||
|
||||
```bash
|
||||
wasm-pack build wasm --target web --release
|
||||
mv wasm/pkg frontend/pkg
|
||||
```
|
||||
|
||||
This produces `frontend/pkg/antibot_wasm.js` and `frontend/pkg/antibot_wasm_bg.wasm`,
|
||||
which are loaded by `frontend/main.js` at runtime.
|
||||
|
||||
### 2. Build the server
|
||||
|
||||
```bash
|
||||
cargo build -p server --release
|
||||
```
|
||||
|
||||
Binary output: `target/release/server`
|
||||
|
||||
### 3. Build both (convenience script)
|
||||
Use the repository build script:
|
||||
|
||||
```bash
|
||||
bash scripts/build.sh
|
||||
```
|
||||
|
||||
---
|
||||
The script:
|
||||
|
||||
## Running
|
||||
1. Builds `wasm/` with `wasm-pack build --target web --release`.
|
||||
2. Replaces `frontend/pkg` with the generated package.
|
||||
3. Builds the release daemon binary.
|
||||
|
||||
### Development
|
||||
Manual equivalent:
|
||||
|
||||
```bash
|
||||
bash scripts/dev.sh
|
||||
wasm-pack build wasm --target web --release
|
||||
rm -rf frontend/pkg
|
||||
mv wasm/pkg frontend/pkg
|
||||
|
||||
cargo build -p chronoseal-server --bin chronoseal --release
|
||||
```
|
||||
|
||||
Runs the server with `cargo run --release`. The server serves the `frontend/`
|
||||
directory statically at `/` via tower-http `ServeDir`.
|
||||
Release binary:
|
||||
|
||||
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.
|
||||
```text
|
||||
target/release/chronoseal
|
||||
```
|
||||
|
||||
### Production (native binary)
|
||||
## Native Install
|
||||
|
||||
The installer builds, installs, enables, and starts the service:
|
||||
|
||||
```bash
|
||||
cargo build -p server --release
|
||||
sudo cp target/release/server /usr/local/bin/chronoseal
|
||||
sudo bash scripts/install.sh
|
||||
```
|
||||
|
||||
Set environment variables before running:
|
||||
Installer actions:
|
||||
|
||||
```bash
|
||||
export RUST_LOG=info # or warn for quieter output
|
||||
chronoseal
|
||||
```
|
||||
- create the `chronoseal` system user if missing
|
||||
- build WASM and server artifacts
|
||||
- install `target/release/chronoseal` to `/usr/local/bin/chronoseal`
|
||||
- copy `frontend/` to `/opt/chronoseal/frontend`
|
||||
- install `chronoseal.service` to `/etc/systemd/system/chronoseal.service`
|
||||
- reload systemd
|
||||
- enable and start the service
|
||||
|
||||
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
|
||||
|
||||
### Service file
|
||||
|
||||
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
|
||||
|
||||
```bash
|
||||
# Create a dedicated system user
|
||||
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
|
||||
Verify:
|
||||
|
||||
```bash
|
||||
sudo systemctl status chronoseal
|
||||
journalctl -u chronoseal -f
|
||||
chronoseal status --format json
|
||||
chronoseal health
|
||||
sudo journalctl -u chronoseal -f
|
||||
```
|
||||
|
||||
---
|
||||
## Running Without Install
|
||||
|
||||
## Docker
|
||||
|
||||
### Build and run
|
||||
For local development:
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
bash scripts/build.sh
|
||||
cargo run -p chronoseal-server --bin chronoseal -- run \
|
||||
--bind 127.0.0.1:3000 \
|
||||
--frontend-dir frontend
|
||||
```
|
||||
|
||||
### docker-compose.yml overview
|
||||
|
||||
```yaml
|
||||
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/`.
|
||||
Probe the daemon:
|
||||
|
||||
```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
|
||||
curl http://127.0.0.1:3000/health
|
||||
curl http://127.0.0.1:3000/stats
|
||||
curl http://127.0.0.1:3000/metrics
|
||||
```
|
||||
|
||||
Or mount the pre-built frontend as a volume:
|
||||
## Configuration
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- ./frontend:/opt/chronoseal/frontend:ro
|
||||
ChronoSeal resolves configuration in this order:
|
||||
|
||||
1. CLI flags
|
||||
2. `CHRONOSEAL_*` environment variables
|
||||
3. TOML config file
|
||||
4. built-in defaults
|
||||
|
||||
Default config discovery:
|
||||
|
||||
1. `CHRONOSEAL_CONFIG`, if it points to an existing file
|
||||
2. `/etc/chronoseal/config.toml`
|
||||
3. `$XDG_CONFIG_HOME/chronoseal/config.toml`
|
||||
4. `~/.config/chronoseal/config.toml`
|
||||
|
||||
Validate effective configuration:
|
||||
|
||||
```bash
|
||||
chronoseal config check --format yaml
|
||||
```
|
||||
|
||||
---
|
||||
Example:
|
||||
|
||||
## Reverse Proxy
|
||||
```toml
|
||||
bind = "127.0.0.1:3000"
|
||||
db_type = "sqlite-in-disk"
|
||||
pid_file = "/run/chronoseal.pid"
|
||||
db_path = "/var/lib/chronoseal/chronoseal.sqlite"
|
||||
frontend_dir = "/usr/share/chronoseal/frontend"
|
||||
log_file = "/var/log/chronoseal/chronoseal.jsonl"
|
||||
|
||||
ChronoSeal must be served over HTTPS. The heartbeat payload contains a
|
||||
timestamp; if traffic is observable in plaintext, timing attacks become
|
||||
easier. TLS 1.3 is strongly recommended.
|
||||
heartbeat_min_interval_ms = 12000
|
||||
heartbeat_max_interval_ms = 25000
|
||||
expiration_minutes = 30
|
||||
rate_limit_count = 5
|
||||
rate_limit_window_secs = 10
|
||||
max_timestamp_drift_ms = 30000
|
||||
|
||||
### nginx
|
||||
min_mouse_total_dist = 10.0
|
||||
max_mouse_avg_speed = 2.0
|
||||
min_pause_count = 1
|
||||
require_mouse_activity = true
|
||||
|
||||
gene_size = 512
|
||||
mutation_rounds = 4
|
||||
```
|
||||
|
||||
## Storage Backends
|
||||
|
||||
| Backend | `db_type` | Use case |
|
||||
|---|---|---|
|
||||
| SQLite memory | `sqlite-in-memory` | ephemeral local or stateless deployment |
|
||||
| SQLite disk | `sqlite-in-disk` | persisted session continuity across restarts |
|
||||
| Valkey | `valkey` | external session storage |
|
||||
|
||||
For disk persistence:
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /var/lib/chronoseal
|
||||
sudo chown -R chronoseal:chronoseal /var/lib/chronoseal
|
||||
```
|
||||
|
||||
For Valkey:
|
||||
|
||||
```bash
|
||||
export CHRONOSEAL_DB_TYPE=valkey
|
||||
export CHRONOSEAL_VALKEY_ADDR=127.0.0.1:6666
|
||||
```
|
||||
|
||||
If Valkey connection setup fails, the current implementation logs a warning and falls back to in-memory SQLite.
|
||||
|
||||
## systemd
|
||||
|
||||
The supplied service file is intended as the baseline unit. Keep the daemon under a dedicated user and restrict filesystem access to the paths it needs.
|
||||
|
||||
Useful commands:
|
||||
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now chronoseal
|
||||
sudo systemctl restart chronoseal
|
||||
sudo systemctl status chronoseal
|
||||
sudo journalctl -u chronoseal -f
|
||||
```
|
||||
|
||||
Recommended hardening properties include:
|
||||
|
||||
- `NoNewPrivileges=true`
|
||||
- `PrivateTmp=true`
|
||||
- `ProtectSystem=strict`
|
||||
- `ProtectHome=true`
|
||||
- `ProtectKernelTunables=true`
|
||||
- `ProtectKernelModules=true`
|
||||
- `ProtectControlGroups=true`
|
||||
- `MemoryDenyWriteExecute=true`
|
||||
- `RestrictRealtime=true`
|
||||
- `RestrictSUIDSGID=true`
|
||||
- `SystemCallArchitectures=native`
|
||||
|
||||
Any hardening must still allow access to:
|
||||
|
||||
- the binary
|
||||
- frontend assets
|
||||
- PID file directory
|
||||
- optional log file directory
|
||||
- SQLite database directory, if using `sqlite-in-disk`
|
||||
|
||||
## Reverse Proxy and TLS
|
||||
|
||||
ChronoSeal should be served over HTTPS in production. Terminate TLS at a reverse proxy or load balancer and proxy to the local daemon.
|
||||
|
||||
Minimal nginx example:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name your.domain.com;
|
||||
server_name example.com;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/your.domain.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/your.domain.com/privkey.pem;
|
||||
ssl_protocols TLSv1.3;
|
||||
ssl_ciphers ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
|
||||
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
|
||||
|
||||
# Tight timeouts — heartbeat interval is 12–25s
|
||||
proxy_read_timeout 35s;
|
||||
proxy_send_timeout 10s;
|
||||
proxy_read_timeout 35s;
|
||||
proxy_send_timeout 10s;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:3000;
|
||||
@@ -213,134 +252,79 @@ server {
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name your.domain.com;
|
||||
server_name example.com;
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
```
|
||||
|
||||
### Nginx Proxy Manager
|
||||
Keep `/init`, `/hb`, and frontend assets on the same origin when possible. If you split origins, configure CORS and cookie/application policy deliberately.
|
||||
|
||||
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;
|
||||
```
|
||||
## Docker
|
||||
|
||||
### HAProxy
|
||||
Build and run:
|
||||
|
||||
```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
|
||||
```bash
|
||||
bash scripts/build.sh
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
---
|
||||
The Compose file exposes port `3000`.
|
||||
|
||||
## 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;
|
||||
}
|
||||
```bash
|
||||
curl http://127.0.0.1:3000/health
|
||||
```
|
||||
|
||||
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 |
|
||||
|
||||
---
|
||||
The Dockerfile copies `frontend/` from the working tree. Build `frontend/pkg` before building the image when the browser WASM runtime is required inside the container.
|
||||
|
||||
## 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 |
|
||||
CLI:
|
||||
|
||||
```bash
|
||||
RUST_LOG=info chronoseal # production
|
||||
RUST_LOG=debug chronoseal # development
|
||||
RUST_LOG=warn chronoseal # minimal output
|
||||
chronoseal status --format json
|
||||
chronoseal health
|
||||
chronoseal stats --format json
|
||||
chronoseal metrics
|
||||
```
|
||||
|
||||
Log format is plain text to stdout. Pipe to `journald`, `fluentd`, or any
|
||||
log aggregator via stdout capture.
|
||||
|
||||
---
|
||||
|
||||
## Health Check
|
||||
|
||||
The server has no dedicated `/health` endpoint. Use a TCP check on port 3000,
|
||||
or a lightweight HTTP check on `GET /` (which serves `index.html`).
|
||||
HTTP:
|
||||
|
||||
```bash
|
||||
# Docker health check (add to docker-compose.yml if needed)
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-sf", "http://localhost:3000/"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
curl http://127.0.0.1:3000/health
|
||||
curl http://127.0.0.1:3000/stats
|
||||
curl http://127.0.0.1:3000/metrics
|
||||
```
|
||||
|
||||
---
|
||||
Prometheus metrics:
|
||||
|
||||
## Security Checklist
|
||||
- `chronoseal_sessions`
|
||||
- `chronoseal_expired_sessions`
|
||||
- `chronoseal_max_chain_length`
|
||||
|
||||
- [ ] TLS 1.3 enabled, TLS 1.0/1.1 disabled
|
||||
- [ ] HTTP/2 enabled
|
||||
- [ ] Port 3000 not exposed to the public internet (only via reverse proxy)
|
||||
- [ ] `RUST_LOG=warn` or `info` in production (not `debug` — session IDs appear in logs)
|
||||
- [ ] systemd service running as `chronoseal` user with hardened sandbox
|
||||
- [ ] `MemoryDenyWriteExecute=true` in service file (prevents JIT in process)
|
||||
- [ ] CORS `CorsLayer::permissive()` replaced with origin-restricted policy for production
|
||||
- [ ] Frontend assets served over the same HTTPS origin as protected pages
|
||||
## Logging
|
||||
|
||||
Use info-level logs for production:
|
||||
|
||||
```bash
|
||||
CHRONOSEAL_LOG=info chronoseal run
|
||||
```
|
||||
|
||||
or with systemd:
|
||||
|
||||
```bash
|
||||
sudo systemctl edit chronoseal
|
||||
```
|
||||
|
||||
Avoid debug logging in production because internal identifiers may be written to logs.
|
||||
|
||||
## Production Checklist
|
||||
|
||||
- Build `frontend/pkg` before packaging.
|
||||
- Serve ChronoSeal traffic over HTTPS.
|
||||
- Bind the daemon to localhost behind a reverse proxy unless direct exposure is required.
|
||||
- Use a dedicated service user.
|
||||
- Keep debug logs disabled.
|
||||
- Choose storage intentionally: `sqlite-in-memory`, `sqlite-in-disk`, or `valkey`.
|
||||
- Protect SQLite and log directories with correct ownership.
|
||||
- Monitor `/health`, `/stats`, and `/metrics`.
|
||||
- Verify `chronoseal config check` after environment or config changes.
|
||||
+111
-27
@@ -1,41 +1,125 @@
|
||||
# ChronoSeal Design Philosophy
|
||||
|
||||
**"Everything is a File" — Unix-Native Software Design**
|
||||
ChronoSeal is designed for operators who want a local, inspectable, Unix-native browser attestation layer rather than a hosted anti-bot black box.
|
||||
|
||||
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 Position
|
||||
|
||||
### Why This Philosophy Matters
|
||||
ChronoSeal is infrastructure software. It should feel closer to `nginx`, `redis-server`, or a small system daemon than to a third-party analytics platform.
|
||||
|
||||
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
|
||||
- CLI-first operation
|
||||
- explicit configuration
|
||||
- deterministic protocol behavior
|
||||
- small runtime surface
|
||||
- privacy-preserving state
|
||||
- observable health and metrics
|
||||
- no hidden telemetry
|
||||
- no persistent user profiling
|
||||
|
||||
- **Everything is a File**: The application must be controllable, inspectable, and composable through standard Unix interfaces (CLI, files, signals, pipes, and environment).
|
||||
- **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.
|
||||
## What ChronoSeal Optimizes For
|
||||
|
||||
### Non-Goals
|
||||
### Operator Control
|
||||
|
||||
ChronoSeal is **not** designed to be:
|
||||
- Cloud-first or vendor-specific
|
||||
- Browser-first or JavaScript-heavy
|
||||
- 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
|
||||
Operators should be able to build, run, inspect, configure, monitor, and stop the service with ordinary Unix tools.
|
||||
|
||||
These non-goals help keep the project focused on stability, simplicity, security, and deep Unix integration.
|
||||
This is why ChronoSeal provides:
|
||||
|
||||
### Development Mindset
|
||||
- `chronoseal run`
|
||||
- `chronoseal status`
|
||||
- `chronoseal health`
|
||||
- `chronoseal config check`
|
||||
- `chronoseal metrics`
|
||||
- `chronoseal stats`
|
||||
- shell completions
|
||||
- systemd integration
|
||||
|
||||
- Production robustness, security, and long-term sustainability take clear precedence over development speed.
|
||||
- Every design decision is evaluated against one question:
|
||||
**“Does this make ChronoSeal feel like it naturally belongs in `/usr/bin/`?”**
|
||||
### Determinism
|
||||
|
||||
This philosophy guided the complete refactoring of ChronoSeal and continues to drive all future development.
|
||||
The protocol depends on deterministic agreement between server Rust and browser WASM.
|
||||
|
||||
**Status**: Core architecture and systemd integration completed. Rich CLI, runtime configuration system, and one-line installer are in active development.
|
||||
Shared logic belongs in `shared/` when divergence would create security or correctness risk. This includes:
|
||||
|
||||
- protocol structs
|
||||
- hash-chain semantics
|
||||
- synthetic gene model
|
||||
- mutation opcode behavior
|
||||
- mutation order encoding
|
||||
|
||||
### Cost Escalation
|
||||
|
||||
ChronoSeal does not claim impossible security. It raises the cost of automation by making clients maintain:
|
||||
|
||||
- a browser-local signing key
|
||||
- a signed canonical heartbeat payload
|
||||
- a Blake3 hash chain
|
||||
- VM execution output
|
||||
- server-issued mutation progression
|
||||
- plausible timing and interaction signals
|
||||
|
||||
The objective is to make cheap automation brittle and expensive automation more complex.
|
||||
|
||||
### Silent Rejection
|
||||
|
||||
Heartbeat rejection is intentionally ambiguous. Invalid heartbeats receive the same `status` value as accepted heartbeats, but accepted responses include next-state fields.
|
||||
|
||||
This avoids turning the API into a validation oracle. Integrators must check for `next_salt`, `next_mutation_step`, and `next_mutation_order_b64`.
|
||||
|
||||
### Privacy
|
||||
|
||||
ChronoSeal should not become a surveillance system.
|
||||
|
||||
It avoids:
|
||||
|
||||
- long-term user identifiers
|
||||
- browser history
|
||||
- cross-site identity graphs
|
||||
- fingerprint databases
|
||||
- behavioral profiling as a product feature
|
||||
|
||||
It stores only the session state required for continuity.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
ChronoSeal is not:
|
||||
|
||||
- a CAPTCHA
|
||||
- a fraud scoring engine
|
||||
- an authentication provider
|
||||
- a hosted SaaS product
|
||||
- a persistent fingerprinting system
|
||||
- a replacement for authorization checks
|
||||
- a complete defense against real browser farms
|
||||
|
||||
## Operational Assumptions
|
||||
|
||||
ChronoSeal assumes:
|
||||
|
||||
- Linux or a Unix-like host
|
||||
- systemd for production service management
|
||||
- TLS in production
|
||||
- browser clients can execute WASM
|
||||
- operators can manage config files and service users
|
||||
- application owners decide how attestation status gates protected resources
|
||||
|
||||
## Engineering Biases
|
||||
|
||||
When the project faces tradeoffs, prefer:
|
||||
|
||||
- explicit configuration over implicit magic
|
||||
- server-side recomputation over browser trust
|
||||
- bounded deterministic execution over unbounded heuristics
|
||||
- clear CLI output over hidden dashboards
|
||||
- local deployment over mandatory cloud dependencies
|
||||
- privacy by data minimization over privacy by policy alone
|
||||
|
||||
## Success Criteria
|
||||
|
||||
ChronoSeal is succeeding when:
|
||||
|
||||
- legitimate browser sessions advance without user friction
|
||||
- simple scrapers cannot pass the protocol
|
||||
- automation requires a full stateful implementation
|
||||
- operators can debug deployments with normal Unix tools
|
||||
- stored data remains minimal and short-lived
|
||||
- documentation reflects the implementation precisely
|
||||
@@ -0,0 +1,283 @@
|
||||
# ChronoSeal Performance Tuning Guide
|
||||
|
||||
ChronoSeal uses a deterministic Synthetic Gene Mutation Engine to strengthen browser session continuity validation. This guide explains how to tune the mutation engine for an appropriate balance between security strength, resource consumption, and user experience.
|
||||
|
||||
The primary tuning parameters are:
|
||||
|
||||
* `gene_size` — size of the synthetic gene buffer
|
||||
* `mutation_rounds` — number of mutation iterations executed per heartbeat
|
||||
|
||||
---
|
||||
|
||||
## Understanding the Mutation Engine
|
||||
|
||||
For every accepted heartbeat, ChronoSeal executes a server-generated mutation program against a synthetic gene buffer.
|
||||
|
||||
Increasing mutation complexity raises the computational cost of reproducing valid session state while also increasing CPU utilization on both the server and browser runtime.
|
||||
|
||||
General effects:
|
||||
|
||||
* Larger `gene_size` increases mutation state complexity.
|
||||
* Higher `mutation_rounds` increase computational work per heartbeat.
|
||||
* Both increase memory access and CPU consumption.
|
||||
* Excessive values may negatively impact lower-end mobile devices.
|
||||
|
||||
The optimal values depend on your threat model and expected client hardware.
|
||||
|
||||
---
|
||||
|
||||
## Recommended Configurations
|
||||
|
||||
| Profile | `gene_size` | `mutation_rounds` | Security Level | Recommended Usage |
|
||||
| ----------------- | ----------- | ----------------- | ---------------- | -------------------------------- |
|
||||
| Default | 512 | 4 | Moderate | Development and testing |
|
||||
| Recommended | 2048 | 16 | Strong | Most production deployments |
|
||||
| High Security | 4096 | 32 | Very Strong | Sensitive applications |
|
||||
| Maximum Practical | 8192 | 64 | Extremely Strong | High-value targets |
|
||||
| Experimental | 65536 | 65536 | Research Only | Benchmarking and experimentation |
|
||||
|
||||
### Recommended Production Configuration
|
||||
|
||||
```toml
|
||||
gene_size = 2048
|
||||
mutation_rounds = 16
|
||||
```
|
||||
|
||||
This configuration provides a strong balance between security and runtime overhead for most deployments.
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
Edit your configuration file:
|
||||
|
||||
```toml
|
||||
# Mutation Engine Settings
|
||||
|
||||
gene_size = 2048
|
||||
mutation_rounds = 16
|
||||
```
|
||||
|
||||
Common configuration locations:
|
||||
|
||||
```text
|
||||
/etc/chronoseal/config.toml
|
||||
~/.config/chronoseal/config.toml
|
||||
```
|
||||
|
||||
Restart ChronoSeal:
|
||||
|
||||
```bash
|
||||
sudo systemctl restart chronoseal
|
||||
```
|
||||
|
||||
Validate the effective configuration:
|
||||
|
||||
```bash
|
||||
chronoseal config check --format yaml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Performance Monitoring
|
||||
|
||||
### Server-Side Monitoring
|
||||
|
||||
View service logs:
|
||||
|
||||
```bash
|
||||
sudo journalctl -u chronoseal -f
|
||||
```
|
||||
|
||||
Inspect runtime statistics:
|
||||
|
||||
```bash
|
||||
chronoseal stats --format json
|
||||
```
|
||||
|
||||
Enable additional diagnostics when required:
|
||||
|
||||
```bash
|
||||
CHRONOSEAL_LOG=debug chronoseal run
|
||||
```
|
||||
|
||||
Avoid debug logging in production environments.
|
||||
|
||||
---
|
||||
|
||||
### Browser-Side Monitoring
|
||||
|
||||
Measure mutation execution time:
|
||||
|
||||
```javascript
|
||||
console.time("gene-mutation");
|
||||
|
||||
const commitment = preview_gene_commitment(
|
||||
order_b64,
|
||||
session_id,
|
||||
mutation_step,
|
||||
mutation_rounds
|
||||
);
|
||||
|
||||
console.timeEnd("gene-mutation");
|
||||
```
|
||||
|
||||
Browser developer tools can also be used to monitor:
|
||||
|
||||
* JavaScript execution time
|
||||
* WASM execution time
|
||||
* CPU utilization
|
||||
* Memory consumption
|
||||
|
||||
---
|
||||
|
||||
## Tuning Strategy
|
||||
|
||||
### Step 1: Start with Recommended Values
|
||||
|
||||
```toml
|
||||
gene_size = 2048
|
||||
mutation_rounds = 16
|
||||
```
|
||||
|
||||
Deploy and observe normal usage patterns.
|
||||
|
||||
### Step 2: Monitor Heartbeat Success Rates
|
||||
|
||||
Watch for:
|
||||
|
||||
* heartbeat failures
|
||||
* increased browser CPU usage
|
||||
* elevated mobile device latency
|
||||
* increased battery consumption
|
||||
|
||||
### Step 3: Increase Gradually
|
||||
|
||||
Increase one parameter at a time.
|
||||
|
||||
Recommended progression:
|
||||
|
||||
```text
|
||||
2048 / 16
|
||||
4096 / 16
|
||||
4096 / 32
|
||||
8192 / 32
|
||||
8192 / 64
|
||||
```
|
||||
|
||||
This makes it easier to identify performance bottlenecks.
|
||||
|
||||
### Step 4: Test Mobile Devices
|
||||
|
||||
Always test on:
|
||||
|
||||
* Android devices
|
||||
* iPhones
|
||||
* older laptops
|
||||
* low-power CPUs
|
||||
|
||||
Desktop-only validation can be misleading.
|
||||
|
||||
---
|
||||
|
||||
## Advanced Deployment Strategies
|
||||
|
||||
### Risk-Based Mutation Strength
|
||||
|
||||
Future deployments may choose to dynamically increase mutation strength based on:
|
||||
|
||||
* session age
|
||||
* failed heartbeat history
|
||||
* suspicious behavior signals
|
||||
* protected resource sensitivity
|
||||
|
||||
Example policy:
|
||||
|
||||
```text
|
||||
New session → 2048 / 16
|
||||
Suspicious session → 4096 / 32
|
||||
Elevated-risk action → 8192 / 64
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Performance Recommendations
|
||||
|
||||
### WASM Builds
|
||||
|
||||
Always use optimized builds:
|
||||
|
||||
```bash
|
||||
wasm-pack build wasm --target web --release
|
||||
```
|
||||
|
||||
### General Guidance
|
||||
|
||||
* Keep `mutation_rounds` below 64 for most deployments.
|
||||
* Prefer increasing `gene_size` before dramatically increasing rounds.
|
||||
* Benchmark on representative client hardware.
|
||||
* Monitor browser CPU utilization during load testing.
|
||||
* Re-evaluate settings after major algorithm changes.
|
||||
|
||||
### Storage Performance
|
||||
|
||||
For maximum throughput:
|
||||
|
||||
```toml
|
||||
db_type = "sqlite-in-memory"
|
||||
```
|
||||
|
||||
For persistence:
|
||||
|
||||
```toml
|
||||
db_type = "sqlite-in-disk"
|
||||
```
|
||||
|
||||
Choose based on operational requirements rather than mutation settings.
|
||||
|
||||
---
|
||||
|
||||
## Security Considerations
|
||||
|
||||
Higher values increase the cost of reproducing valid session state but do not provide absolute protection against determined attackers.
|
||||
|
||||
ChronoSeal remains a cost-raising attestation layer rather than a complete anti-abuse solution.
|
||||
|
||||
Mutation tuning should be considered alongside:
|
||||
|
||||
* heartbeat timing controls
|
||||
* signature validation
|
||||
* hash-chain continuity
|
||||
* behavioral trust checks
|
||||
* rate limiting
|
||||
* session expiration
|
||||
|
||||
---
|
||||
|
||||
## Recommended Starting Point
|
||||
|
||||
For most production deployments:
|
||||
|
||||
```toml
|
||||
gene_size = 2048
|
||||
mutation_rounds = 16
|
||||
```
|
||||
|
||||
This configuration provides a strong balance between security, performance, and compatibility across desktop and mobile devices.
|
||||
|
||||
---
|
||||
|
||||
## Related Documentation
|
||||
|
||||
* `docs/ARCHITECTURE.md`
|
||||
* `docs/API.md`
|
||||
* `docs/THREAT_MODEL.md`
|
||||
* `docs/REFRACTORING-v0.6.0.md`
|
||||
|
||||
For diagnostics:
|
||||
|
||||
```bash
|
||||
chronoseal config check
|
||||
chronoseal stats
|
||||
chronoseal health
|
||||
```
|
||||
+71
-243
@@ -1,278 +1,106 @@
|
||||
# ChronoSeal Privacy & Design Principles
|
||||
# ChronoSeal Privacy Policy
|
||||
|
||||
## Privacy-First Browser Attestation Framework
|
||||
ChronoSeal is a privacy-oriented browser attestation system. It is designed to validate short-lived session continuity without creating persistent user profiles.
|
||||
|
||||
ChronoSeal is a lightweight, privacy-first browser attestation framework designed to resist:
|
||||
This document describes what ChronoSeal itself collects and stores. Applications that integrate ChronoSeal may collect additional data under their own policies.
|
||||
|
||||
- automated bots
|
||||
- AI-driven browser automation
|
||||
- scripted abuse
|
||||
- browser surveillance ecosystems
|
||||
## Data ChronoSeal Processes
|
||||
|
||||
Unlike conventional anti-bot systems, ChronoSeal is intentionally designed to operate **without collecting or storing client identity data**.
|
||||
ChronoSeal processes the minimum protocol data needed to validate a live browser session.
|
||||
|
||||
---
|
||||
|
||||
# Core Philosophy
|
||||
|
||||
ChronoSeal verifies:
|
||||
|
||||
- session continuity
|
||||
- runtime coherence
|
||||
- cryptographic synchronization
|
||||
|
||||
It does **not** verify:
|
||||
|
||||
- personal identity
|
||||
- browsing history
|
||||
- behavioral profiles
|
||||
- long-term reputation
|
||||
|
||||
The framework is built around one principle:
|
||||
|
||||
> Verify live browser participation without turning users into telemetry.
|
||||
|
||||
---
|
||||
|
||||
# Privacy-First By Architecture
|
||||
|
||||
ChronoSeal is intentionally engineered to avoid becoming:
|
||||
|
||||
- a tracking platform
|
||||
- a fingerprinting database
|
||||
- a telemetry pipeline
|
||||
- a surveillance system
|
||||
|
||||
## ChronoSeal Does NOT Store
|
||||
|
||||
- IP addresses
|
||||
- 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.
|
||||
|
||||
---
|
||||
|
||||
# Stateless Trust Model
|
||||
|
||||
ChronoSeal focuses on:
|
||||
|
||||
- ephemeral runtime verification
|
||||
- cryptographic continuity
|
||||
- synchronized challenge progression
|
||||
- live execution integrity
|
||||
|
||||
The server only validates:
|
||||
|
||||
- whether the current browser session behaves like a coherent participant *right now*
|
||||
|
||||
ChronoSeal does not maintain:
|
||||
|
||||
- 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 |
|
||||
| Data | Purpose |
|
||||
|---|---|
|
||||
| `wasm/pkg/` | Generated build output |
|
||||
| `frontend/pkg/` | Generated serve-time artefacts |
|
||||
| `target/` | Standard Rust build artefacts |
|
||||
| `session_id` | Opaque session lookup key |
|
||||
| public key | Verify signed heartbeats for the session |
|
||||
| `salt` | Hash-chain progression |
|
||||
| `initial_hash` / `prev_hash` / `last_hash` | Replay-resistant continuity |
|
||||
| `timestamp` | Drift and liveness validation |
|
||||
| mouse event samples | Behavioral plausibility checks |
|
||||
| VM stack state | Input to hash-chain progression |
|
||||
| basic fingerprint fields | Sanity validation |
|
||||
| gene bytes and environment records | Mutation continuity |
|
||||
| pending mutation program and step | Next heartbeat verification |
|
||||
| expiration and last-seen timestamps | Session lifecycle and cleanup |
|
||||
|
||||
Generated binaries change frequently and are reproducible from source.
|
||||
Basic fingerprint fields currently include:
|
||||
|
||||
The repository intentionally stores:
|
||||
- aspect ratio
|
||||
- device pixel ratio
|
||||
- hardware concurrency
|
||||
|
||||
- source code
|
||||
- architecture
|
||||
- reproducible build logic only
|
||||
## Data ChronoSeal Does Not Intentionally Collect
|
||||
|
||||
---
|
||||
ChronoSeal does not intentionally collect or build:
|
||||
|
||||
# Unix-Native Operational Model
|
||||
- browser history
|
||||
- page content history
|
||||
- account identity
|
||||
- email addresses
|
||||
- names
|
||||
- payment data
|
||||
- location history
|
||||
- cross-site tracking identifiers
|
||||
- persistent fingerprint databases
|
||||
- long-term behavioral profiles
|
||||
|
||||
ChronoSeal is designed as:
|
||||
ChronoSeal is not intended for analytics, advertising, or identity graph construction.
|
||||
|
||||
- infrastructure software
|
||||
- not browser-centric SaaS
|
||||
## Session Lifetime
|
||||
|
||||
Core operational principles:
|
||||
Sessions are short-lived and expire according to `expiration_minutes`, which defaults to 30 minutes.
|
||||
|
||||
- CLI-first operation
|
||||
- systemd-native deployment
|
||||
- structured logs
|
||||
- explicit configuration
|
||||
- inspectable runtime behavior
|
||||
- minimal hidden state
|
||||
Expired sessions are removed by cleanup behavior. In-memory storage is lost when the process exits.
|
||||
|
||||
ChronoSeal should feel natural on Linux systems:
|
||||
## Storage Modes and Persistence
|
||||
|
||||
- simple to deploy
|
||||
- easy to audit
|
||||
- understandable years later
|
||||
| Mode | Persistence |
|
||||
|---|---|
|
||||
| `sqlite-in-memory` | process lifetime only |
|
||||
| `sqlite-in-disk` | persisted to the configured SQLite file |
|
||||
| `valkey` | persisted according to the Valkey deployment configuration |
|
||||
|
||||
---
|
||||
Persistent state is operator-selected. The default backend is `sqlite-in-memory`.
|
||||
|
||||
# Security Through Operational Asymmetry
|
||||
## Client-Side Key Handling
|
||||
|
||||
ChronoSeal increases attacker cost through:
|
||||
The browser WASM runtime generates an Ed25519 keypair for the session.
|
||||
|
||||
- synchronization burden
|
||||
- runtime continuity requirements
|
||||
- WASM-isolated cryptographic execution
|
||||
- chained session progression
|
||||
- The public key is sent to `/init`.
|
||||
- The private key is not sent to the server.
|
||||
- Heartbeat payloads are signed in the browser runtime.
|
||||
|
||||
It does not attempt:
|
||||
This is a continuity mechanism, not a long-term identity mechanism.
|
||||
|
||||
- invasive tracking
|
||||
- permanent identification
|
||||
- surveillance-driven scoring
|
||||
## Silent Rejection
|
||||
|
||||
---
|
||||
ChronoSeal returns the same basic heartbeat status for accepted and rejected heartbeat requests:
|
||||
|
||||
# Design Goals
|
||||
```json
|
||||
{
|
||||
"status": "ok"
|
||||
}
|
||||
```
|
||||
|
||||
ChronoSeal prioritizes:
|
||||
Accepted responses additionally include next-state fields. Rejected responses omit them.
|
||||
|
||||
- Privacy
|
||||
- Simplicity
|
||||
- Transparency
|
||||
- Operational clarity
|
||||
- Long-term maintainability
|
||||
- Minimalism
|
||||
- Unix-native behavior
|
||||
- Low deployment friction
|
||||
This reduces attacker feedback and avoids returning detailed failure classifications to clients.
|
||||
|
||||
---
|
||||
## Logs
|
||||
|
||||
# Non-Goals
|
||||
Operators control logging through `CHRONOSEAL_LOG`, `RUST_LOG`, and optional log-file configuration.
|
||||
|
||||
ChronoSeal is intentionally NOT:
|
||||
Production deployments should avoid debug logging because internal session identifiers or validation context may appear in logs.
|
||||
|
||||
- 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
|
||||
## Operator Responsibilities
|
||||
|
||||
---
|
||||
Operators should:
|
||||
|
||||
# Summary
|
||||
- serve traffic over HTTPS
|
||||
- protect SQLite, Valkey, and log storage
|
||||
- restrict access to metrics and stats endpoints
|
||||
- choose persistence mode deliberately
|
||||
- disclose any application-level data collection separately
|
||||
|
||||
ChronoSeal is designed to prove:
|
||||
## Summary
|
||||
|
||||
> “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.
|
||||
ChronoSeal validates live session continuity using short-lived cryptographic and deterministic state. It is designed to raise automation cost without becoming a persistent tracking or profiling system.
|
||||
+165
-89
@@ -1,115 +1,191 @@
|
||||
# ChronoSeal v0.6.0 — Synthetic Gene Mutation System
|
||||
# ChronoSeal v0.6.0 Refactoring and System Upgrade
|
||||
|
||||
## 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)>`).
|
||||
ChronoSeal v0.6.0 changed the project from a lightweight heartbeat prototype into a Unix-native attestation daemon with shared server/WASM protocol logic, deterministic mutation parity, operational CLI commands, and pluggable storage modes.
|
||||
|
||||
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.
|
||||
This document summarizes the architectural changes introduced in the v0.6.0 line.
|
||||
|
||||
## 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.
|
||||
## Summary
|
||||
|
||||
## Design Decisions
|
||||
1. **Shared mutation engine**
|
||||
Mutation opcode semantics live in `shared/src/vm_extensions.rs` to guarantee server/client parity from one implementation.
|
||||
Major changes:
|
||||
|
||||
2. **Deterministic gene commitment**
|
||||
A domain-separated BLAKE3 commitment (`chronoseal/gene/v1`) binds both gene bytes and sorted environment records.
|
||||
- introduced the Synthetic Gene Mutation Engine
|
||||
- added server-side validation of `mutation_step` and `gene_commitment`
|
||||
- moved protocol and deterministic mutation logic into `shared/`
|
||||
- added `chronoseal-wasm` browser runtime support for mutation preview and commit
|
||||
- expanded persisted session state with gene and pending mutation fields
|
||||
- added storage modes: `sqlite-in-memory`, `sqlite-in-disk`, and `valkey`
|
||||
- added health, metrics, stats, config, status, completion, and version CLI surfaces
|
||||
- added PID file handling, structured logging, and graceful shutdown behavior
|
||||
- preserved silent heartbeat rejection semantics
|
||||
|
||||
3. **Bounded mutation complexity**
|
||||
Mutation program length is capped (`MAX_MUTATION_PROGRAM_BYTES`) and environment cardinality is capped (`MAX_ENV_RECORDS`).
|
||||
## Motivation
|
||||
|
||||
4. **Strict validation on ingest**
|
||||
Environment payloads are validated for sortedness, uniqueness, non-zero quantity, and length constraints.
|
||||
The earlier model relied mainly on:
|
||||
|
||||
5. **Protocol-level mutation handshake**
|
||||
`InitResponse` and `Heartbeat` payloads now include mutation step/order and commitment fields.
|
||||
- heartbeat timing
|
||||
- behavioral entropy
|
||||
- hash-chain continuity
|
||||
- signature verification
|
||||
|
||||
6. **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)
|
||||
v0.6.0 added a second deterministic state channel: a server-authored synthetic gene mutation sequence. This makes successful automation maintain both:
|
||||
|
||||
## Implementation Plan
|
||||
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).
|
||||
- the cryptographic hash/signature chain
|
||||
- the synthetic mutation state expected by the server
|
||||
|
||||
## Testing Strategy (detailed section)
|
||||
ChronoSeal v0.6.0 test coverage is organized across unit, integration, and randomized/fuzz-style validation.
|
||||
## Shared Crate Refactor
|
||||
|
||||
1. **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.
|
||||
`shared/` now owns the parts of the protocol that must remain identical across server and browser runtime:
|
||||
|
||||
2. **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.
|
||||
- request and response structs
|
||||
- hashing helpers
|
||||
- synthetic gene state
|
||||
- mutation environment encoding
|
||||
- mutation order generation and encoding
|
||||
- opcode execution semantics
|
||||
- protocol constants
|
||||
|
||||
3. **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.
|
||||
This reduces the risk of server/WASM drift.
|
||||
|
||||
4. **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.
|
||||
## Mutation Handshake
|
||||
|
||||
## Security Analysis
|
||||
1. **Replay resistance**
|
||||
Heartbeats are now tied to both chain hash and mutation step progression.
|
||||
New protocol fields:
|
||||
|
||||
2. **Mutation tampering resistance**
|
||||
Server recomputes candidate gene state from authoritative pending mutation program and rejects commitment mismatch.
|
||||
- `gene_size`
|
||||
- `mutation_step`
|
||||
- `mutation_order_b64`
|
||||
- `gene_commitment`
|
||||
- `next_mutation_step`
|
||||
- `next_mutation_order_b64`
|
||||
|
||||
3. **Protocol ambiguity reduction**
|
||||
Canonical signing payload includes mutation fields, reducing exploitable unsigned state.
|
||||
Lifecycle:
|
||||
|
||||
4. **Input hardening**
|
||||
Program size limits, stack underflow checks, and strict environment decoding reduce parser abuse and malformed payload amplification.
|
||||
1. `/init` returns mutation step 1 and a server-authored mutation order.
|
||||
2. The browser previews the mutation in WASM.
|
||||
3. The browser signs and submits the resulting `gene_commitment`.
|
||||
4. The server applies the same pending mutation to its committed state.
|
||||
5. The server compares commitments.
|
||||
6. On success, server commits the candidate state and issues the next mutation.
|
||||
7. The browser commits its preview only after receiving the accepted response.
|
||||
|
||||
5. **Deterministic failure semantics**
|
||||
Invalid mutation instructions fail predictably and symmetrically across server and WASM paths.
|
||||
## Session Schema Changes
|
||||
|
||||
## Performance Considerations
|
||||
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.
|
||||
The persisted session record now includes:
|
||||
|
||||
## Migration & Backward Compatibility
|
||||
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.
|
||||
- committed gene bytes
|
||||
- encoded environment records
|
||||
- pending mutation program
|
||||
- pending mutation step
|
||||
|
||||
## Risks & Mitigations
|
||||
1. **Risk: State divergence between server and client**
|
||||
Mitigation: shared opcode engine + deterministic seeded parity tests + multi-heartbeat integration tests.
|
||||
State advances only after a heartbeat is accepted. Rejected heartbeats do not rotate salt, update hash state, commit gene state, or consume the pending mutation.
|
||||
|
||||
2. **Risk: Mutation opcode abuse via malformed programs**
|
||||
Mitigation: strict parsing, length caps, explicit underflow/unknown-opcode errors.
|
||||
## WASM Runtime Changes
|
||||
|
||||
3. **Risk: Performance regressions**
|
||||
Mitigation: bounded structures, smoke timing tests, and focused hot-path validation.
|
||||
The WASM crate now supports:
|
||||
|
||||
4. **Risk: Backend confusion during `db_type` rollout**
|
||||
Mitigation: explicit CLI command (`chronoseal db-type`), config output visibility, and clear runtime compatibility behavior.
|
||||
- `generate_keypair()`
|
||||
- `get_public_key()`
|
||||
- `sign_message()`
|
||||
- `compute_next_hash()`
|
||||
- `run_program()`
|
||||
- `init_gene_state()`
|
||||
- `preview_gene_commitment(order_b64, session_id, mutation_step, rounds)`
|
||||
- `commit_gene_preview()`
|
||||
- `discard_gene_preview()`
|
||||
- `current_gene_commitment(session_id, mutation_step)`
|
||||
|
||||
The generated package uses the `chronoseal_wasm` prefix.
|
||||
|
||||
## Storage Refactor
|
||||
|
||||
The storage layer is abstracted behind `DbPool`.
|
||||
|
||||
Supported modes:
|
||||
|
||||
| Mode | Behavior |
|
||||
|---|---|
|
||||
| `sqlite-in-memory` | default ephemeral in-process SQLite |
|
||||
| `sqlite-in-disk` | persisted SQLite database at `db_path` |
|
||||
| `valkey` | Valkey-compatible external store |
|
||||
|
||||
The storage interface supports insert, load, update, delete expired sessions, and stats.
|
||||
|
||||
## CLI and Runtime Changes
|
||||
|
||||
The `chronoseal` binary now provides:
|
||||
|
||||
- `run`
|
||||
- `status`
|
||||
- `health`
|
||||
- `config check`
|
||||
- `generate keypair`
|
||||
- `version`
|
||||
- `db-type`
|
||||
- `metrics`
|
||||
- `stats`
|
||||
- `completion`
|
||||
|
||||
The daemon exposes:
|
||||
|
||||
- `POST /init`
|
||||
- `POST /hb`
|
||||
- `GET /health`
|
||||
- `GET /metrics`
|
||||
- `GET /stats`
|
||||
- static frontend serving at `/`
|
||||
|
||||
## Validation Improvements
|
||||
|
||||
The heartbeat verifier now checks:
|
||||
|
||||
- session presence
|
||||
- expiration
|
||||
- signature
|
||||
- hash-chain continuity
|
||||
- mutation step
|
||||
- mutation commitment parity
|
||||
- timestamp drift
|
||||
- behavioral mouse checks
|
||||
- fingerprint ranges
|
||||
- rate limiting at the route layer
|
||||
|
||||
Accepted heartbeats return next-state fields. Rejected heartbeats return only `{"status":"ok"}`.
|
||||
|
||||
## Testing Impact
|
||||
|
||||
The refactor added or strengthened tests for:
|
||||
|
||||
- gene environment encoding and validation
|
||||
- mutation opcode behavior
|
||||
- mutation order round-trips
|
||||
- deterministic mutation generation with seeded RNG
|
||||
- server/client mutation parity
|
||||
- random program divergence resistance
|
||||
- replay rejection
|
||||
- mutation step mismatch rejection
|
||||
- mutation commitment tamper rejection
|
||||
- storage backend stats
|
||||
- route-level silent rejection behavior
|
||||
|
||||
## Operational Impact
|
||||
|
||||
v0.6.0 makes ChronoSeal more suitable for deployment as a real service:
|
||||
|
||||
- explicit daemon lifecycle
|
||||
- CLI-first operations
|
||||
- systemd-oriented install path
|
||||
- health and metrics endpoints
|
||||
- configurable persistence
|
||||
- shared protocol implementation
|
||||
- clearer docs and threat model
|
||||
|
||||
## Compatibility Notes
|
||||
|
||||
Important names in the current implementation:
|
||||
|
||||
- binary: `chronoseal`
|
||||
- server crate: `chronoseal-server`
|
||||
- WASM crate: `chronoseal-wasm`
|
||||
- generated WASM module prefix: `chronoseal_wasm`
|
||||
- persistent SQLite mode: `sqlite-in-disk`
|
||||
|
||||
Older docs or integrations may refer to `sqlite-disk`, `server`, or `antibot_wasm`; those names are stale for the current codebase.
|
||||
+365
@@ -0,0 +1,365 @@
|
||||
# ChronoSeal Testing Strategy and Suite
|
||||
|
||||
This document describes the testing strategy for ChronoSeal and summarizes the test coverage included in v0.6.1.
|
||||
|
||||
ChronoSeal is a security-focused system. Testing therefore prioritizes cryptographic correctness, deterministic execution, protocol integrity, and resistance to replay or tampering rather than simple line coverage.
|
||||
|
||||
## Overview
|
||||
|
||||
As of v0.6.1, the ChronoSeal workspace contains:
|
||||
|
||||
| Crate | Tests |
|
||||
| ------------------- | -----: |
|
||||
| `chronoseal-server` | 30 |
|
||||
| `chronoseal-wasm` | 24 |
|
||||
| `shared` | 35 |
|
||||
| **Total** | **89** |
|
||||
|
||||
All tests pass successfully on the reference development environment.
|
||||
|
||||
## Testing Philosophy
|
||||
|
||||
ChronoSeal testing focuses on:
|
||||
|
||||
1. Cryptographic correctness
|
||||
2. Deterministic server ↔ WASM parity
|
||||
3. Replay and tampering resistance
|
||||
4. Mutation engine integrity
|
||||
5. Negative-path validation
|
||||
6. Storage reliability
|
||||
7. Performance regression detection
|
||||
|
||||
Particular emphasis is placed on ensuring that browser-side WASM execution produces identical results to server-side validation.
|
||||
|
||||
---
|
||||
|
||||
# Server Test Coverage (`chronoseal-server`)
|
||||
|
||||
The server crate contains 30 tests covering configuration, runtime initialization, session lifecycle management, heartbeat validation, rate limiting, and behavioral trust checks.
|
||||
|
||||
## Configuration
|
||||
|
||||
Configuration tests verify:
|
||||
|
||||
* database type parsing
|
||||
* TOML configuration loading
|
||||
* default value handling
|
||||
* command-line override behavior
|
||||
|
||||
Examples:
|
||||
|
||||
```text
|
||||
test_apply_run_args_overrides_db_type
|
||||
test_default_db_type_is_sqlite_in_memory
|
||||
test_toml_parses_db_type_kebab_case
|
||||
```
|
||||
|
||||
## Runtime Initialization
|
||||
|
||||
Backend initialization tests verify:
|
||||
|
||||
* SQLite in-memory mode
|
||||
* SQLite disk-backed mode
|
||||
* Valkey compatibility mode
|
||||
|
||||
Examples:
|
||||
|
||||
```text
|
||||
test_init_db_pool_sqlite_in_memory
|
||||
test_init_db_pool_sqlite_in_disk
|
||||
test_init_db_pool_valkey_compat_mode
|
||||
```
|
||||
|
||||
## Session Lifecycle and Security
|
||||
|
||||
Session tests validate:
|
||||
|
||||
* public key validation
|
||||
* session expiration
|
||||
* replay attack prevention
|
||||
* mutation step enforcement
|
||||
* commitment verification
|
||||
* long-running deterministic parity
|
||||
|
||||
Examples:
|
||||
|
||||
```text
|
||||
test_create_session_rejects_invalid_public_key_length
|
||||
test_expired_session_is_rejected
|
||||
test_replay_attack_is_rejected
|
||||
test_mutation_step_mismatch_is_rejected
|
||||
test_mutation_commitment_tamper_is_rejected
|
||||
test_session_lifecycle_and_verification
|
||||
test_deterministic_server_client_parity_across_many_heartbeats
|
||||
```
|
||||
|
||||
## Heartbeat Validation
|
||||
|
||||
Heartbeat tests verify:
|
||||
|
||||
* successful state advancement
|
||||
* silent rejection behavior
|
||||
* rate limiting
|
||||
|
||||
Examples:
|
||||
|
||||
```text
|
||||
test_handler_success_returns_next_mutation_fields
|
||||
test_handler_tampered_commitment_is_silent_failure
|
||||
test_handler_rate_limit_returns_no_mutation_data
|
||||
```
|
||||
|
||||
## Behavioral Trust Validation
|
||||
|
||||
Trust checks verify:
|
||||
|
||||
* minimum mouse activity
|
||||
* minimum distance traveled
|
||||
* pause detection
|
||||
* speed thresholds
|
||||
* optional activity requirements
|
||||
|
||||
Examples:
|
||||
|
||||
```text
|
||||
test_validate_mouse_success
|
||||
test_validate_mouse_insufficient_events
|
||||
test_validate_mouse_insufficient_distance
|
||||
test_validate_mouse_too_fast
|
||||
test_validate_mouse_no_pauses
|
||||
```
|
||||
|
||||
## Rate Limiting
|
||||
|
||||
Examples:
|
||||
|
||||
```text
|
||||
test_rate_limiter
|
||||
test_rate_limiter_eviction
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# WASM Test Coverage (`chronoseal-wasm`)
|
||||
|
||||
The WASM crate contains 24 tests covering both the virtual machine and browser-side mutation lifecycle.
|
||||
|
||||
## Virtual Machine
|
||||
|
||||
Opcode correctness is validated for:
|
||||
|
||||
* ADD
|
||||
* SUB
|
||||
* MUL
|
||||
* XOR
|
||||
* AND
|
||||
* OR
|
||||
* NOT
|
||||
* HASH
|
||||
* ROT
|
||||
* PUSH
|
||||
|
||||
Edge cases include:
|
||||
|
||||
* stack underflow
|
||||
* truncated instructions
|
||||
* wrapping arithmetic
|
||||
|
||||
Examples:
|
||||
|
||||
```text
|
||||
test_add
|
||||
test_add_wrapping
|
||||
test_sub
|
||||
test_sub_wrapping
|
||||
test_mul
|
||||
test_hash
|
||||
test_underflow_binary
|
||||
test_underflow_unary
|
||||
test_incomplete_push
|
||||
```
|
||||
|
||||
## Browser Mutation Lifecycle
|
||||
|
||||
The browser runtime tests:
|
||||
|
||||
* gene initialization
|
||||
* mutation preview
|
||||
* mutation commit
|
||||
* mutation discard
|
||||
* commitment parity
|
||||
|
||||
Examples:
|
||||
|
||||
```text
|
||||
test_init_gene_state_success
|
||||
test_init_gene_state_rejects_zero
|
||||
test_preview_commitment_matches_shared_engine
|
||||
test_commit_applies_preview
|
||||
test_discard_preview_keeps_committed_state
|
||||
```
|
||||
|
||||
## Deterministic Parity
|
||||
|
||||
Examples:
|
||||
|
||||
```text
|
||||
test_table_driven_parity_across_many_generated_orders
|
||||
```
|
||||
|
||||
These tests ensure browser-generated commitments remain consistent with server expectations.
|
||||
|
||||
---
|
||||
|
||||
# Shared Crate Coverage (`shared`)
|
||||
|
||||
The shared crate contains 35 tests and represents the core security-critical logic of ChronoSeal.
|
||||
|
||||
This crate receives the heaviest protocol-focused testing because it is shared by both server and WASM runtimes.
|
||||
|
||||
## Synthetic Gene Engine
|
||||
|
||||
Gene state tests validate:
|
||||
|
||||
* initialization rules
|
||||
* environment encoding
|
||||
* environment decoding
|
||||
* commitment generation
|
||||
* quantity management
|
||||
|
||||
Examples:
|
||||
|
||||
```text
|
||||
test_new_state_with_default_size
|
||||
test_new_state_rejects_invalid_sizes
|
||||
test_commitment_changes_when_gene_or_environment_changes
|
||||
test_encode_decode_environment_roundtrip
|
||||
test_table_driven_randomized_environment_roundtrip
|
||||
```
|
||||
|
||||
## Mutation Engine
|
||||
|
||||
Mutation engine tests verify:
|
||||
|
||||
* deterministic execution
|
||||
* mutation chains
|
||||
* opcode correctness
|
||||
* stack handling
|
||||
* instruction validation
|
||||
|
||||
Examples:
|
||||
|
||||
```text
|
||||
test_mutation_chain
|
||||
test_opcode_insert
|
||||
test_opcode_delete
|
||||
test_opcode_mutate_point
|
||||
test_opcode_apply_mutagen
|
||||
test_opcode_finalize_gene_hash
|
||||
```
|
||||
|
||||
## Validation and Hardening
|
||||
|
||||
Defensive validation tests include:
|
||||
|
||||
```text
|
||||
test_rejects_stack_underflow
|
||||
test_rejects_truncated_instruction
|
||||
test_rejects_unknown_opcode
|
||||
test_zero_length_gene_is_rejected
|
||||
```
|
||||
|
||||
## Deterministic Server ↔ WASM Parity
|
||||
|
||||
These are among the most important tests in the project:
|
||||
|
||||
```text
|
||||
test_server_client_parity_across_random_orders
|
||||
test_generate_order_is_deterministic_for_seeded_rng
|
||||
test_invalid_positions_wrap_deterministically
|
||||
```
|
||||
|
||||
## Fuzz and Regression Testing
|
||||
|
||||
Examples:
|
||||
|
||||
```text
|
||||
test_fuzz_style_random_program_bytes_do_not_diverge
|
||||
test_performance_smoke_mutation_execution
|
||||
```
|
||||
|
||||
These tests help detect behavioral divergence and unintended performance regressions.
|
||||
|
||||
---
|
||||
|
||||
# Running the Test Suite
|
||||
|
||||
Run the full workspace:
|
||||
|
||||
```bash
|
||||
cargo test --workspace
|
||||
```
|
||||
|
||||
Run individual crates:
|
||||
|
||||
```bash
|
||||
cargo test -p chronoseal-server
|
||||
cargo test -p chronoseal-wasm
|
||||
cargo test -p shared
|
||||
```
|
||||
|
||||
Show test output:
|
||||
|
||||
```bash
|
||||
cargo test -- --nocapture
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Critical Security Tests
|
||||
|
||||
The following tests protect core ChronoSeal security guarantees:
|
||||
|
||||
```text
|
||||
test_mutation_commitment_tamper_is_rejected
|
||||
test_replay_attack_is_rejected
|
||||
test_handler_tampered_commitment_is_silent_failure
|
||||
test_server_client_parity_across_random_orders
|
||||
test_deterministic_server_client_parity_across_many_heartbeats
|
||||
test_fuzz_style_random_program_bytes_do_not_diverge
|
||||
```
|
||||
|
||||
Any failure in these areas should be treated as a release-blocking issue.
|
||||
|
||||
---
|
||||
|
||||
# Future Improvements
|
||||
|
||||
Planned enhancements include:
|
||||
|
||||
* property-based testing using `proptest`
|
||||
* browser-driven end-to-end integration tests
|
||||
* Valkey concurrency testing
|
||||
* automated benchmark execution
|
||||
* expanded mutation-engine fuzzing
|
||||
* CI-enforced performance regression thresholds
|
||||
|
||||
---
|
||||
|
||||
# Conclusion
|
||||
|
||||
ChronoSeal's testing strategy is centered on preserving deterministic behavior, cryptographic correctness, and protocol integrity.
|
||||
|
||||
The current suite of 89 tests provides broad coverage across:
|
||||
|
||||
* session security
|
||||
* heartbeat validation
|
||||
* mutation engine correctness
|
||||
* deterministic server/WASM parity
|
||||
* trust validation
|
||||
* storage abstraction
|
||||
* replay resistance
|
||||
|
||||
As ChronoSeal evolves, expanding and strengthening this test suite remains a core project priority.
|
||||
|
||||
**Last Updated:** May 2026 (v0.6.1)
|
||||
+184
-149
@@ -1,205 +1,240 @@
|
||||
# ChronoSeal — Threat Model
|
||||
# ChronoSeal Threat Model
|
||||
|
||||
## Purpose
|
||||
ChronoSeal is a cost-raising browser attestation layer. It makes replay, stale state reuse, and incomplete automation more expensive by requiring signed, continuous, deterministic browser-side state progression.
|
||||
|
||||
This document defines what ChronoSeal is designed to protect against, what
|
||||
it explicitly does not protect against, and the reasoning behind each
|
||||
design decision in security terms.
|
||||
It is not a perfect bot blocker, CAPTCHA replacement, hardware attestation system, fraud engine, or identity provider.
|
||||
|
||||
ChronoSeal is a **cost-raising mechanism**. It does not claim to make
|
||||
automated access impossible. It makes automated access expensive, complex
|
||||
to maintain, and operationally fragile at scale.
|
||||
## Security Objectives
|
||||
|
||||
---
|
||||
ChronoSeal aims to:
|
||||
|
||||
## Assets Being Protected
|
||||
- reject stale or replayed heartbeat payloads
|
||||
- reject heartbeats that do not maintain the server-issued mutation sequence
|
||||
- bind heartbeat payloads to a browser-local Ed25519 session key
|
||||
- make basic HTTP clients insufficient
|
||||
- make browser automation maintain multiple synchronized state channels
|
||||
- avoid detailed rejection feedback
|
||||
- preserve privacy by avoiding persistent user identity state
|
||||
|
||||
| Asset | Description |
|
||||
## Protected Assets
|
||||
|
||||
| Asset | Protection focus |
|
||||
|---|---|
|
||||
| Web page content | HTML, rendered data, scraped text |
|
||||
| API responses | JSON endpoints that serve structured data |
|
||||
| Server compute | CPU and bandwidth consumed by automated clients |
|
||||
| Rate-limited resources | Endpoints with per-user quotas |
|
||||
| Behavioral analytics | Metrics polluted by bot traffic |
|
||||
| Protected page/API access | Require live attestation before allowing continued access |
|
||||
| Session continuity | Ensure each accepted heartbeat advances from the last accepted state |
|
||||
| Server compute | Rate-limit and reject invalid clients without expensive application work |
|
||||
| Protocol state | Protect hash-chain, salt, and mutation progression |
|
||||
| User privacy | Avoid long-term tracking and detailed failure disclosure |
|
||||
|
||||
---
|
||||
## Trust Assumptions
|
||||
|
||||
## Attacker Profiles
|
||||
ChronoSeal assumes:
|
||||
|
||||
### Level 1 — Script Kiddie / Commodity Scraper
|
||||
- the server host and daemon process are trusted
|
||||
- storage is trusted for session continuity
|
||||
- TLS protects traffic in production
|
||||
- browser clients can run JavaScript and WASM
|
||||
- operators configure reverse proxy, filesystem permissions, and logs appropriately
|
||||
|
||||
**Tools:** `curl`, `requests`, `scrapy`, simple HTTP clients.
|
||||
**Capability:** No browser environment. Cannot execute JavaScript or WASM.
|
||||
**ChronoSeal response:** Session never initialises. No `session_id` is ever
|
||||
presented to `/hb`. Content gated behind session validation is never served.
|
||||
ChronoSeal does not assume:
|
||||
|
||||
### Level 2 — Headless Browser Operator
|
||||
- the browser is honest
|
||||
- WASM is a secure enclave
|
||||
- mouse data proves human presence
|
||||
- fingerprint values are unforgeable
|
||||
- attackers cannot run a full browser
|
||||
|
||||
**Tools:** Playwright, Puppeteer, Selenium, undetected-chromedriver.
|
||||
**Capability:** Full browser environment. Can execute JavaScript and WASM.
|
||||
Cannot easily synthesise realistic mouse entropy or maintain hash chain state
|
||||
across concurrent sessions.
|
||||
**ChronoSeal response:** Mouse entropy validation rejects absent or synthetic
|
||||
movement. Hash chain requires per-session state synchronisation. Scaling to
|
||||
hundreds of concurrent sessions requires proportional infrastructure.
|
||||
## Attacker Levels
|
||||
|
||||
### Level 3 — Stealth Automation
|
||||
### Level 1: Commodity HTTP Client
|
||||
|
||||
**Tools:** Puppeteer Stealth, rebrowser-patches, custom CDP clients with
|
||||
evasion patches.
|
||||
**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.
|
||||
Examples:
|
||||
|
||||
### Level 4 — Sophisticated Adversary
|
||||
- `curl`
|
||||
- `requests`
|
||||
- scraper scripts without browser or WASM execution
|
||||
|
||||
**Tools:** Full browser farm with real input devices, WASM reverse engineering,
|
||||
custom chain maintenance infrastructure.
|
||||
**Capability:** Can pass all current ChronoSeal checks given sufficient
|
||||
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.
|
||||
Expected result:
|
||||
|
||||
---
|
||||
- cannot produce valid signatures
|
||||
- cannot maintain hash-chain state
|
||||
- cannot execute mutation preview
|
||||
- cannot produce accepted heartbeats
|
||||
|
||||
### Level 2: Basic Headless Browser
|
||||
|
||||
Examples:
|
||||
|
||||
- Playwright
|
||||
- Puppeteer
|
||||
- Selenium
|
||||
|
||||
Expected result:
|
||||
|
||||
- can load JavaScript and WASM
|
||||
- must preserve keypair, hash chain, salt, VM, and mutation state
|
||||
- must generate plausible timing and mouse event windows
|
||||
- silent rejection complicates debugging and scaling
|
||||
|
||||
### Level 3: Stealth Automation
|
||||
|
||||
Examples:
|
||||
|
||||
- patched browser runtime
|
||||
- synthetic event generation
|
||||
- custom protocol client with WASM or Rust reimplementation
|
||||
|
||||
Expected result:
|
||||
|
||||
- can attempt full protocol implementation
|
||||
- must still match canonical signing, hash progression, mutation parity, and timing
|
||||
- must handle changing server-issued mutation programs
|
||||
- receives limited failure feedback
|
||||
|
||||
### Level 4: Resourced Browser Farm
|
||||
|
||||
Examples:
|
||||
|
||||
- real browsers
|
||||
- realistic input devices
|
||||
- human-assisted workflows
|
||||
- distributed session management
|
||||
|
||||
Expected result:
|
||||
|
||||
- ChronoSeal raises cost and complexity
|
||||
- ChronoSeal does not claim complete prevention
|
||||
- additional application-level controls are required
|
||||
|
||||
## Attack Vectors and Mitigations
|
||||
|
||||
### Replay Attack
|
||||
### Replay
|
||||
|
||||
**Attack:** Capture a valid heartbeat payload and retransmit it.
|
||||
**Mitigation:**
|
||||
- Timestamp window (±30 seconds): replayed payloads are rejected after 30s.
|
||||
- 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
|
||||
matches after one successful heartbeat has advanced the chain.
|
||||
Attack: resend a previously accepted heartbeat.
|
||||
|
||||
Mitigations:
|
||||
|
||||
- stored `last_hash` must match request `prev_hash`
|
||||
- accepted heartbeats rotate salt
|
||||
- mutation step advances after acceptance
|
||||
- timestamp drift is bounded
|
||||
|
||||
### Signature Forgery
|
||||
|
||||
**Attack:** Construct a valid-looking heartbeat payload 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.
|
||||
Attack: submit a heartbeat without the browser session private key.
|
||||
|
||||
### Key Extraction
|
||||
Mitigations:
|
||||
|
||||
**Attack:** Inspect WASM linear memory to extract the private signing key.
|
||||
**Mitigation:** The key is stored in a Rust `thread_local! { RefCell<Option<SigningKey>> }`.
|
||||
It has no exported symbol and is not referenced by any exported WASM function
|
||||
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.
|
||||
- Ed25519 signature over canonical payload
|
||||
- public key registered during `/init`
|
||||
- signature verified on every heartbeat
|
||||
- signature covers mutation step and gene commitment
|
||||
|
||||
### Hash Chain Forgery
|
||||
### Hash-Chain Desynchronization
|
||||
|
||||
**Attack:** Compute a valid `H(n)` without the server-side salt.
|
||||
**Mitigation:** Each chain link incorporates `saltₙ₋₁`, which is a 16-byte
|
||||
random value known only to the server and returned (once) in the heartbeat
|
||||
response. An attacker cannot compute `H(n+1)` without first receiving
|
||||
`saltₙ` from a successful heartbeat response, which requires a valid signature
|
||||
and all other checks to pass.
|
||||
Attack: submit a heartbeat from stale client state.
|
||||
|
||||
### Session Hijacking
|
||||
Mitigations:
|
||||
|
||||
**Attack:** Steal a `session_id` and use it from a different client.
|
||||
**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.
|
||||
- server compares request `prev_hash` to stored `last_hash`
|
||||
- server computes the next hash only after all validation passes
|
||||
- rejected heartbeats do not advance server state
|
||||
|
||||
### Enumeration of Validation Rules
|
||||
### Mutation Tampering
|
||||
|
||||
**Attack:** Send malformed heartbeats and analyse error responses to map
|
||||
validation logic.
|
||||
**Mitigation:** All failure paths return `{"status":"ok"}` with no `next_salt`.
|
||||
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.
|
||||
Attack: forge or skip synthetic gene mutations.
|
||||
|
||||
### DoS via Session Flooding
|
||||
Mitigations:
|
||||
|
||||
**Attack:** Open thousands of sessions to exhaust the rate limiter's HashMap
|
||||
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.
|
||||
- server stores the pending mutation program
|
||||
- request must include the expected `mutation_step`
|
||||
- server applies the mutation independently
|
||||
- commitment includes candidate gene state, `session_id`, and step
|
||||
- mismatch causes silent rejection
|
||||
|
||||
### Clock Manipulation
|
||||
### Session Identifier Theft
|
||||
|
||||
**Attack:** Manipulate the client's `Date.now()` to bypass the timestamp
|
||||
window.
|
||||
**Mitigation:** The timestamp is included in the signed payload. Manipulating
|
||||
it requires also forging the signature. The server validates against its own
|
||||
clock — client-side clock manipulation cannot help without the private key.
|
||||
Attack: reuse a stolen `session_id`.
|
||||
|
||||
### Synthetic Mouse Events
|
||||
Mitigations:
|
||||
|
||||
**Attack:** Inject programmatic `mousemove` events via `dispatchEvent` or
|
||||
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.
|
||||
- `session_id` alone is insufficient
|
||||
- attacker also needs current private key, hash state, salt, mutation step, and mutation state
|
||||
- stale attempts fail after the real session advances
|
||||
|
||||
---
|
||||
### Failure Oracle Probing
|
||||
|
||||
## What ChronoSeal Does Not Protect Against
|
||||
Attack: send malformed requests and inspect responses to infer validation rules.
|
||||
|
||||
| Limitation | Explanation |
|
||||
|---|---|
|
||||
| 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. |
|
||||
Mitigations:
|
||||
|
||||
---
|
||||
- heartbeat semantic failures return `200 OK` with `{"status":"ok"}`
|
||||
- accepted heartbeats are distinguished only by next-state fields
|
||||
- detailed validation errors are not returned to the client
|
||||
|
||||
## Operational Security Notes
|
||||
### Storage Tampering
|
||||
|
||||
### Log Level
|
||||
Attack: alter persisted session state.
|
||||
|
||||
Do not run with `RUST_LOG=debug` in production. The debug log includes
|
||||
`session_id` values, which are sensitive identifiers. Use `warn` or `info`.
|
||||
Mitigations:
|
||||
|
||||
### CORS Policy
|
||||
- run the daemon under a dedicated user
|
||||
- restrict SQLite database permissions
|
||||
- protect Valkey behind trusted network boundaries
|
||||
- use normal host hardening and backups where persistence matters
|
||||
|
||||
The default `CorsLayer::permissive()` is suitable for development only.
|
||||
In production, restrict allowed origins to your own domain:
|
||||
Storage is trusted. If an attacker can modify storage, they can affect session continuity.
|
||||
|
||||
```rust
|
||||
CorsLayer::new()
|
||||
.allow_origin("https://your.domain.com".parse::<HeaderValue>().unwrap())
|
||||
.allow_methods([Method::POST])
|
||||
.allow_headers([header::CONTENT_TYPE])
|
||||
```
|
||||
## Behavioral Checks
|
||||
|
||||
### TLS
|
||||
ChronoSeal validates:
|
||||
|
||||
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.
|
||||
- minimum event count
|
||||
- minimum movement distance
|
||||
- maximum average speed
|
||||
- pause count
|
||||
- timestamp drift
|
||||
- basic fingerprint field ranges
|
||||
|
||||
### In-Memory SQLite
|
||||
These checks are cost signals. They are not proof of humanity and should not be the only security layer for high-risk actions.
|
||||
|
||||
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.
|
||||
## Privacy Constraints
|
||||
|
||||
---
|
||||
ChronoSeal intentionally avoids:
|
||||
|
||||
## Security Disclosure
|
||||
- persistent user identifiers
|
||||
- browser history collection
|
||||
- device fingerprint databases
|
||||
- cross-session identity graphs
|
||||
- long-term behavioral profiles
|
||||
|
||||
See [SECURITY.md](../SECURITY.md) for the vulnerability disclosure policy
|
||||
and contact details.
|
||||
Session data is short-lived by default. Persistent storage is operator-selected through `sqlite-in-disk` or `valkey`.
|
||||
|
||||
## Limitations
|
||||
|
||||
ChronoSeal does not protect against:
|
||||
|
||||
- real users intentionally automating or abusing access
|
||||
- complete browser farms with realistic input
|
||||
- compromised server hosts
|
||||
- tampered storage
|
||||
- server-side application vulnerabilities
|
||||
- credential theft outside ChronoSeal
|
||||
- policy decisions that require identity, risk scoring, or business context
|
||||
|
||||
## Operational Security
|
||||
|
||||
Recommended:
|
||||
|
||||
- serve all traffic over HTTPS
|
||||
- keep `/init` and `/hb` same-origin with protected content when possible
|
||||
- run behind a reverse proxy
|
||||
- keep debug logs disabled in production
|
||||
- protect storage and log directories
|
||||
- monitor health and metrics
|
||||
- use `sqlite-in-memory` for ephemeral sessions
|
||||
- use `sqlite-in-disk` or `valkey` only when persistence is required
|
||||
|
||||
## Disclosure
|
||||
|
||||
See [../SECURITY.md](../SECURITY.md) for the vulnerability disclosure policy.
|
||||
+102
-240
@@ -1,301 +1,163 @@
|
||||
# ChronoSeal — WASM Build Guide
|
||||
# ChronoSeal WASM Build Guide
|
||||
|
||||
## Overview
|
||||
ChronoSeal uses a Rust-generated WASM package for browser-side attestation. The package is built from `wasm/` and copied into `frontend/pkg`.
|
||||
|
||||
The client-side cryptographic core of ChronoSeal is written in Rust and
|
||||
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.
|
||||
## Responsibilities
|
||||
|
||||
The import line in `heartbeat.js`:
|
||||
The WASM runtime:
|
||||
|
||||
```js
|
||||
import init, { generate_keypair, sign_message, compute_next_hash, run_program }
|
||||
from './pkg/antibot_wasm.js';
|
||||
```
|
||||
- generates a browser-local Ed25519 keypair
|
||||
- signs canonical heartbeat payloads
|
||||
- computes Blake3 hash-chain progression
|
||||
- executes server-issued VM opcode programs
|
||||
- initializes synthetic gene state
|
||||
- previews gene mutation commitments
|
||||
- commits or discards preview state after heartbeat response
|
||||
|
||||
`./pkg/antibot_wasm.js` is a **generated file**. It does not exist in the
|
||||
repository and must be produced by building the `wasm/` crate before running
|
||||
the server.
|
||||
The WASM runtime is not treated as a secure enclave. The server independently recomputes deterministic state.
|
||||
|
||||
---
|
||||
|
||||
## How the WASM Module is Built
|
||||
|
||||
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
|
||||
## Requirements
|
||||
|
||||
```bash
|
||||
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
|
||||
```
|
||||
|
||||
Or via the installer script:
|
||||
|
||||
```bash
|
||||
curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh
|
||||
```
|
||||
|
||||
Verify:
|
||||
|
||||
```bash
|
||||
wasm-pack --version
|
||||
# wasm-pack 0.13.x
|
||||
```
|
||||
|
||||
### 3. Build the WASM module
|
||||
## Build
|
||||
|
||||
From the project root:
|
||||
From the repository root:
|
||||
|
||||
```bash
|
||||
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
|
||||
mv wasm/pkg frontend/pkg
|
||||
```
|
||||
|
||||
The frontend expects the WASM module at `frontend/pkg/antibot_wasm.js`
|
||||
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.
|
||||
`--target web` emits native ES modules compatible with the static frontend.
|
||||
|
||||
---
|
||||
Development build:
|
||||
|
||||
## Using the Build Script
|
||||
```bash
|
||||
wasm-pack build wasm --target web
|
||||
rm -rf frontend/pkg
|
||||
mv wasm/pkg frontend/pkg
|
||||
```
|
||||
|
||||
The convenience script at `scripts/build.sh` performs all steps in order:
|
||||
Full project build:
|
||||
|
||||
```bash
|
||||
bash scripts/build.sh
|
||||
```
|
||||
|
||||
This builds the WASM module, moves it to `frontend/pkg/`, and then builds
|
||||
the server binary. Run this for a clean full build before deployment.
|
||||
## Output Files
|
||||
|
||||
For development iteration where you are only changing Rust WASM code:
|
||||
The package name comes from the crate name `chronoseal-wasm`, so generated files use the `chronoseal_wasm` prefix.
|
||||
|
||||
```bash
|
||||
wasm-pack build wasm --target web # (omit --release for speed)
|
||||
rm -rf frontend/pkg && mv wasm/pkg frontend/pkg
|
||||
```
|
||||
Expected `frontend/pkg/` contents include:
|
||||
|
||||
For development where you are only changing server code:
|
||||
- `chronoseal_wasm.js`
|
||||
- `chronoseal_wasm_bg.wasm`
|
||||
- `chronoseal_wasm.d.ts`
|
||||
- `package.json`
|
||||
|
||||
```bash
|
||||
cargo build -p server
|
||||
```
|
||||
Generated files in `wasm/pkg/` and `frontend/pkg/` are build artifacts and should be regenerated during release.
|
||||
|
||||
---
|
||||
|
||||
## How `heartbeat.js` Loads the Module
|
||||
|
||||
`heartbeat.js` uses a standard ES module dynamic import pattern:
|
||||
## Browser Import
|
||||
|
||||
```js
|
||||
import init, { generate_keypair, sign_message, compute_next_hash, run_program }
|
||||
from './pkg/antibot_wasm.js';
|
||||
|
||||
export async function initHeartbeat() {
|
||||
// 1. Fetch and instantiate the .wasm binary
|
||||
await init();
|
||||
|
||||
// 2. Generate keypair — private key stored in WASM memory only
|
||||
const pubKeyHex = generate_keypair();
|
||||
|
||||
// 3. Send public key to server, receive session_id and chain seed
|
||||
// ...
|
||||
}
|
||||
import init, {
|
||||
generate_keypair,
|
||||
get_public_key,
|
||||
sign_message,
|
||||
compute_next_hash,
|
||||
run_program,
|
||||
init_gene_state,
|
||||
preview_gene_commitment,
|
||||
commit_gene_preview,
|
||||
discard_gene_preview,
|
||||
current_gene_commitment
|
||||
} from './pkg/chronoseal_wasm.js';
|
||||
```
|
||||
|
||||
`init()` is the default export from `antibot_wasm.js`. It fetches
|
||||
`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.
|
||||
Call `await init()` before using any exported function.
|
||||
|
||||
The `init()` call must complete before any other WASM function is called.
|
||||
Calling `sign_message()` or `compute_next_hash()` before `await init()`
|
||||
returns will produce an empty string (the module is not yet instantiated).
|
||||
## Exported Functions
|
||||
|
||||
---
|
||||
| Function | Signature | Failure value |
|
||||
|---|---|---|
|
||||
| `generate_keypair()` | `() -> string` | `""` only on unexpected failure |
|
||||
| `get_public_key()` | `() -> string` | `""` if no keypair exists |
|
||||
| `sign_message(msg)` | `(string) -> string` | `""` if no keypair exists or signing fails |
|
||||
| `compute_next_hash(prev, ts, entropy, stack, salt)` | `(string, u64, string, string, string) -> string` | panic/error path should be avoided by valid inputs |
|
||||
| `run_program(b64)` | `(string) -> JsValue` | returns empty/default stack state on invalid execution path |
|
||||
| `init_gene_state(gene_size)` | `(u32) -> bool` | `false` |
|
||||
| `preview_gene_commitment(order_b64, session_id, mutation_step, rounds)` | `(string, string, u64, u8) -> string` | `""` |
|
||||
| `commit_gene_preview()` | `() -> bool` | `false` |
|
||||
| `discard_gene_preview()` | `() -> void` | none |
|
||||
| `current_gene_commitment(session_id, mutation_step)` | `(string, u64) -> string` | `""` if no committed state exists |
|
||||
|
||||
## Serving the WASM Binary
|
||||
`rounds = 0` in `preview_gene_commitment` selects the shared default mutation round count.
|
||||
|
||||
Browsers require WASM files to be served with the correct MIME type:
|
||||
## Mutation State Lifecycle
|
||||
|
||||
The browser must keep two gene states:
|
||||
|
||||
- committed state: the last accepted state
|
||||
- preview state: candidate state for the heartbeat currently being sent
|
||||
|
||||
Expected sequence:
|
||||
|
||||
1. Call `init_gene_state(gene_size)` after `/init`.
|
||||
2. Call `preview_gene_commitment(order_b64, session_id, mutation_step, rounds)` before signing `/hb`.
|
||||
3. Include the returned commitment and mutation step in the signed heartbeat.
|
||||
4. If the response contains next-state fields, call `commit_gene_preview()`.
|
||||
5. If the heartbeat is rejected or errors, call `discard_gene_preview()`.
|
||||
|
||||
Never commit preview state before the server accepts the heartbeat.
|
||||
|
||||
## Hash-Chain Ordering
|
||||
|
||||
After an accepted heartbeat, compute the next local hash with the salt that was active when the heartbeat was sent. Then replace the local salt with `next_salt`.
|
||||
|
||||
Correct order:
|
||||
|
||||
```js
|
||||
const sentSalt = currentSalt;
|
||||
currentSalt = resp.next_salt;
|
||||
prevHash = compute_next_hash(prevHash, timestamp, entropyJson, stackStateJson, sentSalt);
|
||||
```
|
||||
|
||||
This mirrors the server, which computes and stores the new hash before rotating to the next salt.
|
||||
|
||||
## Serving WASM
|
||||
|
||||
The `.wasm` file must be served with:
|
||||
|
||||
```text
|
||||
Content-Type: application/wasm
|
||||
```
|
||||
|
||||
Most web servers set this automatically for `.wasm` files. If you see the
|
||||
error:
|
||||
ChronoSeal's built-in static file service handles this for normal deployments.
|
||||
|
||||
```
|
||||
WebAssembly.instantiate(): Response has unsupported MIME type
|
||||
```
|
||||
## Validation
|
||||
|
||||
Add the MIME type to your server configuration:
|
||||
|
||||
**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:
|
||||
Recommended checks after WASM changes:
|
||||
|
||||
```bash
|
||||
rustup target add wasm32-unknown-unknown
|
||||
cargo test --workspace
|
||||
cargo clippy --workspace --all-targets -- -D warnings
|
||||
wasm-pack build wasm --target web
|
||||
```
|
||||
|
||||
### `wasm-pack` not found
|
||||
Then refresh `frontend/pkg`:
|
||||
|
||||
```bash
|
||||
cargo install wasm-pack
|
||||
rm -rf frontend/pkg
|
||||
mv wasm/pkg frontend/pkg
|
||||
```
|
||||
|
||||
### `wasm-opt` not found (warning, not an error)
|
||||
|
||||
wasm-pack prints a warning if `wasm-opt` is not installed. The build still
|
||||
succeeds; the binary is just not size-optimised.
|
||||
|
||||
```bash
|
||||
# On Debian/Ubuntu/Arch
|
||||
sudo apt install binaryen # Debian/Ubuntu
|
||||
sudo pacman -S binaryen # Arch
|
||||
```
|
||||
|
||||
### `antibot_wasm_bg.wasm` fetch fails (404)
|
||||
|
||||
The `.wasm` file is not being served from `frontend/pkg/`. Verify:
|
||||
|
||||
```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,9 +1,27 @@
|
||||
bind = "0.0.0.0:3000"
|
||||
# sqlite-in-memory (default), sqlite-in-disk, valkey (v0.6.0 compatibility mode)
|
||||
|
||||
# Storage backend: sqlite-in-memory (default), sqlite-in-disk, or valkey.
|
||||
db_type = "sqlite-in-memory"
|
||||
|
||||
pid_file = "/run/chronoseal.pid"
|
||||
db_path = "/var/lib/chronoseal/chronoseal.sqlite"
|
||||
frontend_dir = "/usr/share/chronoseal/frontend"
|
||||
log_file = "/var/log/chronoseal/chronoseal.jsonl"
|
||||
# synthetic gene size (1..=65536), default 512
|
||||
|
||||
heartbeat_min_interval_ms = 12000
|
||||
heartbeat_max_interval_ms = 25000
|
||||
expiration_minutes = 30
|
||||
rate_limit_count = 5
|
||||
rate_limit_window_secs = 10
|
||||
max_timestamp_drift_ms = 30000
|
||||
|
||||
min_mouse_total_dist = 10.0
|
||||
max_mouse_avg_speed = 2.0
|
||||
min_pause_count = 1
|
||||
require_mouse_activity = true
|
||||
|
||||
# Synthetic gene size. Current valid range: 1..=4096. Default: 512.
|
||||
gene_size = 512
|
||||
|
||||
# Current valid range: 1..=10. Default: 4.
|
||||
mutation_rounds = 4
|
||||
@@ -30,3 +30,4 @@ hex = "0.4"
|
||||
base64 = "0.22"
|
||||
rand = "0.8"
|
||||
ed25519-dalek = "2"
|
||||
valkey = "0.0.0-alpha5"
|
||||
@@ -5,16 +5,10 @@ pub async fn cleanup_loop(state: Arc<AppState>) {
|
||||
loop {
|
||||
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() {
|
||||
let now = crate::storage::current_time_ms();
|
||||
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");
|
||||
if let Err(err) = state.db_pool.delete_expired_sessions() {
|
||||
tracing::error!("Failed to evict expired sessions: {}", err);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -123,6 +123,10 @@ pub struct RunArgs {
|
||||
/// Optional structured JSON log file.
|
||||
#[arg(long, env = "CHRONOSEAL_LOG_FILE")]
|
||||
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)]
|
||||
|
||||
@@ -46,6 +46,7 @@ pub struct Config {
|
||||
pub min_pause_count: u32,
|
||||
pub require_mouse_activity: bool,
|
||||
pub gene_size: usize,
|
||||
pub mutation_rounds: u8,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
@@ -68,6 +69,7 @@ impl Default for Config {
|
||||
min_pause_count: 1,
|
||||
require_mouse_activity: true,
|
||||
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 {
|
||||
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> {
|
||||
@@ -132,6 +137,11 @@ impl Config {
|
||||
size: self.gene_size,
|
||||
});
|
||||
}
|
||||
if !(1..=shared::constants::MAX_MUTATION_ROUNDS).contains(&self.mutation_rounds) {
|
||||
return Err(ConfigError::InvalidMutationRounds {
|
||||
rounds: self.mutation_rounds,
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -214,6 +224,11 @@ impl Config {
|
||||
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 {
|
||||
size: usize,
|
||||
},
|
||||
InvalidMutationRounds {
|
||||
rounds: u8,
|
||||
},
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ConfigError {
|
||||
@@ -253,6 +271,13 @@ impl std::fmt::Display for ConfigError {
|
||||
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,
|
||||
frontend_dir: None,
|
||||
log_file: None,
|
||||
mutation_rounds: None,
|
||||
};
|
||||
cfg.apply_run_args(&args);
|
||||
assert_eq!(cfg.db_type, DbType::SqliteInDisk);
|
||||
|
||||
@@ -14,6 +14,9 @@ pub enum SessionError {
|
||||
#[error("Database error: {0}")]
|
||||
Database(#[from] rusqlite::Error),
|
||||
|
||||
#[error("Storage error: {0}")]
|
||||
Storage(String),
|
||||
|
||||
#[error("R2D2 pool error: {0}")]
|
||||
Pool(#[from] r2d2::Error),
|
||||
|
||||
@@ -48,6 +51,9 @@ pub enum VerificationError {
|
||||
#[error("Database error: {0}")]
|
||||
Database(#[from] rusqlite::Error),
|
||||
|
||||
#[error("Storage error: {0}")]
|
||||
Storage(String),
|
||||
|
||||
#[error("Hex decoding error: {0}")]
|
||||
Hex(#[from] hex::FromHexError),
|
||||
|
||||
|
||||
@@ -29,22 +29,7 @@ pub async fn handler(
|
||||
}
|
||||
|
||||
let config = state.get_config();
|
||||
let conn = match state.db_pool.get() {
|
||||
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) {
|
||||
match crate::session::verify_heartbeat(&state.db_pool, &config, &payload) {
|
||||
Ok(result) => (
|
||||
StatusCode::OK,
|
||||
Json(HeartbeatResponse {
|
||||
@@ -145,7 +130,11 @@ mod tests {
|
||||
hardware_concurrency: 8,
|
||||
},
|
||||
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(),
|
||||
};
|
||||
sign_request(sk, &mut req);
|
||||
@@ -157,7 +146,7 @@ mod tests {
|
||||
) -> (Arc<AppState>, InitResponse, SigningKey) {
|
||||
let pool = crate::storage::init_pool(Path::new(":memory:")).unwrap();
|
||||
let state = Arc::new(AppState {
|
||||
db_pool: pool,
|
||||
db_pool: pool.clone(),
|
||||
rate_limiter: tokio::sync::Mutex::new(crate::ratelimit::RateLimiter::new()),
|
||||
config: std::sync::RwLock::new(config.clone()),
|
||||
});
|
||||
@@ -165,8 +154,7 @@ mod tests {
|
||||
let mut rng = rand::thread_rng();
|
||||
let sk = SigningKey::generate(&mut rng);
|
||||
let pk_hex = hex::encode(sk.verifying_key().to_bytes());
|
||||
let conn = state.db_pool.get().unwrap();
|
||||
let init = crate::session::create_session(&conn, &config, &pk_hex).unwrap();
|
||||
let init = crate::session::create_session(&pool, &config, &pk_hex).unwrap();
|
||||
(state, init, sk)
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ pub async fn handler(
|
||||
Json(payload): Json<InitRequest>,
|
||||
) -> Result<Json<InitResponse>, SessionError> {
|
||||
let config = state.get_config();
|
||||
let conn = state.db_pool.get()?;
|
||||
let resp = crate::session::create_session(&conn, &config, &payload.public_key)?;
|
||||
let resp = crate::session::create_session(&state.db_pool, &config, &payload.public_key)?;
|
||||
Ok(Json(resp))
|
||||
}
|
||||
+18
-31
@@ -204,14 +204,7 @@ pub fn db_type_report() -> DbTypeReport {
|
||||
}
|
||||
|
||||
fn init_db_pool(config: &Config) -> Result<storage::DbPool, Box<dyn std::error::Error>> {
|
||||
match config.db_type {
|
||||
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:"))
|
||||
}
|
||||
}
|
||||
storage::DbPool::init(config)
|
||||
}
|
||||
|
||||
pub fn probe_health(config: &Config) -> HealthReport {
|
||||
@@ -278,11 +271,9 @@ async fn health_handler() -> impl IntoResponse {
|
||||
async fn stats_handler(
|
||||
axum::extract::State(state): axum::extract::State<Arc<session::AppState>>,
|
||||
) -> Result<Json<StoreStats>, (StatusCode, String)> {
|
||||
let db = state
|
||||
state
|
||||
.db_pool
|
||||
.get()
|
||||
.map_err(|err| (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()))?;
|
||||
storage::stats(&db)
|
||||
.stats()
|
||||
.map(Json)
|
||||
.map_err(|err| (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()))
|
||||
}
|
||||
@@ -290,11 +281,9 @@ async fn stats_handler(
|
||||
async fn metrics_handler(
|
||||
axum::extract::State(state): axum::extract::State<Arc<session::AppState>>,
|
||||
) -> Result<String, (StatusCode, String)> {
|
||||
let db = state
|
||||
state
|
||||
.db_pool
|
||||
.get()
|
||||
.map_err(|err| (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()))?;
|
||||
storage::stats(&db)
|
||||
.stats()
|
||||
.map(|stats| {
|
||||
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",
|
||||
@@ -430,6 +419,7 @@ mod tests {
|
||||
min_pause_count: 0,
|
||||
require_mouse_activity: false,
|
||||
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() {
|
||||
let config = base_config();
|
||||
let pool = init_db_pool(&config).unwrap();
|
||||
let conn = pool.get().unwrap();
|
||||
let count: u64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM sessions", [], |row| row.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(count, 0);
|
||||
let stats = pool.stats().unwrap();
|
||||
assert_eq!(stats.sessions, 0);
|
||||
assert_eq!(stats.expired_sessions, 0);
|
||||
assert_eq!(stats.max_chain_length, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -459,11 +448,10 @@ mod tests {
|
||||
config.db_path = std::path::PathBuf::from("/tmp/chronoseal-db-type-disk.sqlite");
|
||||
let _ = std::fs::remove_file(&config.db_path);
|
||||
let pool = init_db_pool(&config).unwrap();
|
||||
let conn = pool.get().unwrap();
|
||||
let count: u64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM sessions", [], |row| row.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(count, 0);
|
||||
let stats = pool.stats().unwrap();
|
||||
assert_eq!(stats.sessions, 0);
|
||||
assert_eq!(stats.expired_sessions, 0);
|
||||
assert_eq!(stats.max_chain_length, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -471,10 +459,9 @@ mod tests {
|
||||
let mut config = base_config();
|
||||
config.db_type = crate::config::DbType::Valkey;
|
||||
let pool = init_db_pool(&config).unwrap();
|
||||
let conn = pool.get().unwrap();
|
||||
let count: u64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM sessions", [], |row| row.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(count, 0);
|
||||
let stats = pool.stats().unwrap();
|
||||
assert_eq!(stats.sessions, 0);
|
||||
assert_eq!(stats.expired_sessions, 0);
|
||||
assert_eq!(stats.max_chain_length, 0);
|
||||
}
|
||||
}
|
||||
+111
-147
@@ -15,7 +15,6 @@ impl AppState {
|
||||
}
|
||||
|
||||
use crate::{crypto, fingerprint, storage, trust, vm};
|
||||
use rusqlite::params;
|
||||
use shared::{
|
||||
gene::{self, GeneState},
|
||||
protocol::{HeartbeatRequest, InitResponse},
|
||||
@@ -30,7 +29,7 @@ pub struct HeartbeatVerificationResult {
|
||||
}
|
||||
|
||||
pub fn create_session(
|
||||
conn: &rusqlite::Connection,
|
||||
db: &storage::DbPool,
|
||||
config: &crate::config::Config,
|
||||
pub_key_hex: &str,
|
||||
) -> 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_b64 = vm_extensions::encode_order_b64(&initial_mutation);
|
||||
|
||||
conn.execute(
|
||||
"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, 1, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
|
||||
params![
|
||||
session_id,
|
||||
pub_key,
|
||||
salt.to_vec(),
|
||||
initial_hash,
|
||||
now,
|
||||
now,
|
||||
expires_at,
|
||||
gene_state.gene,
|
||||
environment_blob,
|
||||
initial_mutation.program,
|
||||
initial_mutation.step,
|
||||
],
|
||||
)?;
|
||||
let record = storage::SessionRecord {
|
||||
session_id: session_id.clone(),
|
||||
public_key: pub_key,
|
||||
salt: salt.to_vec(),
|
||||
last_hash: initial_hash.clone(),
|
||||
chain_length: 1,
|
||||
created_at: now,
|
||||
last_seen: now,
|
||||
expires_at,
|
||||
gene: gene_state.gene,
|
||||
environment: environment_blob,
|
||||
pending_mutation: initial_mutation.program,
|
||||
pending_mutation_step: initial_mutation.step,
|
||||
};
|
||||
|
||||
db.insert_session(&record)
|
||||
.map_err(|err| crate::errors::SessionError::Storage(err.to_string()))?;
|
||||
|
||||
Ok(InitResponse {
|
||||
session_id,
|
||||
@@ -91,85 +88,52 @@ pub fn create_session(
|
||||
}
|
||||
|
||||
pub fn verify_heartbeat(
|
||||
conn: &rusqlite::Connection,
|
||||
db: &storage::DbPool,
|
||||
config: &crate::config::Config,
|
||||
req: &HeartbeatRequest,
|
||||
) -> Result<HeartbeatVerificationResult, crate::errors::VerificationError> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT public_key, salt, last_hash, expires_at, gene, environment, pending_mutation, pending_mutation_step
|
||||
FROM sessions WHERE session_id = ?1",
|
||||
)?;
|
||||
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 session = db
|
||||
.load_session(&req.session_id)
|
||||
.map_err(|e| crate::errors::VerificationError::Storage(e.to_string()))?;
|
||||
let session = session.ok_or(crate::errors::VerificationError::SessionNotFound)?;
|
||||
|
||||
let now = storage::current_time_ms();
|
||||
if now > expires_at {
|
||||
if now > session.expires_at {
|
||||
return Err(crate::errors::VerificationError::Expired);
|
||||
}
|
||||
|
||||
// 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()))?;
|
||||
|
||||
// 2. Check chain continuity
|
||||
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);
|
||||
}
|
||||
|
||||
// 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 {
|
||||
expected: pending_step,
|
||||
expected: session.pending_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()))?;
|
||||
let server_state = GeneState {
|
||||
gene: gene_blob,
|
||||
gene: session.gene.clone(),
|
||||
environment,
|
||||
};
|
||||
let candidate_state = vm_extensions::apply_program_clone(&server_state, &pending_mutation)
|
||||
.map_err(|e| crate::errors::VerificationError::MutationProgram(e.to_string()))?;
|
||||
let expected_gene_commitment = gene::commitment_hex(&candidate_state);
|
||||
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()))?;
|
||||
let expected_gene_commitment =
|
||||
gene::commitment_hex_with_context(&candidate_state, &req.session_id, req.mutation_step);
|
||||
if req.gene_commitment != expected_gene_commitment {
|
||||
return Err(crate::errors::VerificationError::MutationCommitmentMismatch);
|
||||
}
|
||||
@@ -192,11 +156,11 @@ pub fn verify_heartbeat(
|
||||
req.timestamp,
|
||||
&req.entropy_data,
|
||||
&req.stack_state,
|
||||
&salt,
|
||||
&session.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_b64 = vm_extensions::encode_order_b64(&next_mutation);
|
||||
|
||||
@@ -205,28 +169,22 @@ pub fn verify_heartbeat(
|
||||
let next_environment_blob = gene::encode_environment(&candidate_state.environment)
|
||||
.map_err(|e| crate::errors::VerificationError::GeneState(e.to_string()))?;
|
||||
|
||||
conn.execute(
|
||||
"UPDATE sessions SET
|
||||
last_hash=?1,
|
||||
salt=?2,
|
||||
chain_length=chain_length+1,
|
||||
last_seen=?3,
|
||||
gene=?4,
|
||||
environment=?5,
|
||||
pending_mutation=?6,
|
||||
pending_mutation_step=?7
|
||||
WHERE session_id=?8",
|
||||
params![
|
||||
new_hash,
|
||||
next_salt.to_vec(),
|
||||
now,
|
||||
candidate_state.gene,
|
||||
next_environment_blob,
|
||||
next_mutation.program,
|
||||
next_step,
|
||||
req.session_id
|
||||
],
|
||||
)?;
|
||||
let update_record = storage::SessionRecord {
|
||||
session_id: req.session_id.clone(),
|
||||
public_key: session.public_key,
|
||||
salt: next_salt.to_vec(),
|
||||
last_hash: new_hash.clone(),
|
||||
chain_length: session.chain_length + 1,
|
||||
created_at: session.created_at,
|
||||
last_seen: now,
|
||||
expires_at: session.expires_at,
|
||||
gene: candidate_state.gene,
|
||||
environment: next_environment_blob,
|
||||
pending_mutation: next_mutation.program,
|
||||
pending_mutation_step: next_step,
|
||||
};
|
||||
db.update_session(&update_record)
|
||||
.map_err(|e| crate::errors::VerificationError::Storage(e.to_string()))?;
|
||||
|
||||
Ok(HeartbeatVerificationResult {
|
||||
next_salt_hex,
|
||||
@@ -239,6 +197,7 @@ pub fn verify_heartbeat(
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ed25519_dalek::{Signer, SigningKey};
|
||||
use rusqlite::params;
|
||||
use shared::protocol::{EntropyData, Fingerprint, HeartbeatRequest, MouseEvent, StackState};
|
||||
use std::path::Path;
|
||||
|
||||
@@ -310,13 +269,13 @@ mod tests {
|
||||
}
|
||||
|
||||
fn create_test_session(
|
||||
conn: &rusqlite::Connection,
|
||||
db: &storage::DbPool,
|
||||
config: &crate::config::Config,
|
||||
) -> (InitResponse, SigningKey) {
|
||||
let mut rng = rand::thread_rng();
|
||||
let sk = SigningKey::generate(&mut rng);
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -355,7 +314,11 @@ mod tests {
|
||||
stack_state: stack.clone(),
|
||||
fingerprint: test_fingerprint(),
|
||||
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(),
|
||||
};
|
||||
sign_request(&client.signing_key, &mut req);
|
||||
@@ -382,28 +345,22 @@ mod tests {
|
||||
client.committed_gene_state = candidate_state;
|
||||
}
|
||||
|
||||
fn load_server_gene_state(conn: &rusqlite::Connection, session_id: &str) -> GeneState {
|
||||
let (gene_blob, env_blob): (Vec<u8>, Vec<u8>) = conn
|
||||
.query_row(
|
||||
"SELECT gene, environment FROM sessions WHERE session_id=?1",
|
||||
[session_id],
|
||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||
)
|
||||
.unwrap();
|
||||
fn load_server_gene_state(db: &storage::DbPool, session_id: &str) -> GeneState {
|
||||
let session = db.load_session(session_id).unwrap().unwrap();
|
||||
GeneState {
|
||||
gene: gene_blob,
|
||||
environment: gene::decode_environment(&env_blob).unwrap(),
|
||||
gene: session.gene,
|
||||
environment: gene::decode_environment(&session.environment).unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
fn run_successful_heartbeat(
|
||||
conn: &rusqlite::Connection,
|
||||
db: &storage::DbPool,
|
||||
config: &crate::config::Config,
|
||||
client: &mut SimulatedClient,
|
||||
) -> HeartbeatRequest {
|
||||
let timestamp = storage::current_time_ms();
|
||||
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);
|
||||
req
|
||||
}
|
||||
@@ -411,20 +368,19 @@ mod tests {
|
||||
#[test]
|
||||
fn test_session_lifecycle_and_verification() {
|
||||
let pool = storage::init_pool(Path::new(":memory:")).unwrap();
|
||||
let conn = pool.get().unwrap();
|
||||
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!(!init.mutation_order_b64.is_empty());
|
||||
assert_eq!(init.mutation_step, 1);
|
||||
|
||||
let mut client = client_from_init(&init, signing_key);
|
||||
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.max_chain_length, 6);
|
||||
}
|
||||
@@ -432,14 +388,13 @@ mod tests {
|
||||
#[test]
|
||||
fn test_deterministic_server_client_parity_across_many_heartbeats() {
|
||||
let pool = storage::init_pool(Path::new(":memory:")).unwrap();
|
||||
let conn = pool.get().unwrap();
|
||||
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);
|
||||
|
||||
for _ in 0..12 {
|
||||
run_successful_heartbeat(&conn, &config, &mut client);
|
||||
let server_state = load_server_gene_state(&conn, &client.session_id);
|
||||
run_successful_heartbeat(&pool, &config, &mut client);
|
||||
let server_state = load_server_gene_state(&pool, &client.session_id);
|
||||
assert_eq!(server_state, client.committed_gene_state);
|
||||
}
|
||||
}
|
||||
@@ -447,14 +402,13 @@ mod tests {
|
||||
#[test]
|
||||
fn test_replay_attack_is_rejected() {
|
||||
let pool = storage::init_pool(Path::new(":memory:")).unwrap();
|
||||
let conn = pool.get().unwrap();
|
||||
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 timestamp = storage::current_time_ms();
|
||||
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(
|
||||
&mut client,
|
||||
&req,
|
||||
@@ -464,7 +418,7 @@ mod tests {
|
||||
&result,
|
||||
);
|
||||
|
||||
let replay = verify_heartbeat(&conn, &config, &req);
|
||||
let replay = verify_heartbeat(&pool, &config, &req);
|
||||
assert!(matches!(
|
||||
replay.unwrap_err(),
|
||||
crate::errors::VerificationError::ChainBroken
|
||||
@@ -474,9 +428,8 @@ mod tests {
|
||||
#[test]
|
||||
fn test_mutation_step_mismatch_is_rejected() {
|
||||
let pool = storage::init_pool(Path::new(":memory:")).unwrap();
|
||||
let conn = pool.get().unwrap();
|
||||
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 timestamp = storage::current_time_ms();
|
||||
@@ -484,7 +437,7 @@ mod tests {
|
||||
req.mutation_step += 1;
|
||||
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!(
|
||||
err,
|
||||
crate::errors::VerificationError::MutationStepMismatch { .. }
|
||||
@@ -494,9 +447,8 @@ mod tests {
|
||||
#[test]
|
||||
fn test_mutation_commitment_tamper_is_rejected() {
|
||||
let pool = storage::init_pool(Path::new(":memory:")).unwrap();
|
||||
let conn = pool.get().unwrap();
|
||||
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 timestamp = storage::current_time_ms();
|
||||
@@ -504,7 +456,7 @@ mod tests {
|
||||
req.gene_commitment = "00".repeat(32);
|
||||
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!(
|
||||
err,
|
||||
crate::errors::VerificationError::MutationCommitmentMismatch
|
||||
@@ -514,9 +466,12 @@ mod tests {
|
||||
#[test]
|
||||
fn test_malformed_server_mutation_program_is_rejected() {
|
||||
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 (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);
|
||||
|
||||
conn.execute(
|
||||
@@ -525,9 +480,18 @@ mod tests {
|
||||
)
|
||||
.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 (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::MutationProgram(_)
|
||||
@@ -537,9 +501,12 @@ mod tests {
|
||||
#[test]
|
||||
fn test_expired_session_is_rejected() {
|
||||
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 (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);
|
||||
|
||||
conn.execute(
|
||||
@@ -550,16 +517,15 @@ mod tests {
|
||||
|
||||
let timestamp = storage::current_time_ms();
|
||||
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));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_session_rejects_invalid_public_key_length() {
|
||||
let pool = storage::init_pool(Path::new(":memory:")).unwrap();
|
||||
let conn = pool.get().unwrap();
|
||||
let config = test_config();
|
||||
let err = create_session(&conn, &config, "00ff").unwrap_err();
|
||||
let err = create_session(&pool, &config, "00ff").unwrap_err();
|
||||
assert!(matches!(
|
||||
err,
|
||||
crate::errors::SessionError::InvalidPublicKeyLength
|
||||
@@ -569,19 +535,18 @@ mod tests {
|
||||
#[test]
|
||||
fn test_stale_mutation_step_after_success_is_rejected() {
|
||||
let pool = storage::init_pool(Path::new(":memory:")).unwrap();
|
||||
let conn = pool.get().unwrap();
|
||||
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);
|
||||
|
||||
run_successful_heartbeat(&conn, &config, &mut client);
|
||||
run_successful_heartbeat(&pool, &config, &mut client);
|
||||
|
||||
let timestamp = storage::current_time_ms();
|
||||
let (mut req, _, _, _) = build_request(&client, timestamp);
|
||||
req.mutation_step -= 1;
|
||||
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!(
|
||||
err,
|
||||
crate::errors::VerificationError::MutationStepMismatch { .. }
|
||||
@@ -591,15 +556,14 @@ mod tests {
|
||||
#[test]
|
||||
fn test_repeated_simulation_keeps_server_and_client_commitments_equal() {
|
||||
let pool = storage::init_pool(Path::new(":memory:")).unwrap();
|
||||
let conn = pool.get().unwrap();
|
||||
let mut config = test_config();
|
||||
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);
|
||||
|
||||
for _ in 0..10 {
|
||||
run_successful_heartbeat(&conn, &config, &mut client);
|
||||
let server_state = load_server_gene_state(&conn, &client.session_id);
|
||||
run_successful_heartbeat(&pool, &config, &mut client);
|
||||
let server_state = load_server_gene_state(&pool, &client.session_id);
|
||||
assert_eq!(
|
||||
gene::commitment(&server_state),
|
||||
gene::commitment(&client.committed_gene_state)
|
||||
|
||||
+294
-21
@@ -1,7 +1,9 @@
|
||||
use rusqlite::Connection;
|
||||
use crate::config::Config;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use valkey::Client as ValkeyClient;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StoreStats {
|
||||
@@ -10,9 +12,223 @@ pub struct StoreStats {
|
||||
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>> {
|
||||
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:") {
|
||||
r2d2_sqlite::SqliteConnectionManager::memory()
|
||||
} else {
|
||||
@@ -21,7 +237,6 @@ pub fn init_pool(path: &Path) -> Result<DbPool, Box<dyn std::error::Error>> {
|
||||
}
|
||||
r2d2_sqlite::SqliteConnectionManager::file(path)
|
||||
};
|
||||
|
||||
let pool = r2d2::Pool::new(manager)?;
|
||||
let conn = pool.get()?;
|
||||
init_schema(&conn)?;
|
||||
@@ -89,24 +304,82 @@ fn ensure_column(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn stats(conn: &Connection) -> Result<StoreStats, rusqlite::Error> {
|
||||
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,
|
||||
})
|
||||
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 mut remaining: Vec<String> = Vec::new();
|
||||
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) {
|
||||
if record.expires_at > now {
|
||||
remaining.push(id.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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 {
|
||||
sessions,
|
||||
expired_sessions,
|
||||
max_chain_length,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub fn current_time_ms() -> u64 {
|
||||
|
||||
@@ -47,6 +47,7 @@ pub fn generate_random_program(len_range: std::ops::RangeInclusive<usize>) -> Ve
|
||||
ops
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn execute_mutation_program(
|
||||
state: &mut GeneState,
|
||||
program: &[u8],
|
||||
@@ -54,6 +55,7 @@ pub fn execute_mutation_program(
|
||||
vm_extensions::execute_program(state, program)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn execute_mutation_order(
|
||||
state: &mut GeneState,
|
||||
order: &MutationOrder,
|
||||
|
||||
@@ -11,3 +11,4 @@ hex = "0.4"
|
||||
base64 = "0.22"
|
||||
rand = "0.8"
|
||||
ed25519-dalek = { version = "2", features = ["rand_core"] }
|
||||
tracing = "0.1"
|
||||
@@ -4,3 +4,9 @@ pub const DEFAULT_GENE_SIZE: usize = 512;
|
||||
pub const MAX_GENE_SIZE: usize = 4096;
|
||||
pub const MAX_ENV_RECORDS: usize = 48;
|
||||
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;
|
||||
+14
-1
@@ -158,7 +158,7 @@ pub fn encode_environment(records: &[EnvironmentRecord]) -> Result<Vec<u8>, Gene
|
||||
}
|
||||
|
||||
pub fn decode_environment(blob: &[u8]) -> Result<Vec<EnvironmentRecord>, GeneError> {
|
||||
if blob.len() % 6 != 0 {
|
||||
if !blob.len().is_multiple_of(6) {
|
||||
return Err(GeneError::EnvironmentBlobLengthInvalid { len: blob.len() });
|
||||
}
|
||||
let records_len = blob.len() / 6;
|
||||
@@ -197,6 +197,19 @@ pub fn commitment_hex(state: &GeneState) -> String {
|
||||
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> {
|
||||
if records.len() > MAX_ENV_RECORDS {
|
||||
return Err(GeneError::TooManyEnvironmentRecords { len: records.len() });
|
||||
|
||||
+142
-26
@@ -1,18 +1,22 @@
|
||||
use crate::{
|
||||
constants::{MAX_GENE_SIZE, MAX_MUTATION_PROGRAM_BYTES},
|
||||
constants::{
|
||||
DEFAULT_MUTATION_ROUNDS, HASH_OPCODE_INSTRUCTION_COST, MAX_GENE_SIZE,
|
||||
MAX_MUTATION_INSTRUCTION_BUDGET, MAX_MUTATION_PROGRAM_BYTES, SOFT_CAP_DURATION_MS,
|
||||
},
|
||||
gene::{
|
||||
add_env_quantity, get_env_quantity, sub_env_quantity, validate_state, GeneError, GeneState,
|
||||
},
|
||||
};
|
||||
use rand::Rng;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::Instant;
|
||||
|
||||
// Stack-machine mutation opcodes (v0.6.0).
|
||||
//
|
||||
// NOTE: stack effect notation:
|
||||
// +1 => pushes one u32
|
||||
// -1 => pops one u32
|
||||
// 0 => net-zero (or no stack interaction)
|
||||
// 0 => net-zero (or no stack interaction)
|
||||
//
|
||||
// Security/performance notes:
|
||||
// - All index operands are normalized with modulo to avoid panics.
|
||||
@@ -107,48 +111,63 @@ pub fn generate_order_with_rng<R: Rng + ?Sized>(
|
||||
step: u64,
|
||||
gene_size: usize,
|
||||
) -> MutationOrder {
|
||||
let mut program = Vec::with_capacity(96);
|
||||
let mut program = Vec::with_capacity(128);
|
||||
let mut stack_depth: i32 = 0;
|
||||
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 {
|
||||
let op = if stack_depth <= 0 {
|
||||
for idx in 0..ops {
|
||||
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)
|
||||
} 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 {
|
||||
// Pushers
|
||||
0 => {
|
||||
OP_GENE_LOAD => {
|
||||
program.push(OP_GENE_LOAD);
|
||||
push_u16(&mut program, rng.r#gen::<u16>());
|
||||
stack_depth += 1;
|
||||
}
|
||||
1 => {
|
||||
OP_TRANSCRIBE => {
|
||||
program.push(OP_TRANSCRIBE);
|
||||
push_u16(&mut program, rng.r#gen::<u16>());
|
||||
program.push(rng.gen_range(1u8..=16u8));
|
||||
stack_depth += 1;
|
||||
}
|
||||
2 => {
|
||||
OP_FINALIZE_GENE_HASH => {
|
||||
program.push(OP_FINALIZE_GENE_HASH);
|
||||
stack_depth += 1;
|
||||
hash_ops_needed = hash_ops_needed.saturating_sub(1);
|
||||
}
|
||||
// Consumers
|
||||
3 => {
|
||||
OP_GENE_STORE => {
|
||||
if stack_depth > 0 {
|
||||
program.push(OP_GENE_STORE);
|
||||
push_u16(&mut program, rng.r#gen::<u16>());
|
||||
stack_depth -= 1;
|
||||
}
|
||||
}
|
||||
4 => {
|
||||
OP_MUTATE_POINT => {
|
||||
program.push(OP_MUTATE_POINT);
|
||||
push_u16(&mut program, rng.r#gen::<u16>());
|
||||
program.push(rng.r#gen::<u8>());
|
||||
}
|
||||
5 => {
|
||||
OP_INSERT => {
|
||||
if stack_depth > 0 && estimated_gene_len < MAX_GENE_SIZE {
|
||||
program.push(OP_INSERT);
|
||||
push_u16(&mut program, rng.r#gen::<u16>());
|
||||
@@ -156,7 +175,7 @@ pub fn generate_order_with_rng<R: Rng + ?Sized>(
|
||||
estimated_gene_len += 1;
|
||||
}
|
||||
}
|
||||
6 => {
|
||||
OP_DELETE => {
|
||||
program.push(OP_DELETE);
|
||||
push_u16(&mut program, rng.r#gen::<u16>());
|
||||
stack_depth += 1;
|
||||
@@ -164,7 +183,7 @@ pub fn generate_order_with_rng<R: Rng + ?Sized>(
|
||||
estimated_gene_len -= 1;
|
||||
}
|
||||
}
|
||||
7 => {
|
||||
OP_APPLY_MUTAGEN => {
|
||||
if stack_depth > 0 {
|
||||
program.push(OP_APPLY_MUTAGEN);
|
||||
push_u16(&mut program, rng.r#gen::<u16>());
|
||||
@@ -172,35 +191,132 @@ pub fn generate_order_with_rng<R: Rng + ?Sized>(
|
||||
stack_depth -= 1;
|
||||
}
|
||||
}
|
||||
8 => {
|
||||
OP_CONSUME => {
|
||||
if stack_depth > 0 {
|
||||
program.push(OP_CONSUME);
|
||||
push_u16(&mut program, rng.r#gen::<u16>());
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if stack_depth > 0 {
|
||||
program.push(OP_PRODUCE);
|
||||
push_u16(&mut program, rng.r#gen::<u16>());
|
||||
}
|
||||
OP_PRODUCE if stack_depth > 0 => {
|
||||
program.push(OP_PRODUCE);
|
||||
push_u16(&mut program, rng.r#gen::<u16>());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
while hash_ops_needed > 0 && program.len() < MAX_MUTATION_PROGRAM_BYTES {
|
||||
program.push(OP_FINALIZE_GENE_HASH);
|
||||
hash_ops_needed -= 1;
|
||||
}
|
||||
|
||||
MutationOrder { step, program }
|
||||
}
|
||||
|
||||
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();
|
||||
apply_program(&mut next, program)?;
|
||||
execute_program_with_rounds(&mut next, program, rounds)?;
|
||||
Ok(next)
|
||||
}
|
||||
|
||||
pub fn apply_program(state: &mut GeneState, program: &[u8]) -> Result<(), MutationError> {
|
||||
let _ = execute_program(state, program)?;
|
||||
pub fn apply_program_with_rounds(
|
||||
state: &mut GeneState,
|
||||
program: &[u8],
|
||||
rounds: u8,
|
||||
) -> Result<(), MutationError> {
|
||||
let _ = execute_program_with_rounds(state, program, rounds)?;
|
||||
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(
|
||||
state: &mut GeneState,
|
||||
program: &[u8],
|
||||
|
||||
@@ -18,3 +18,4 @@ getrandom = { version = "0.2", features = ["js"] }
|
||||
hex = "0.4"
|
||||
base64 = "0.22"
|
||||
serde-wasm-bindgen = "0.6"
|
||||
tracing = "0.1"
|
||||
+65
-29
@@ -1,4 +1,5 @@
|
||||
use std::cell::RefCell;
|
||||
use std::time::Instant;
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
thread_local! {
|
||||
@@ -17,24 +18,40 @@ pub fn init_gene_state(gene_size: u32) -> bool {
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn preview_gene_commitment(order_b64: &str) -> String {
|
||||
let order = match shared::vm_extensions::decode_order_b64(0, order_b64) {
|
||||
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(mutation_step, order_b64) {
|
||||
Ok(order) => order,
|
||||
Err(_) => return String::new(),
|
||||
};
|
||||
|
||||
let start = Instant::now();
|
||||
let candidate = GENE_STATE.with(|slot| {
|
||||
let state = slot.borrow();
|
||||
let Some(current) = state.as_ref() else {
|
||||
return None;
|
||||
};
|
||||
shared::vm_extensions::apply_program_clone(current, &order.program).ok()
|
||||
let current = state.as_ref()?;
|
||||
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 {
|
||||
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));
|
||||
commitment
|
||||
}
|
||||
@@ -55,11 +72,13 @@ pub fn discard_gene_preview() {
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn current_gene_commitment() -> String {
|
||||
pub fn current_gene_commitment(session_id: &str, mutation_step: u64) -> String {
|
||||
GENE_STATE.with(|slot| {
|
||||
slot.borrow()
|
||||
.as_ref()
|
||||
.map(shared::gene::commitment_hex)
|
||||
.map(|state| {
|
||||
shared::gene::commitment_hex_with_context(state, session_id, mutation_step)
|
||||
})
|
||||
.unwrap_or_default()
|
||||
})
|
||||
}
|
||||
@@ -77,7 +96,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_init_gene_state_success() {
|
||||
assert!(init_gene_state(64));
|
||||
let commitment = current_gene_commitment();
|
||||
let commitment = current_gene_commitment("deadbeef", 1);
|
||||
assert_eq!(commitment.len(), 64);
|
||||
}
|
||||
|
||||
@@ -90,43 +109,43 @@ mod tests {
|
||||
fn test_preview_requires_initialized_state() {
|
||||
discard_gene_preview();
|
||||
GENE_STATE.with(|slot| *slot.borrow_mut() = None);
|
||||
let c = preview_gene_commitment(&order_b64(vec![
|
||||
shared::vm_extensions::OP_MUTATE_POINT,
|
||||
0,
|
||||
0,
|
||||
let c = preview_gene_commitment(
|
||||
&order_b64(vec![shared::vm_extensions::OP_MUTATE_POINT, 0, 0, 1]),
|
||||
"deadbeef",
|
||||
1,
|
||||
]));
|
||||
0,
|
||||
);
|
||||
assert!(c.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_preview_rejects_invalid_order() {
|
||||
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());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_commit_applies_preview() {
|
||||
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 preview = preview_gene_commitment(&order);
|
||||
let preview = preview_gene_commitment(&order, "deadbeef", 1, 0);
|
||||
assert_ne!(preview, before);
|
||||
assert!(commit_gene_preview());
|
||||
let after = current_gene_commitment();
|
||||
let after = current_gene_commitment("deadbeef", 1);
|
||||
assert_eq!(preview, after);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_discard_preview_keeps_committed_state() {
|
||||
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 preview = preview_gene_commitment(&order);
|
||||
let preview = preview_gene_commitment(&order, "deadbeef", 1, 0);
|
||||
assert_ne!(preview, before);
|
||||
discard_gene_preview();
|
||||
let after = current_gene_commitment();
|
||||
let after = current_gene_commitment("deadbeef", 1);
|
||||
assert_eq!(before, after);
|
||||
}
|
||||
|
||||
@@ -161,11 +180,19 @@ mod tests {
|
||||
};
|
||||
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();
|
||||
shared::vm_extensions::apply_program(&mut expected, &order.program).unwrap();
|
||||
assert_eq!(preview, shared::gene::commitment_hex(&expected));
|
||||
shared::vm_extensions::apply_program_with_rounds(
|
||||
&mut expected,
|
||||
&order.program,
|
||||
shared::constants::DEFAULT_MUTATION_ROUNDS,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
preview,
|
||||
shared::gene::commitment_hex_with_context(&expected, "deadbeef", 3)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -178,13 +205,22 @@ mod tests {
|
||||
let order = shared::vm_extensions::generate_order_with_rng(&mut rng, step + 1, 64);
|
||||
let b64 = shared::vm_extensions::encode_order_b64(&order);
|
||||
|
||||
let preview = preview_gene_commitment(&b64);
|
||||
shared::vm_extensions::apply_program(&mut expected, &order.program).unwrap();
|
||||
let expected_commitment = shared::gene::commitment_hex(&expected);
|
||||
let preview = preview_gene_commitment(&b64, "deadbeef", step + 1, 0);
|
||||
shared::vm_extensions::apply_program_with_rounds(
|
||||
&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!(commit_gene_preview());
|
||||
assert_eq!(current_gene_commitment(), expected_commitment);
|
||||
assert_eq!(
|
||||
current_gene_commitment("deadbeef", step + 1),
|
||||
expected_commitment
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user