feat: implement v0.6.0 mutation engine and db_type runtime selection
Rust / build (push) Canceled after 0s
Rust / build (push) Canceled after 0s
This commit is contained in:
1 parent
217dc5f92b
commit
ba768da58e
27 files changed
+2615
-158
No files matched your search
Generated
+3
-3
@@ -245,7 +245,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "chronoseal-server"
|
name = "chronoseal-server"
|
||||||
version = "0.5.0"
|
version = "0.6.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"axum",
|
"axum",
|
||||||
"base64",
|
"base64",
|
||||||
@@ -273,7 +273,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "chronoseal-wasm"
|
name = "chronoseal-wasm"
|
||||||
version = "0.5.0"
|
version = "0.6.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64",
|
||||||
"blake3",
|
"blake3",
|
||||||
@@ -1294,7 +1294,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "shared"
|
name = "shared"
|
||||||
version = "0.5.0"
|
version = "0.6.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64",
|
||||||
"blake3",
|
"blake3",
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ ChronoSeal establishes a continuous cryptographic proof-of-runtime continuity us
|
|||||||
|
|
||||||
while remaining completely invisible and frictionless to legitimate human users.
|
while remaining completely invisible and frictionless to legitimate human users.
|
||||||
|
|
||||||
|
v0.6.0 adds a deterministic synthetic gene mutation chain (hybrid `Vec<u8>` gene + bounded environment records) to strengthen anti-replay continuity with server/WASM parity.
|
||||||
|
See [docs/REFRACTORING-v0.6.0.md](docs/REFRACTORING-v0.6.0.md) for the full refactoring details.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# Features
|
# Features
|
||||||
@@ -47,8 +50,11 @@ while remaining completely invisible and frictionless to legitimate human users.
|
|||||||
* Ed25519 + Blake3 cryptographic chaining
|
* Ed25519 + Blake3 cryptographic chaining
|
||||||
* Behavioral entropy validation
|
* Behavioral entropy validation
|
||||||
* Randomized stack-machine verification
|
* Randomized stack-machine verification
|
||||||
|
* Deterministic synthetic gene mutation chain
|
||||||
|
* Server/WASM mutation parity checks
|
||||||
* Silent rejection model
|
* Silent rejection model
|
||||||
* SQLite-backed ephemeral sessions
|
* SQLite-backed ephemeral sessions
|
||||||
|
* Configurable runtime DB backend selection (`db_type`)
|
||||||
* Connection-pooled runtime architecture
|
* Connection-pooled runtime architecture
|
||||||
* Lightweight deployment footprint
|
* Lightweight deployment footprint
|
||||||
* Docker and native deployment support
|
* Docker and native deployment support
|
||||||
@@ -104,6 +110,7 @@ chronoseal --help
|
|||||||
| `health` | Perform daemon health probe |
|
| `health` | Perform daemon health probe |
|
||||||
| `config` | Validate and print effective configuration |
|
| `config` | Validate and print effective configuration |
|
||||||
| `generate` | Generate operational material |
|
| `generate` | Generate operational material |
|
||||||
|
| `db-type` | List database backend support status |
|
||||||
| `metrics` | Output Prometheus metrics |
|
| `metrics` | Output Prometheus metrics |
|
||||||
| `stats` | Print runtime statistics |
|
| `stats` | Print runtime statistics |
|
||||||
| `completion` | Generate shell completions |
|
| `completion` | Generate shell completions |
|
||||||
@@ -144,18 +151,24 @@ Browser Server
|
|||||||
│ Private key never leaves WASM memory │
|
│ Private key never leaves WASM memory │
|
||||||
│ │
|
│ │
|
||||||
├──── POST /init { public_key } ──────────►│
|
├──── POST /init { public_key } ──────────►│
|
||||||
│◄─── { session_id, salt, opcodes, H0 } ────┤
|
│◄─── { session_id, salt, opcodes_b64, H0, │
|
||||||
|
│ mutation_step, mutation_order_b64 } ──┤
|
||||||
│ │
|
│ │
|
||||||
│ Every 12–25s (randomized): │
|
│ Every 12–25s (randomized): │
|
||||||
│ ┌─ Collect behavioral entropy │
|
│ ┌─ Collect behavioral entropy │
|
||||||
│ ├─ Execute VM opcode program │
|
│ ├─ Execute verification VM opcodes │
|
||||||
|
│ ├─ Preview mutation commitment │
|
||||||
|
│ ├─ Attach mutation_step + commitment │
|
||||||
│ ├─ Advance Blake3 hash chain │
|
│ ├─ Advance Blake3 hash chain │
|
||||||
│ └─ Sign payload using Ed25519 │
|
│ └─ Sign payload using Ed25519 │
|
||||||
│ │
|
│ │
|
||||||
├──── POST /heartbeat { signed_payload } ─►│
|
├──── POST /hb { signed_payload } ────────►│
|
||||||
│◄─── { status, next_salt } ────────────────┤
|
│◄─── { status, next_salt, │
|
||||||
|
│ next_mutation_step, │
|
||||||
|
│ next_mutation_order_b64 } ────────────┤
|
||||||
│ │
|
│ │
|
||||||
│ Invalid sessions silently rejected │
|
│ Invalid sessions silently rejected │
|
||||||
|
│ (`status=ok` without next_* fields) │
|
||||||
```
|
```
|
||||||
|
|
||||||
The server validates:
|
The server validates:
|
||||||
@@ -163,6 +176,8 @@ The server validates:
|
|||||||
* signature authenticity
|
* signature authenticity
|
||||||
* heartbeat continuity
|
* heartbeat continuity
|
||||||
* replay resistance
|
* replay resistance
|
||||||
|
* mutation step parity
|
||||||
|
* mutation commitment parity
|
||||||
* behavioral entropy
|
* behavioral entropy
|
||||||
* timestamp validity
|
* timestamp validity
|
||||||
* fingerprint sanity
|
* fingerprint sanity
|
||||||
@@ -229,10 +244,10 @@ The goal is to make automation:
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
chronoseal-rs/
|
chronoseal-rs/
|
||||||
├── shared/ Shared types, constants, hash-chain logic
|
├── shared/ Shared types, hash chain, gene + mutation engine
|
||||||
├── server/ Axum HTTP daemon
|
├── server/ Axum HTTP daemon
|
||||||
│ ├── routes/ API routes
|
│ ├── routes/ API routes
|
||||||
│ ├── session.rs Session lifecycle management
|
│ ├── session.rs Session lifecycle + mutation parity checks
|
||||||
│ ├── crypto.rs Ed25519 verification
|
│ ├── crypto.rs Ed25519 verification
|
||||||
│ ├── trust.rs Behavioral validation
|
│ ├── trust.rs Behavioral validation
|
||||||
│ ├── fingerprint/ Browser sanity validation
|
│ ├── fingerprint/ Browser sanity validation
|
||||||
@@ -242,7 +257,8 @@ chronoseal-rs/
|
|||||||
│ └── metrics.rs Prometheus metrics
|
│ └── metrics.rs Prometheus metrics
|
||||||
├── wasm/ Rust → WASM runtime
|
├── wasm/ Rust → WASM runtime
|
||||||
│ ├── crypto.rs Signing + hash chaining
|
│ ├── crypto.rs Signing + hash chaining
|
||||||
│ └── vm.rs Stack-machine executor
|
│ ├── vm.rs Stack-machine executor
|
||||||
|
│ └── vm_extensions.rs Gene mutation preview/commit
|
||||||
├── frontend/ Lightweight JS integration
|
├── frontend/ Lightweight JS integration
|
||||||
├── scripts/ Build/install/dev scripts
|
├── scripts/ Build/install/dev scripts
|
||||||
└── docs/ Project documentation
|
└── docs/ Project documentation
|
||||||
@@ -275,6 +291,21 @@ This makes heartbeat payloads structurally dynamic.
|
|||||||
| `0x08` | NOT | Unary inversion |
|
| `0x08` | NOT | Unary inversion |
|
||||||
| `0x09` | HASH | Blake3 stack hash |
|
| `0x09` | HASH | Blake3 stack hash |
|
||||||
|
|
||||||
|
## Mutation Opcodes (v0.6.0)
|
||||||
|
|
||||||
|
| Opcode | Mnemonic | Effect |
|
||||||
|
| ------ | -------------------- | ------ |
|
||||||
|
| `0x23` | GENE_LOAD | Push `gene[idx]` |
|
||||||
|
| `0x24` | GENE_STORE | Pop and store at `gene[idx]` |
|
||||||
|
| `0x25` | MUTATE_POINT | Apply wrapping byte delta at index |
|
||||||
|
| `0x26` | INSERT | Insert popped byte at index |
|
||||||
|
| `0x27` | DELETE | Delete byte at index and push removed value |
|
||||||
|
| `0x28` | TRANSCRIBE | Push deterministic transcription hash |
|
||||||
|
| `0x29` | APPLY_MUTAGEN | Mix environment symbol quantity into gene byte |
|
||||||
|
| `0x2A` | FINALIZE_GENE_HASH | Push commitment-derived `u32` |
|
||||||
|
| `0x2B` | CONSUME | Pop amount, subtract environment quantity |
|
||||||
|
| `0x2C` | PRODUCE | Pop amount, add environment quantity |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# Hash Chain
|
# Hash Chain
|
||||||
@@ -330,14 +361,18 @@ This prevents:
|
|||||||
|
|
||||||
```sql
|
```sql
|
||||||
CREATE TABLE IF NOT EXISTS sessions (
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
session_id TEXT PRIMARY KEY,
|
session_id TEXT PRIMARY KEY,
|
||||||
public_key BLOB NOT NULL,
|
public_key BLOB NOT NULL,
|
||||||
salt BLOB NOT NULL,
|
salt BLOB NOT NULL,
|
||||||
last_hash BLOB NOT NULL,
|
last_hash BLOB NOT NULL,
|
||||||
chain_length INTEGER NOT NULL DEFAULT 1,
|
chain_length INTEGER NOT NULL DEFAULT 1,
|
||||||
created_at INTEGER NOT NULL,
|
created_at INTEGER NOT NULL,
|
||||||
last_seen INTEGER NOT NULL,
|
last_seen INTEGER NOT NULL,
|
||||||
expires_at INTEGER NOT NULL
|
expires_at INTEGER NOT NULL,
|
||||||
|
gene BLOB NOT NULL DEFAULT X'',
|
||||||
|
environment BLOB NOT NULL DEFAULT X'',
|
||||||
|
pending_mutation BLOB NOT NULL DEFAULT X'',
|
||||||
|
pending_mutation_step INTEGER NOT NULL DEFAULT 0
|
||||||
);
|
);
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -354,7 +389,8 @@ Session continuity is designed to reset transparently.
|
|||||||
* Rust
|
* Rust
|
||||||
* Axum
|
* Axum
|
||||||
* Tokio
|
* Tokio
|
||||||
* SQLite
|
* SQLite (`sqlite-in-memory` / `sqlite-in-disk`)
|
||||||
|
* `db_type=valkey` compatibility mode (falls back to in-memory in v0.6.0)
|
||||||
* `r2d2`
|
* `r2d2`
|
||||||
* `thiserror`
|
* `thiserror`
|
||||||
|
|
||||||
@@ -464,6 +500,20 @@ $XDG_CONFIG_HOME/chronoseal/config.toml
|
|||||||
~/.local/state/chronoseal/
|
~/.local/state/chronoseal/
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Database Backend Selection (v0.6.0)
|
||||||
|
|
||||||
|
Choose backend with config, env var, or CLI flag:
|
||||||
|
|
||||||
|
* Config: `db_type = "sqlite-in-memory" | "sqlite-in-disk" | "valkey"`
|
||||||
|
* Env: `CHRONOSEAL_DB_TYPE=...`
|
||||||
|
* CLI: `chronoseal run --db-type sqlite-in-disk --db-path /var/lib/chronoseal/chronoseal.sqlite`
|
||||||
|
|
||||||
|
Inspect backend status:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
chronoseal db-type --format text
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# Observability
|
# Observability
|
||||||
|
|||||||
+41
-11
@@ -39,7 +39,12 @@ Content-Type: application/json
|
|||||||
"salt": "32-char hex string (16 bytes)",
|
"salt": "32-char hex string (16 bytes)",
|
||||||
"opcodes_b64": "base64-encoded VM program (8–16 opcodes)",
|
"opcodes_b64": "base64-encoded VM program (8–16 opcodes)",
|
||||||
"initial_hash": "64-char hex string (32 bytes Blake3)",
|
"initial_hash": "64-char hex string (32 bytes Blake3)",
|
||||||
"expires_at": 1234567890123
|
"expires_at": 1234567890123,
|
||||||
|
"heartbeat_min_interval_ms": 12000,
|
||||||
|
"heartbeat_max_interval_ms": 25000,
|
||||||
|
"gene_size": 512,
|
||||||
|
"mutation_step": 1,
|
||||||
|
"mutation_order_b64": "base64-encoded mutation program"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -50,6 +55,11 @@ Content-Type: application/json
|
|||||||
| `opcodes_b64` | `string` | Base64 VM program; execute with `run_program()` on every heartbeat |
|
| `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` |
|
| `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 |
|
| `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 |
|
||||||
|
|
||||||
#### Error
|
#### Error
|
||||||
|
|
||||||
@@ -89,6 +99,8 @@ Content-Type: application/json
|
|||||||
"devicePixelRatio": "2",
|
"devicePixelRatio": "2",
|
||||||
"hardwareConcurrency": 8
|
"hardwareConcurrency": 8
|
||||||
},
|
},
|
||||||
|
"mutation_step": 1,
|
||||||
|
"gene_commitment": "64-char hex Blake3 commitment",
|
||||||
"signature": "128-char hex Ed25519 signature"
|
"signature": "128-char hex Ed25519 signature"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -104,6 +116,8 @@ Content-Type: application/json
|
|||||||
| `fingerprint.aspectRatio` | `string` | `(screen.width / screen.height).toFixed(10)` |
|
| `fingerprint.aspectRatio` | `string` | `(screen.width / screen.height).toFixed(10)` |
|
||||||
| `fingerprint.devicePixelRatio` | `string` | `String(window.devicePixelRatio)` |
|
| `fingerprint.devicePixelRatio` | `string` | `String(window.devicePixelRatio)` |
|
||||||
| `fingerprint.hardwareConcurrency` | `number` | `navigator.hardwareConcurrency \|\| 1` |
|
| `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 |
|
| `signature` | `string` | Hex-encoded 64-byte Ed25519 signature over the canonical payload |
|
||||||
|
|
||||||
#### Canonical Signing Payload
|
#### Canonical Signing Payload
|
||||||
@@ -115,6 +129,8 @@ alphabetically. Nested object keys follow their natural serialisation order.
|
|||||||
{
|
{
|
||||||
"entropyData": { "events": [{ "t": …, "x": …, "y": … }] },
|
"entropyData": { "events": [{ "t": …, "x": …, "y": … }] },
|
||||||
"fingerprint": { "aspectRatio": "…", "devicePixelRatio": "…", "hardwareConcurrency": … },
|
"fingerprint": { "aspectRatio": "…", "devicePixelRatio": "…", "hardwareConcurrency": … },
|
||||||
|
"geneCommitment":"…",
|
||||||
|
"mutationStep": …,
|
||||||
"prevHash": "…",
|
"prevHash": "…",
|
||||||
"sessionId": "…",
|
"sessionId": "…",
|
||||||
"stackState": { "ip": …, "stack": […] },
|
"stackState": { "ip": …, "stack": […] },
|
||||||
@@ -123,22 +139,28 @@ alphabetically. Nested object keys follow their natural serialisation order.
|
|||||||
```
|
```
|
||||||
|
|
||||||
Note: field names in the signing payload use camelCase (`sessionId`,
|
Note: field names in the signing payload use camelCase (`sessionId`,
|
||||||
`prevHash`, `entropyData`, `stackState`) while the request body uses
|
`prevHash`, `entropyData`, `stackState`, `mutationStep`, `geneCommitment`)
|
||||||
snake_case (`session_id`, `prev_hash`, `entropy_data`, `stack_state`).
|
while the request body uses snake_case (`session_id`, `prev_hash`,
|
||||||
|
`entropy_data`, `stack_state`, `mutation_step`, `gene_commitment`).
|
||||||
|
|
||||||
#### Response `200 OK` — Accepted
|
#### Response `200 OK` — Accepted
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"next_salt": "32-char hex string (16 bytes)"
|
"next_salt": "32-char hex string (16 bytes)",
|
||||||
|
"next_mutation_step": 2,
|
||||||
|
"next_mutation_order_b64": "base64-encoded mutation program"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
The client must:
|
The client must:
|
||||||
1. Capture `sentSalt = currentSalt` before updating.
|
1. Preview commitment locally from `mutation_order_b64` and send it in the heartbeat.
|
||||||
2. Set `currentSalt = next_salt`.
|
2. Capture `sentSalt = currentSalt` before updating.
|
||||||
3. Compute `prevHash = compute_next_hash(prevHash, timestamp, entropyJson, stackStateJson, sentSalt)`.
|
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`.
|
||||||
|
|
||||||
#### Response `200 OK` — Rejected
|
#### Response `200 OK` — Rejected
|
||||||
|
|
||||||
@@ -148,7 +170,8 @@ The client must:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`next_salt` is absent. The response body is intentionally identical in
|
`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
|
structure. Rejections are silent — the caller cannot distinguish a validation
|
||||||
failure from a rate limit hit or an expired session.
|
failure from a rate limit hit or an expired session.
|
||||||
|
|
||||||
@@ -168,6 +191,8 @@ Heartbeats are rejected (silently) if any of the following checks fail:
|
|||||||
| Session expired | `current_time_ms > expires_at` |
|
| Session expired | `current_time_ms > expires_at` |
|
||||||
| Signature invalid | Ed25519 verification fails against stored public key |
|
| Signature invalid | Ed25519 verification fails against stored public key |
|
||||||
| Hash chain broken | `hex(prev_hash) ≠ stored last_hash` |
|
| 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` |
|
| Timestamp drift | `\|server_now_ms - timestamp\| > 30 000` |
|
||||||
| Insufficient mouse events | `events.len() < 3` |
|
| Insufficient mouse events | `events.len() < 3` |
|
||||||
| Insufficient mouse distance | `total_dist < 10.0 px` |
|
| Insufficient mouse distance | `total_dist < 10.0 px` |
|
||||||
@@ -202,7 +227,7 @@ Rust types (serde derive, no custom ordering).
|
|||||||
|
|
||||||
## WASM API
|
## WASM API
|
||||||
|
|
||||||
The WASM module (`antibot_wasm`) exports the following functions to JavaScript:
|
The WASM module (`chronoseal_wasm`) exports the following functions to JavaScript:
|
||||||
|
|
||||||
| Function | Signature | Description |
|
| Function | Signature | Description |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
@@ -211,6 +236,11 @@ The WASM module (`antibot_wasm`) exports the following functions to JavaScript:
|
|||||||
| `sign_message(msg)` | `(string) → string` | Sign UTF-8 string; return hex signature, 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. |
|
| `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 }`. |
|
| `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. |
|
||||||
|
|
||||||
All functions return empty strings on error rather than panicking.
|
String-returning functions return `""` on error rather than panicking. Callers
|
||||||
Callers must check for empty return values before using the result.
|
must check for empty strings and boolean return values before use.
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
# 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).
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
ChronoSeal is a stateless, cryptographic browser attestation framework. Its
|
ChronoSeal is a stateless, cryptographic browser attestation framework. Its
|
||||||
@@ -42,7 +44,7 @@ synchronisation burden alone makes scaled operation expensive.
|
|||||||
### High-Level Design
|
### High-Level Design
|
||||||
|
|
||||||
- **Core**: Rust + Axum (async web framework)
|
- **Core**: Rust + Axum (async web framework)
|
||||||
- **Storage**: In-memory SQLite (fast, ephemeral per process — restarts are clean)
|
- **Storage**: `db_type` selectable (`sqlite-in-memory`, `sqlite-in-disk`, `valkey` compatibility mode)
|
||||||
- **Client**: WASM + Rust (runs in browser for proof generation)
|
- **Client**: WASM + Rust (runs in browser for proof generation)
|
||||||
- **Security Model**: Behavioral analysis + hash chaining + entropy scoring
|
- **Security Model**: Behavioral analysis + hash chaining + entropy scoring
|
||||||
- **Deployment**: Static musl binary, systemd service, optional Docker
|
- **Deployment**: Static musl binary, systemd service, optional Docker
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
# ChronoSeal v0.6.0 — Synthetic Gene Mutation System
|
||||||
|
|
||||||
|
## Overview & Motivation
|
||||||
|
ChronoSeal v0.6.0 introduces a synthetic mutation chain model to strengthen attestation liveness and anti-replay guarantees while preserving privacy-first behavior. The core model combines:
|
||||||
|
- a primary byte-oriented gene buffer (`Vec<u8>`), and
|
||||||
|
- a bounded secondary environment map (`Vec<(u16 symbol, u32 quantity)>`).
|
||||||
|
|
||||||
|
Each heartbeat now carries deterministic mutation progression evidence (`mutation_step`, `gene_commitment`) that is validated server-side against the exact server-issued mutation order. This design increases attacker workload by coupling cryptographic chain continuity with stateful deterministic mutation parity.
|
||||||
|
|
||||||
|
## Architectural Goals
|
||||||
|
1. Keep runtime behavior deterministic across server and WASM execution.
|
||||||
|
2. Preserve ephemerality and low operational complexity.
|
||||||
|
3. Minimize additional latency on the heartbeat path.
|
||||||
|
4. Improve protocol resistance against replay and mutation tampering.
|
||||||
|
5. Maintain a maintainable codebase with explicit invariants and focused modules.
|
||||||
|
|
||||||
|
## Design Decisions
|
||||||
|
1. **Shared mutation engine**
|
||||||
|
Mutation opcode semantics live in `shared/src/vm_extensions.rs` to guarantee server/client parity from one implementation.
|
||||||
|
|
||||||
|
2. **Deterministic gene commitment**
|
||||||
|
A domain-separated BLAKE3 commitment (`chronoseal/gene/v1`) binds both gene bytes and sorted environment records.
|
||||||
|
|
||||||
|
3. **Bounded mutation complexity**
|
||||||
|
Mutation program length is capped (`MAX_MUTATION_PROGRAM_BYTES`) and environment cardinality is capped (`MAX_ENV_RECORDS`).
|
||||||
|
|
||||||
|
4. **Strict validation on ingest**
|
||||||
|
Environment payloads are validated for sortedness, uniqueness, non-zero quantity, and length constraints.
|
||||||
|
|
||||||
|
5. **Protocol-level mutation handshake**
|
||||||
|
`InitResponse` and `Heartbeat` payloads now include mutation step/order and commitment fields.
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
## 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).
|
||||||
|
|
||||||
|
## Testing Strategy (detailed section)
|
||||||
|
ChronoSeal v0.6.0 test coverage is organized across unit, integration, and randomized/fuzz-style validation.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Security Analysis
|
||||||
|
1. **Replay resistance**
|
||||||
|
Heartbeats are now tied to both chain hash and mutation step progression.
|
||||||
|
|
||||||
|
2. **Mutation tampering resistance**
|
||||||
|
Server recomputes candidate gene state from authoritative pending mutation program and rejects commitment mismatch.
|
||||||
|
|
||||||
|
3. **Protocol ambiguity reduction**
|
||||||
|
Canonical signing payload includes mutation fields, reducing exploitable unsigned state.
|
||||||
|
|
||||||
|
4. **Input hardening**
|
||||||
|
Program size limits, stack underflow checks, and strict environment decoding reduce parser abuse and malformed payload amplification.
|
||||||
|
|
||||||
|
5. **Deterministic failure semantics**
|
||||||
|
Invalid mutation instructions fail predictably and symmetrically across server and WASM paths.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
## Risks & Mitigations
|
||||||
|
1. **Risk: State divergence between server and client**
|
||||||
|
Mitigation: shared opcode engine + deterministic seeded parity tests + multi-heartbeat integration tests.
|
||||||
|
|
||||||
|
2. **Risk: Mutation opcode abuse via malformed programs**
|
||||||
|
Mitigation: strict parsing, length caps, explicit underflow/unknown-opcode errors.
|
||||||
|
|
||||||
|
3. **Risk: Performance regressions**
|
||||||
|
Mitigation: bounded structures, smoke timing tests, and focused hot-path validation.
|
||||||
|
|
||||||
|
4. **Risk: Backend confusion during `db_type` rollout**
|
||||||
|
Mitigation: explicit CLI command (`chronoseal db-type`), config output visibility, and clear runtime compatibility behavior.
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
bind = "0.0.0.0:3000"
|
bind = "0.0.0.0:3000"
|
||||||
|
# sqlite-in-memory (default), sqlite-in-disk, valkey (v0.6.0 compatibility mode)
|
||||||
|
db_type = "sqlite-in-memory"
|
||||||
pid_file = "/run/chronoseal.pid"
|
pid_file = "/run/chronoseal.pid"
|
||||||
db_path = "/var/lib/chronoseal/chronoseal.sqlite"
|
db_path = "/var/lib/chronoseal/chronoseal.sqlite"
|
||||||
frontend_dir = "/usr/share/chronoseal/frontend"
|
frontend_dir = "/usr/share/chronoseal/frontend"
|
||||||
log_file = "/var/log/chronoseal/chronoseal.jsonl"
|
log_file = "/var/log/chronoseal/chronoseal.jsonl"
|
||||||
|
# synthetic gene size (1..=65536), default 512
|
||||||
|
gene_size = 512
|
||||||
+37
-4
@@ -1,10 +1,21 @@
|
|||||||
import init, { generate_keypair, sign_message, compute_next_hash, run_program } from './pkg/chronoseal_wasm.js';
|
import init, {
|
||||||
|
generate_keypair,
|
||||||
|
sign_message,
|
||||||
|
compute_next_hash,
|
||||||
|
run_program,
|
||||||
|
init_gene_state,
|
||||||
|
preview_gene_commitment,
|
||||||
|
commit_gene_preview,
|
||||||
|
discard_gene_preview
|
||||||
|
} from './pkg/chronoseal_wasm.js';
|
||||||
import { collectEntropy } from './entropy.js';
|
import { collectEntropy } from './entropy.js';
|
||||||
import { sendRequest } from './transport.js';
|
import { sendRequest } from './transport.js';
|
||||||
|
|
||||||
let session, prevHash, currentSalt, opcodesB64, lastTime;
|
let session, prevHash, currentSalt, opcodesB64, lastTime;
|
||||||
let minInterval = 12000;
|
let minInterval = 12000;
|
||||||
let maxInterval = 25000;
|
let maxInterval = 25000;
|
||||||
|
let pendingMutationStep = 0;
|
||||||
|
let pendingMutationOrderB64 = '';
|
||||||
|
|
||||||
export async function initHeartbeat() {
|
export async function initHeartbeat() {
|
||||||
await init();
|
await init();
|
||||||
@@ -16,6 +27,11 @@ export async function initHeartbeat() {
|
|||||||
opcodesB64 = initResp.opcodes_b64;
|
opcodesB64 = initResp.opcodes_b64;
|
||||||
minInterval = initResp.heartbeat_min_interval_ms || 12000;
|
minInterval = initResp.heartbeat_min_interval_ms || 12000;
|
||||||
maxInterval = initResp.heartbeat_max_interval_ms || 25000;
|
maxInterval = initResp.heartbeat_max_interval_ms || 25000;
|
||||||
|
if (!init_gene_state(initResp.gene_size || 512)) {
|
||||||
|
throw new Error('Failed to initialize gene state');
|
||||||
|
}
|
||||||
|
pendingMutationStep = initResp.mutation_step;
|
||||||
|
pendingMutationOrderB64 = initResp.mutation_order_b64;
|
||||||
lastTime = performance.now();
|
lastTime = performance.now();
|
||||||
scheduleNext();
|
scheduleNext();
|
||||||
}
|
}
|
||||||
@@ -40,6 +56,10 @@ async function sendHeartbeat() {
|
|||||||
const timestamp = Date.now();
|
const timestamp = Date.now();
|
||||||
const entropyData = { events: events.map(e => ({ x: e.x, y: e.y, t: e.t })) };
|
const entropyData = { events: events.map(e => ({ x: e.x, y: e.y, t: e.t })) };
|
||||||
const entropyJson = JSON.stringify(entropyData);
|
const entropyJson = JSON.stringify(entropyData);
|
||||||
|
const geneCommitment = preview_gene_commitment(pendingMutationOrderB64);
|
||||||
|
if (!geneCommitment) {
|
||||||
|
throw new Error('Unable to compute mutation commitment');
|
||||||
|
}
|
||||||
|
|
||||||
const signable = {
|
const signable = {
|
||||||
sessionId: session,
|
sessionId: session,
|
||||||
@@ -47,11 +67,14 @@ async function sendHeartbeat() {
|
|||||||
timestamp: timestamp,
|
timestamp: timestamp,
|
||||||
entropyData: entropyData,
|
entropyData: entropyData,
|
||||||
stackState: JSON.parse(stackState),
|
stackState: JSON.parse(stackState),
|
||||||
fingerprint: fingerprint
|
fingerprint: fingerprint,
|
||||||
|
mutationStep: pendingMutationStep,
|
||||||
|
geneCommitment: geneCommitment
|
||||||
};
|
};
|
||||||
const msg = JSON.stringify(signable, Object.keys(signable).sort());
|
const msg = JSON.stringify(signable, Object.keys(signable).sort());
|
||||||
const sig = sign_message(msg);
|
const sig = sign_message(msg);
|
||||||
if (!sig) {
|
if (!sig) {
|
||||||
|
discard_gene_preview();
|
||||||
console.error('Keypair not initialised — skipping heartbeat');
|
console.error('Keypair not initialised — skipping heartbeat');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -62,22 +85,32 @@ async function sendHeartbeat() {
|
|||||||
entropy_data: entropyData,
|
entropy_data: entropyData,
|
||||||
stack_state: JSON.parse(stackState),
|
stack_state: JSON.parse(stackState),
|
||||||
fingerprint,
|
fingerprint,
|
||||||
|
mutation_step: pendingMutationStep,
|
||||||
|
gene_commitment: geneCommitment,
|
||||||
signature: sig
|
signature: sig
|
||||||
});
|
});
|
||||||
|
|
||||||
if (resp.next_salt) {
|
if (resp.next_salt && resp.next_mutation_step && resp.next_mutation_order_b64) {
|
||||||
|
if (!commit_gene_preview()) {
|
||||||
|
discard_gene_preview();
|
||||||
|
throw new Error('Failed to commit local mutation preview');
|
||||||
|
}
|
||||||
// IMPORTANT: capture the salt that was active when this heartbeat was sent.
|
// IMPORTANT: capture the salt that was active when this heartbeat was sent.
|
||||||
// The server computes new_hash = H(prev, ts, entropy, stack, OLD_salt) and stores it,
|
// The server computes new_hash = H(prev, ts, entropy, stack, OLD_salt) and stores it,
|
||||||
// then rotates to next_salt. We must mirror that using the same old salt, then rotate.
|
// then rotates to next_salt. We must mirror that using the same old salt, then rotate.
|
||||||
const sentSalt = currentSalt;
|
const sentSalt = currentSalt;
|
||||||
currentSalt = resp.next_salt;
|
currentSalt = resp.next_salt;
|
||||||
prevHash = compute_next_hash(prevHash, timestamp, entropyJson, stackState, sentSalt);
|
prevHash = compute_next_hash(prevHash, timestamp, entropyJson, stackState, sentSalt);
|
||||||
|
pendingMutationStep = resp.next_mutation_step;
|
||||||
|
pendingMutationOrderB64 = resp.next_mutation_order_b64;
|
||||||
} else {
|
} else {
|
||||||
|
discard_gene_preview();
|
||||||
console.warn('Heartbeat rejected');
|
console.warn('Heartbeat rejected');
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
discard_gene_preview();
|
||||||
console.error(e);
|
console.error(e);
|
||||||
} finally {
|
} finally {
|
||||||
scheduleNext();
|
scheduleNext();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "chronoseal-server"
|
name = "chronoseal-server"
|
||||||
version = "0.5.0"
|
version = "0.6.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
|
|||||||
+10
-1
@@ -53,7 +53,7 @@ impl GlobalArgs {
|
|||||||
pub enum Command {
|
pub enum Command {
|
||||||
/// Run the ChronoSeal daemon.
|
/// Run the ChronoSeal daemon.
|
||||||
#[command(
|
#[command(
|
||||||
after_help = "Examples:\n chronoseal run\n chronoseal run --bind 127.0.0.1:3000 --frontend-dir /srv/chronoseal/frontend\n CHRONOSEAL_BIND=0.0.0.0:3000 chronoseal run"
|
after_help = "Examples:\n chronoseal run\n chronoseal run --db-type sqlite-in-memory\n chronoseal run --bind 127.0.0.1:3000 --frontend-dir /srv/chronoseal/frontend\n CHRONOSEAL_BIND=0.0.0.0:3000 chronoseal run"
|
||||||
)]
|
)]
|
||||||
Run(RunArgs),
|
Run(RunArgs),
|
||||||
|
|
||||||
@@ -81,6 +81,10 @@ pub enum Command {
|
|||||||
#[command(after_help = "Examples:\n chronoseal version\n chronoseal version --format json")]
|
#[command(after_help = "Examples:\n chronoseal version\n chronoseal version --format json")]
|
||||||
Version,
|
Version,
|
||||||
|
|
||||||
|
/// List database backend types and implementation status.
|
||||||
|
#[command(after_help = "Examples:\n chronoseal db-type\n chronoseal db-type --format json")]
|
||||||
|
DbType,
|
||||||
|
|
||||||
/// Print Prometheus metrics from the running daemon.
|
/// Print Prometheus metrics from the running daemon.
|
||||||
#[command(
|
#[command(
|
||||||
after_help = "Examples:\n chronoseal metrics\n chronoseal metrics --bind 127.0.0.1:3000"
|
after_help = "Examples:\n chronoseal metrics\n chronoseal metrics --bind 127.0.0.1:3000"
|
||||||
@@ -103,6 +107,11 @@ pub struct RunArgs {
|
|||||||
#[command(flatten)]
|
#[command(flatten)]
|
||||||
pub runtime: RuntimeArgs,
|
pub runtime: RuntimeArgs,
|
||||||
|
|
||||||
|
/// Database backend selection.
|
||||||
|
/// sqlite-in-memory is active. sqlite-in-disk and valkey are planned (TODO).
|
||||||
|
#[arg(long, env = "CHRONOSEAL_DB_TYPE", value_enum)]
|
||||||
|
pub db_type: Option<crate::config::DbType>,
|
||||||
|
|
||||||
/// SQLite database path. Use ':memory:' for ephemeral state.
|
/// SQLite database path. Use ':memory:' for ephemeral state.
|
||||||
#[arg(long, env = "CHRONOSEAL_DB_PATH")]
|
#[arg(long, env = "CHRONOSEAL_DB_PATH")]
|
||||||
pub db_path: Option<PathBuf>,
|
pub db_path: Option<PathBuf>,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use crate::cli::{RunArgs, RuntimeArgs};
|
use crate::cli::{RunArgs, RuntimeArgs};
|
||||||
|
use clap::ValueEnum;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::{
|
use std::{
|
||||||
env, fs, io,
|
env, fs, io,
|
||||||
@@ -6,10 +7,30 @@ use std::{
|
|||||||
path::{Path, PathBuf},
|
path::{Path, PathBuf},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ValueEnum)]
|
||||||
|
#[serde(rename_all = "kebab-case")]
|
||||||
|
#[value(rename_all = "kebab-case")]
|
||||||
|
pub enum DbType {
|
||||||
|
SqliteInMemory,
|
||||||
|
SqliteInDisk,
|
||||||
|
Valkey,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DbType {
|
||||||
|
pub fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::SqliteInMemory => "sqlite-in-memory",
|
||||||
|
Self::SqliteInDisk => "sqlite-in-disk",
|
||||||
|
Self::Valkey => "valkey",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(default, deny_unknown_fields)]
|
#[serde(default, deny_unknown_fields)]
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
pub bind: String,
|
pub bind: String,
|
||||||
|
pub db_type: DbType,
|
||||||
pub pid_file: PathBuf,
|
pub pid_file: PathBuf,
|
||||||
pub db_path: PathBuf,
|
pub db_path: PathBuf,
|
||||||
pub frontend_dir: PathBuf,
|
pub frontend_dir: PathBuf,
|
||||||
@@ -24,12 +45,14 @@ pub struct Config {
|
|||||||
pub max_mouse_avg_speed: f64,
|
pub max_mouse_avg_speed: f64,
|
||||||
pub min_pause_count: u32,
|
pub min_pause_count: u32,
|
||||||
pub require_mouse_activity: bool,
|
pub require_mouse_activity: bool,
|
||||||
|
pub gene_size: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Config {
|
impl Default for Config {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
bind: "0.0.0.0:3000".to_string(),
|
bind: "0.0.0.0:3000".to_string(),
|
||||||
|
db_type: DbType::SqliteInMemory,
|
||||||
pid_file: PathBuf::from("/run/chronoseal.pid"),
|
pid_file: PathBuf::from("/run/chronoseal.pid"),
|
||||||
db_path: default_state_dir().join("chronoseal.sqlite"),
|
db_path: default_state_dir().join("chronoseal.sqlite"),
|
||||||
frontend_dir: PathBuf::from("/usr/share/chronoseal/frontend"),
|
frontend_dir: PathBuf::from("/usr/share/chronoseal/frontend"),
|
||||||
@@ -44,6 +67,7 @@ impl Default for Config {
|
|||||||
max_mouse_avg_speed: 2.0,
|
max_mouse_avg_speed: 2.0,
|
||||||
min_pause_count: 1,
|
min_pause_count: 1,
|
||||||
require_mouse_activity: true,
|
require_mouse_activity: true,
|
||||||
|
gene_size: shared::constants::DEFAULT_GENE_SIZE,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -82,6 +106,9 @@ impl Config {
|
|||||||
|
|
||||||
pub fn apply_run_args(&mut self, args: &RunArgs) {
|
pub fn apply_run_args(&mut self, args: &RunArgs) {
|
||||||
self.apply_runtime_args(&args.runtime);
|
self.apply_runtime_args(&args.runtime);
|
||||||
|
if let Some(db_type) = args.db_type {
|
||||||
|
self.db_type = db_type;
|
||||||
|
}
|
||||||
if let Some(db_path) = &args.db_path {
|
if let Some(db_path) = &args.db_path {
|
||||||
self.db_path = db_path.clone();
|
self.db_path = db_path.clone();
|
||||||
}
|
}
|
||||||
@@ -100,6 +127,11 @@ impl Config {
|
|||||||
bind: self.bind.clone(),
|
bind: self.bind.clone(),
|
||||||
source,
|
source,
|
||||||
})?;
|
})?;
|
||||||
|
if !(1..=shared::constants::MAX_GENE_SIZE).contains(&self.gene_size) {
|
||||||
|
return Err(ConfigError::InvalidGeneSize {
|
||||||
|
size: self.gene_size,
|
||||||
|
});
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,6 +139,14 @@ impl Config {
|
|||||||
if let Ok(value) = env::var("CHRONOSEAL_BIND") {
|
if let Ok(value) = env::var("CHRONOSEAL_BIND") {
|
||||||
self.bind = value;
|
self.bind = value;
|
||||||
}
|
}
|
||||||
|
if let Ok(value) = env::var("CHRONOSEAL_DB_TYPE") {
|
||||||
|
self.db_type = match value.as_str() {
|
||||||
|
"sqlite-in-memory" => DbType::SqliteInMemory,
|
||||||
|
"sqlite-in-disk" => DbType::SqliteInDisk,
|
||||||
|
"valkey" => DbType::Valkey,
|
||||||
|
_ => self.db_type,
|
||||||
|
};
|
||||||
|
}
|
||||||
if let Ok(value) = env::var("CHRONOSEAL_PID_FILE") {
|
if let Ok(value) = env::var("CHRONOSEAL_PID_FILE") {
|
||||||
self.pid_file = PathBuf::from(value);
|
self.pid_file = PathBuf::from(value);
|
||||||
}
|
}
|
||||||
@@ -169,6 +209,11 @@ impl Config {
|
|||||||
self.require_mouse_activity = val;
|
self.require_mouse_activity = val;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if let Ok(value) = env::var("CHRONOSEAL_GENE_SIZE") {
|
||||||
|
if let Ok(val) = value.parse() {
|
||||||
|
self.gene_size = val;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,6 +231,9 @@ pub enum ConfigError {
|
|||||||
bind: String,
|
bind: String,
|
||||||
source: std::net::AddrParseError,
|
source: std::net::AddrParseError,
|
||||||
},
|
},
|
||||||
|
InvalidGeneSize {
|
||||||
|
size: usize,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::fmt::Display for ConfigError {
|
impl std::fmt::Display for ConfigError {
|
||||||
@@ -198,6 +246,13 @@ impl std::fmt::Display for ConfigError {
|
|||||||
Self::InvalidBind { bind, source } => {
|
Self::InvalidBind { bind, source } => {
|
||||||
write!(f, "invalid bind address {bind}: {source}")
|
write!(f, "invalid bind address {bind}: {source}")
|
||||||
}
|
}
|
||||||
|
Self::InvalidGeneSize { size } => {
|
||||||
|
write!(
|
||||||
|
f,
|
||||||
|
"invalid gene size {size}; expected 1..={}",
|
||||||
|
shared::constants::MAX_GENE_SIZE
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -238,3 +293,55 @@ fn default_state_dir() -> PathBuf {
|
|||||||
}
|
}
|
||||||
PathBuf::from("/var/lib/chronoseal")
|
PathBuf::from("/var/lib/chronoseal")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_default_db_type_is_sqlite_in_memory() {
|
||||||
|
let cfg = Config::default();
|
||||||
|
assert_eq!(cfg.db_type, DbType::SqliteInMemory);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_apply_run_args_overrides_db_type() {
|
||||||
|
let mut cfg = Config::default();
|
||||||
|
let args = crate::cli::RunArgs {
|
||||||
|
runtime: crate::cli::RuntimeArgs {
|
||||||
|
bind: None,
|
||||||
|
pid_file: None,
|
||||||
|
},
|
||||||
|
db_type: Some(DbType::SqliteInDisk),
|
||||||
|
db_path: None,
|
||||||
|
frontend_dir: None,
|
||||||
|
log_file: None,
|
||||||
|
};
|
||||||
|
cfg.apply_run_args(&args);
|
||||||
|
assert_eq!(cfg.db_type, DbType::SqliteInDisk);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_toml_parses_db_type_kebab_case() {
|
||||||
|
let raw = r#"
|
||||||
|
bind = "127.0.0.1:3000"
|
||||||
|
db_type = "valkey"
|
||||||
|
pid_file = "/tmp/pid"
|
||||||
|
db_path = "/tmp/db.sqlite"
|
||||||
|
frontend_dir = "."
|
||||||
|
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 = 1.0
|
||||||
|
max_mouse_avg_speed = 2.0
|
||||||
|
min_pause_count = 1
|
||||||
|
require_mouse_activity = true
|
||||||
|
gene_size = 512
|
||||||
|
"#;
|
||||||
|
let cfg: Config = toml::from_str(raw).unwrap();
|
||||||
|
assert_eq!(cfg.db_type, DbType::Valkey);
|
||||||
|
}
|
||||||
|
}
|
||||||
+18
-12
@@ -2,6 +2,23 @@ use ed25519_dalek::{Signature, VerifyingKey};
|
|||||||
use shared::protocol::HeartbeatRequest;
|
use shared::protocol::HeartbeatRequest;
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
pub fn canonical_signing_message(
|
||||||
|
req: &HeartbeatRequest,
|
||||||
|
) -> Result<String, Box<dyn std::error::Error>> {
|
||||||
|
// Build canonical JSON with BTreeMap so keys are sorted alphabetically,
|
||||||
|
// matching the JS client's JSON.stringify(obj, Object.keys(obj).sort()).
|
||||||
|
let mut payload: BTreeMap<&str, serde_json::Value> = BTreeMap::new();
|
||||||
|
payload.insert("entropyData", serde_json::to_value(&req.entropy_data)?);
|
||||||
|
payload.insert("fingerprint", serde_json::to_value(&req.fingerprint)?);
|
||||||
|
payload.insert("geneCommitment", serde_json::json!(req.gene_commitment));
|
||||||
|
payload.insert("mutationStep", serde_json::json!(req.mutation_step));
|
||||||
|
payload.insert("prevHash", serde_json::json!(req.prev_hash));
|
||||||
|
payload.insert("sessionId", serde_json::json!(req.session_id));
|
||||||
|
payload.insert("stackState", serde_json::to_value(&req.stack_state)?);
|
||||||
|
payload.insert("timestamp", serde_json::json!(req.timestamp));
|
||||||
|
Ok(serde_json::to_string(&payload)?)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn verify_signature(
|
pub fn verify_signature(
|
||||||
pub_key_bytes: &[u8],
|
pub_key_bytes: &[u8],
|
||||||
req: &HeartbeatRequest,
|
req: &HeartbeatRequest,
|
||||||
@@ -9,18 +26,7 @@ pub fn verify_signature(
|
|||||||
let pk = VerifyingKey::from_bytes(&pub_key_bytes.try_into().map_err(|_| "invalid pubkey")?)?;
|
let pk = VerifyingKey::from_bytes(&pub_key_bytes.try_into().map_err(|_| "invalid pubkey")?)?;
|
||||||
let sig_bytes = hex::decode(&req.signature)?;
|
let sig_bytes = hex::decode(&req.signature)?;
|
||||||
let sig = Signature::from_slice(&sig_bytes)?;
|
let sig = Signature::from_slice(&sig_bytes)?;
|
||||||
|
let message = canonical_signing_message(req)?;
|
||||||
// Build canonical JSON with BTreeMap so keys are sorted alphabetically,
|
|
||||||
// matching the JS client's JSON.stringify(obj, Object.keys(obj).sort()).
|
|
||||||
// Sorted order: entropyData, fingerprint, prevHash, sessionId, stackState, timestamp
|
|
||||||
let mut payload: BTreeMap<&str, serde_json::Value> = BTreeMap::new();
|
|
||||||
payload.insert("entropyData", serde_json::to_value(&req.entropy_data)?);
|
|
||||||
payload.insert("fingerprint", serde_json::to_value(&req.fingerprint)?);
|
|
||||||
payload.insert("prevHash", serde_json::json!(req.prev_hash));
|
|
||||||
payload.insert("sessionId", serde_json::json!(req.session_id));
|
|
||||||
payload.insert("stackState", serde_json::to_value(&req.stack_state)?);
|
|
||||||
payload.insert("timestamp", serde_json::json!(req.timestamp));
|
|
||||||
let message = serde_json::to_string(&payload)?;
|
|
||||||
|
|
||||||
pk.verify_strict(message.as_bytes(), &sig)?;
|
pk.verify_strict(message.as_bytes(), &sig)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ pub enum SessionError {
|
|||||||
|
|
||||||
#[error("Invalid public key length")]
|
#[error("Invalid public key length")]
|
||||||
InvalidPublicKeyLength,
|
InvalidPublicKeyLength,
|
||||||
|
|
||||||
|
#[error("Invalid gene configuration: {0}")]
|
||||||
|
InvalidGeneConfiguration(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl IntoResponse for SessionError {
|
impl IntoResponse for SessionError {
|
||||||
@@ -65,4 +68,16 @@ pub enum VerificationError {
|
|||||||
|
|
||||||
#[error("Fingerprint validation failed: {0}")]
|
#[error("Fingerprint validation failed: {0}")]
|
||||||
FingerprintFailed(String),
|
FingerprintFailed(String),
|
||||||
|
|
||||||
|
#[error("Mutation step mismatch: expected {expected}, got {got}")]
|
||||||
|
MutationStepMismatch { expected: u64, got: u64 },
|
||||||
|
|
||||||
|
#[error("Mutation commitment mismatch")]
|
||||||
|
MutationCommitmentMismatch,
|
||||||
|
|
||||||
|
#[error("Mutation program error: {0}")]
|
||||||
|
MutationProgram(String),
|
||||||
|
|
||||||
|
#[error("Gene state error: {0}")]
|
||||||
|
GeneState(String),
|
||||||
}
|
}
|
||||||
@@ -75,6 +75,9 @@ async fn try_main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
Some(Command::Version) => {
|
Some(Command::Version) => {
|
||||||
output::print(cli.globals.output_format(), &runtime::version())?;
|
output::print(cli.globals.output_format(), &runtime::version())?;
|
||||||
}
|
}
|
||||||
|
Some(Command::DbType) => {
|
||||||
|
output::print(cli.globals.output_format(), &runtime::db_type_report())?;
|
||||||
|
}
|
||||||
Some(Command::Metrics(args)) => {
|
Some(Command::Metrics(args)) => {
|
||||||
let mut config = Config::load(cli.globals.config.as_deref())?;
|
let mut config = Config::load(cli.globals.config.as_deref())?;
|
||||||
config.apply_runtime_args(args);
|
config.apply_runtime_args(args);
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ pub async fn handler(
|
|||||||
Json(HeartbeatResponse {
|
Json(HeartbeatResponse {
|
||||||
status: "ok".into(),
|
status: "ok".into(),
|
||||||
next_salt: None,
|
next_salt: None,
|
||||||
|
next_mutation_step: None,
|
||||||
|
next_mutation_order_b64: None,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -36,16 +38,20 @@ pub async fn handler(
|
|||||||
Json(HeartbeatResponse {
|
Json(HeartbeatResponse {
|
||||||
status: "error".into(),
|
status: "error".into(),
|
||||||
next_salt: None,
|
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(&conn, &config, &payload) {
|
||||||
Ok(next_salt) => (
|
Ok(result) => (
|
||||||
StatusCode::OK,
|
StatusCode::OK,
|
||||||
Json(HeartbeatResponse {
|
Json(HeartbeatResponse {
|
||||||
status: "ok".into(),
|
status: "ok".into(),
|
||||||
next_salt: Some(next_salt),
|
next_salt: Some(result.next_salt_hex),
|
||||||
|
next_mutation_step: Some(result.next_mutation_step),
|
||||||
|
next_mutation_order_b64: Some(result.next_mutation_order_b64),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -55,8 +61,157 @@ pub async fn handler(
|
|||||||
Json(HeartbeatResponse {
|
Json(HeartbeatResponse {
|
||||||
status: "ok".into(),
|
status: "ok".into(),
|
||||||
next_salt: None,
|
next_salt: None,
|
||||||
|
next_mutation_step: None,
|
||||||
|
next_mutation_order_b64: None,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use axum::{extract::State, Json};
|
||||||
|
use ed25519_dalek::{Signer, SigningKey};
|
||||||
|
use shared::protocol::{EntropyData, Fingerprint, InitResponse, MouseEvent, StackState};
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
fn test_config() -> crate::config::Config {
|
||||||
|
crate::config::Config {
|
||||||
|
expiration_minutes: 30,
|
||||||
|
max_timestamp_drift_ms: 30_000,
|
||||||
|
min_mouse_total_dist: 1.0,
|
||||||
|
max_mouse_avg_speed: 4.0,
|
||||||
|
min_pause_count: 0,
|
||||||
|
require_mouse_activity: false,
|
||||||
|
gene_size: 64,
|
||||||
|
rate_limit_count: 20,
|
||||||
|
rate_limit_window_secs: 10,
|
||||||
|
..crate::config::Config::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sign_request(sk: &SigningKey, req: &mut HeartbeatRequest) {
|
||||||
|
let msg = crate::crypto::canonical_signing_message(req).unwrap();
|
||||||
|
req.signature = hex::encode(sk.sign(msg.as_bytes()).to_bytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_request(
|
||||||
|
init: &InitResponse,
|
||||||
|
sk: &SigningKey,
|
||||||
|
mutation_step: u64,
|
||||||
|
mutation_order_b64: &str,
|
||||||
|
) -> HeartbeatRequest {
|
||||||
|
let entropy_data = EntropyData {
|
||||||
|
events: vec![
|
||||||
|
MouseEvent {
|
||||||
|
x: 1.0,
|
||||||
|
y: 1.0,
|
||||||
|
timestamp_ms: 1.0,
|
||||||
|
},
|
||||||
|
MouseEvent {
|
||||||
|
x: 3.0,
|
||||||
|
y: 1.0,
|
||||||
|
timestamp_ms: 2.0,
|
||||||
|
},
|
||||||
|
MouseEvent {
|
||||||
|
x: 3.0,
|
||||||
|
y: 1.0,
|
||||||
|
timestamp_ms: 120.0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
let stack_state = StackState {
|
||||||
|
stack: vec![9, 10, 11],
|
||||||
|
ip: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
let order =
|
||||||
|
shared::vm_extensions::decode_order_b64(mutation_step, mutation_order_b64).unwrap();
|
||||||
|
let committed = shared::gene::new_state(init.gene_size as usize).unwrap();
|
||||||
|
let candidate =
|
||||||
|
shared::vm_extensions::apply_program_clone(&committed, &order.program).unwrap();
|
||||||
|
|
||||||
|
let mut req = HeartbeatRequest {
|
||||||
|
session_id: init.session_id.clone(),
|
||||||
|
prev_hash: init.initial_hash.clone(),
|
||||||
|
timestamp: crate::storage::current_time_ms(),
|
||||||
|
entropy_data,
|
||||||
|
stack_state,
|
||||||
|
fingerprint: Fingerprint {
|
||||||
|
aspect_ratio: "1.77".to_string(),
|
||||||
|
device_pixel_ratio: "2.0".to_string(),
|
||||||
|
hardware_concurrency: 8,
|
||||||
|
},
|
||||||
|
mutation_step,
|
||||||
|
gene_commitment: shared::gene::commitment_hex(&candidate),
|
||||||
|
signature: String::new(),
|
||||||
|
};
|
||||||
|
sign_request(sk, &mut req);
|
||||||
|
req
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn setup_state_and_session(
|
||||||
|
config: crate::config::Config,
|
||||||
|
) -> (Arc<AppState>, InitResponse, SigningKey) {
|
||||||
|
let pool = crate::storage::init_pool(Path::new(":memory:")).unwrap();
|
||||||
|
let state = Arc::new(AppState {
|
||||||
|
db_pool: pool,
|
||||||
|
rate_limiter: tokio::sync::Mutex::new(crate::ratelimit::RateLimiter::new()),
|
||||||
|
config: std::sync::RwLock::new(config.clone()),
|
||||||
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
|
(state, init, sk)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_handler_success_returns_next_mutation_fields() {
|
||||||
|
let config = test_config();
|
||||||
|
let (state, init, sk) = setup_state_and_session(config).await;
|
||||||
|
let req = build_request(&init, &sk, init.mutation_step, &init.mutation_order_b64);
|
||||||
|
|
||||||
|
let (status, Json(body)) = handler(State(state), Json(req)).await;
|
||||||
|
assert_eq!(status, StatusCode::OK);
|
||||||
|
assert_eq!(body.status, "ok");
|
||||||
|
assert!(body.next_salt.is_some());
|
||||||
|
assert!(body.next_mutation_step.is_some());
|
||||||
|
assert!(body.next_mutation_order_b64.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_handler_tampered_commitment_is_silent_failure() {
|
||||||
|
let config = test_config();
|
||||||
|
let (state, init, sk) = setup_state_and_session(config).await;
|
||||||
|
let mut req = build_request(&init, &sk, init.mutation_step, &init.mutation_order_b64);
|
||||||
|
req.gene_commitment = "00".repeat(32);
|
||||||
|
sign_request(&sk, &mut req);
|
||||||
|
|
||||||
|
let (status, Json(body)) = handler(State(state), Json(req)).await;
|
||||||
|
assert_eq!(status, StatusCode::OK);
|
||||||
|
assert_eq!(body.status, "ok");
|
||||||
|
assert!(body.next_salt.is_none());
|
||||||
|
assert!(body.next_mutation_step.is_none());
|
||||||
|
assert!(body.next_mutation_order_b64.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_handler_rate_limit_returns_no_mutation_data() {
|
||||||
|
let mut config = test_config();
|
||||||
|
config.rate_limit_count = 0;
|
||||||
|
let (state, init, sk) = setup_state_and_session(config).await;
|
||||||
|
let req = build_request(&init, &sk, init.mutation_step, &init.mutation_order_b64);
|
||||||
|
|
||||||
|
let (status, Json(body)) = handler(State(state), Json(req)).await;
|
||||||
|
assert_eq!(status, StatusCode::OK);
|
||||||
|
assert_eq!(body.status, "ok");
|
||||||
|
assert!(body.next_salt.is_none());
|
||||||
|
assert!(body.next_mutation_step.is_none());
|
||||||
|
assert!(body.next_mutation_order_b64.is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
+142
-3
@@ -80,18 +80,51 @@ impl TextOutput for KeypairReport {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct DbTypeEntry {
|
||||||
|
pub name: &'static str,
|
||||||
|
pub implemented: bool,
|
||||||
|
pub notes: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct DbTypeReport {
|
||||||
|
pub default: &'static str,
|
||||||
|
pub backends: Vec<DbTypeEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TextOutput for DbTypeReport {
|
||||||
|
fn to_text(&self) -> String {
|
||||||
|
let mut out = format!("default={}\n", self.default);
|
||||||
|
for backend in &self.backends {
|
||||||
|
let status = if backend.implemented {
|
||||||
|
"implemented"
|
||||||
|
} else {
|
||||||
|
"todo"
|
||||||
|
};
|
||||||
|
out.push_str(&format!(
|
||||||
|
"db_type={} status={} notes={}\n",
|
||||||
|
backend.name, status, backend.notes
|
||||||
|
));
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl TextOutput for Config {
|
impl TextOutput for Config {
|
||||||
fn to_text(&self) -> String {
|
fn to_text(&self) -> String {
|
||||||
format!(
|
format!(
|
||||||
"bind={}\npid_file={}\ndb_path={}\nfrontend_dir={}\nlog_file={}",
|
"bind={}\ndb_type={}\npid_file={}\ndb_path={}\nfrontend_dir={}\nlog_file={}\ngene_size={}",
|
||||||
self.bind,
|
self.bind,
|
||||||
|
self.db_type.as_str(),
|
||||||
self.pid_file.display(),
|
self.pid_file.display(),
|
||||||
self.db_path.display(),
|
self.db_path.display(),
|
||||||
self.frontend_dir.display(),
|
self.frontend_dir.display(),
|
||||||
self.log_file
|
self.log_file
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|path| path.display().to_string())
|
.map(|path| path.display().to_string())
|
||||||
.unwrap_or_else(|| "none".to_string())
|
.unwrap_or_else(|| "none".to_string()),
|
||||||
|
self.gene_size
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -108,7 +141,7 @@ impl TextOutput for StoreStats {
|
|||||||
pub async fn run_daemon(config: Config) -> Result<(), Box<dyn std::error::Error>> {
|
pub async fn run_daemon(config: Config) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
install_pid_file(&config.pid_file)?;
|
install_pid_file(&config.pid_file)?;
|
||||||
|
|
||||||
let db_pool = storage::init_pool(&config.db_path)?;
|
let db_pool = init_db_pool(&config)?;
|
||||||
let state = Arc::new(session::AppState {
|
let state = Arc::new(session::AppState {
|
||||||
db_pool,
|
db_pool,
|
||||||
rate_limiter: Mutex::new(RateLimiter::new()),
|
rate_limiter: Mutex::new(RateLimiter::new()),
|
||||||
@@ -147,6 +180,40 @@ pub async fn run_daemon(config: Config) -> Result<(), Box<dyn std::error::Error>
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn db_type_report() -> DbTypeReport {
|
||||||
|
DbTypeReport {
|
||||||
|
default: crate::config::DbType::SqliteInMemory.as_str(),
|
||||||
|
backends: vec![
|
||||||
|
DbTypeEntry {
|
||||||
|
name: crate::config::DbType::SqliteInMemory.as_str(),
|
||||||
|
implemented: true,
|
||||||
|
notes: "default runtime backend",
|
||||||
|
},
|
||||||
|
DbTypeEntry {
|
||||||
|
name: crate::config::DbType::SqliteInDisk.as_str(),
|
||||||
|
implemented: true,
|
||||||
|
notes: "persistent SQLite backend (uses --db-path)",
|
||||||
|
},
|
||||||
|
DbTypeEntry {
|
||||||
|
name: crate::config::DbType::Valkey.as_str(),
|
||||||
|
implemented: true,
|
||||||
|
notes: "compatibility mode: falls back to sqlite-in-memory",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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:"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn probe_health(config: &Config) -> HealthReport {
|
pub fn probe_health(config: &Config) -> HealthReport {
|
||||||
if http_get(&config.bind, "/health").is_ok() {
|
if http_get(&config.bind, "/health").is_ok() {
|
||||||
HealthReport {
|
HealthReport {
|
||||||
@@ -339,3 +406,75 @@ fn http_get(bind: &str, path: &str) -> Result<String, Box<dyn std::error::Error>
|
|||||||
.ok_or("daemon returned an invalid HTTP response")?;
|
.ok_or("daemon returned an invalid HTTP response")?;
|
||||||
Ok(body.to_string())
|
Ok(body.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn base_config() -> Config {
|
||||||
|
Config {
|
||||||
|
bind: "127.0.0.1:0".to_string(),
|
||||||
|
db_type: crate::config::DbType::SqliteInMemory,
|
||||||
|
pid_file: std::path::PathBuf::from("/tmp/chronoseal-test.pid"),
|
||||||
|
db_path: std::path::PathBuf::from("/tmp/chronoseal-test.sqlite"),
|
||||||
|
frontend_dir: std::path::PathBuf::from("."),
|
||||||
|
log_file: None,
|
||||||
|
heartbeat_min_interval_ms: 12_000,
|
||||||
|
heartbeat_max_interval_ms: 25_000,
|
||||||
|
expiration_minutes: 30,
|
||||||
|
rate_limit_count: 5,
|
||||||
|
rate_limit_window_secs: 10,
|
||||||
|
max_timestamp_drift_ms: 30_000,
|
||||||
|
min_mouse_total_dist: 1.0,
|
||||||
|
max_mouse_avg_speed: 5.0,
|
||||||
|
min_pause_count: 0,
|
||||||
|
require_mouse_activity: false,
|
||||||
|
gene_size: shared::constants::DEFAULT_GENE_SIZE,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_db_type_report_lists_backends() {
|
||||||
|
let report = db_type_report();
|
||||||
|
assert_eq!(report.default, "sqlite-in-memory");
|
||||||
|
assert_eq!(report.backends.len(), 3);
|
||||||
|
assert!(report.backends.iter().any(|b| b.name == "valkey"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_init_db_pool_sqlite_in_disk() {
|
||||||
|
let mut config = base_config();
|
||||||
|
config.db_type = crate::config::DbType::SqliteInDisk;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_init_db_pool_valkey_compat_mode() {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+476
-91
@@ -16,7 +16,18 @@ impl AppState {
|
|||||||
|
|
||||||
use crate::{crypto, fingerprint, storage, trust, vm};
|
use crate::{crypto, fingerprint, storage, trust, vm};
|
||||||
use rusqlite::params;
|
use rusqlite::params;
|
||||||
use shared::protocol::{HeartbeatRequest, InitResponse};
|
use shared::{
|
||||||
|
gene::{self, GeneState},
|
||||||
|
protocol::{HeartbeatRequest, InitResponse},
|
||||||
|
vm_extensions,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct HeartbeatVerificationResult {
|
||||||
|
pub next_salt_hex: String,
|
||||||
|
pub next_mutation_step: u64,
|
||||||
|
pub next_mutation_order_b64: String,
|
||||||
|
}
|
||||||
|
|
||||||
pub fn create_session(
|
pub fn create_session(
|
||||||
conn: &rusqlite::Connection,
|
conn: &rusqlite::Connection,
|
||||||
@@ -27,22 +38,44 @@ pub fn create_session(
|
|||||||
if pub_key.len() != shared::constants::SESSION_ID_LEN {
|
if pub_key.len() != shared::constants::SESSION_ID_LEN {
|
||||||
return Err(crate::errors::SessionError::InvalidPublicKeyLength);
|
return Err(crate::errors::SessionError::InvalidPublicKeyLength);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let gene_state = gene::new_state(config.gene_size)
|
||||||
|
.map_err(|err| crate::errors::SessionError::InvalidGeneConfiguration(err.to_string()))?;
|
||||||
|
let environment_blob = gene::encode_environment(&gene_state.environment)
|
||||||
|
.map_err(|err| crate::errors::SessionError::InvalidGeneConfiguration(err.to_string()))?;
|
||||||
|
|
||||||
let session_id = hex::encode(rand::random::<[u8; shared::constants::SESSION_ID_LEN]>());
|
let session_id = hex::encode(rand::random::<[u8; shared::constants::SESSION_ID_LEN]>());
|
||||||
let salt = rand::random::<[u8; shared::constants::SALT_LEN]>();
|
let salt = rand::random::<[u8; shared::constants::SALT_LEN]>();
|
||||||
let now = storage::current_time_ms();
|
let now = storage::current_time_ms();
|
||||||
let expires_at = now + (config.expiration_minutes as u64) * 60 * 1000;
|
let expires_at = now + (config.expiration_minutes as u64) * 60 * 1000;
|
||||||
|
|
||||||
let initial_hash = shared::hashing::initial_hash(&session_id, &pub_key, &salt);
|
let initial_hash = shared::hashing::initial_hash(&session_id, &pub_key, &salt);
|
||||||
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO sessions (session_id, public_key, salt, last_hash, created_at, last_seen, expires_at)
|
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
|
||||||
params![session_id, pub_key, salt.to_vec(), initial_hash, now, now, expires_at],
|
|
||||||
)?;
|
|
||||||
|
|
||||||
let opcodes = vm::generate_random_program(8..=16);
|
let opcodes = vm::generate_random_program(8..=16);
|
||||||
let opcodes_b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &opcodes);
|
let opcodes_b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &opcodes);
|
||||||
|
|
||||||
|
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,
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
|
||||||
Ok(InitResponse {
|
Ok(InitResponse {
|
||||||
session_id,
|
session_id,
|
||||||
salt: hex::encode(salt),
|
salt: hex::encode(salt),
|
||||||
@@ -51,6 +84,9 @@ pub fn create_session(
|
|||||||
expires_at,
|
expires_at,
|
||||||
heartbeat_min_interval_ms: config.heartbeat_min_interval_ms,
|
heartbeat_min_interval_ms: config.heartbeat_min_interval_ms,
|
||||||
heartbeat_max_interval_ms: config.heartbeat_max_interval_ms,
|
heartbeat_max_interval_ms: config.heartbeat_max_interval_ms,
|
||||||
|
gene_size: config.gene_size as u32,
|
||||||
|
mutation_step: initial_mutation.step,
|
||||||
|
mutation_order_b64: initial_mutation_b64,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,13 +94,41 @@ pub fn verify_heartbeat(
|
|||||||
conn: &rusqlite::Connection,
|
conn: &rusqlite::Connection,
|
||||||
config: &crate::config::Config,
|
config: &crate::config::Config,
|
||||||
req: &HeartbeatRequest,
|
req: &HeartbeatRequest,
|
||||||
) -> Result<String, crate::errors::VerificationError> {
|
) -> Result<HeartbeatVerificationResult, crate::errors::VerificationError> {
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT public_key, salt, last_hash, expires_at FROM sessions WHERE session_id = ?1",
|
"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): (Vec<u8>, Vec<u8>, Vec<u8>, u64) = stmt
|
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| {
|
.query_row(params![req.session_id], |row| {
|
||||||
Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
|
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| {
|
.map_err(|e| {
|
||||||
if matches!(e, rusqlite::Error::QueryReturnedNoRows) {
|
if matches!(e, rusqlite::Error::QueryReturnedNoRows) {
|
||||||
@@ -84,24 +148,45 @@ pub fn verify_heartbeat(
|
|||||||
.map_err(|e| crate::errors::VerificationError::Signature(e.to_string()))?;
|
.map_err(|e| crate::errors::VerificationError::Signature(e.to_string()))?;
|
||||||
|
|
||||||
// 2. Check chain continuity
|
// 2. Check chain continuity
|
||||||
if stored_last_hash != hex::decode(&req.prev_hash)? {
|
let prev_hash_bytes = hex::decode(&req.prev_hash)?;
|
||||||
|
if stored_last_hash != prev_hash_bytes {
|
||||||
return Err(crate::errors::VerificationError::ChainBroken);
|
return Err(crate::errors::VerificationError::ChainBroken);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Time window
|
// 3. Mutation step and deterministic mutation parity
|
||||||
|
if req.mutation_step != pending_step {
|
||||||
|
return Err(crate::errors::VerificationError::MutationStepMismatch {
|
||||||
|
expected: pending_step,
|
||||||
|
got: req.mutation_step,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let environment = gene::decode_environment(&environment_blob)
|
||||||
|
.map_err(|e| crate::errors::VerificationError::GeneState(e.to_string()))?;
|
||||||
|
let server_state = GeneState {
|
||||||
|
gene: gene_blob,
|
||||||
|
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);
|
||||||
|
if req.gene_commitment != expected_gene_commitment {
|
||||||
|
return Err(crate::errors::VerificationError::MutationCommitmentMismatch);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Time window
|
||||||
let diff = (now as i64) - (req.timestamp as i64);
|
let diff = (now as i64) - (req.timestamp as i64);
|
||||||
if diff.abs() > config.max_timestamp_drift_ms {
|
if diff.abs() > config.max_timestamp_drift_ms {
|
||||||
return Err(crate::errors::VerificationError::TimestampDrift);
|
return Err(crate::errors::VerificationError::TimestampDrift);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Trusted mouse & fingerprint
|
// 5. Trusted mouse & fingerprint
|
||||||
trust::validate_mouse(&req.entropy_data, config)
|
trust::validate_mouse(&req.entropy_data, config)
|
||||||
.map_err(|e| crate::errors::VerificationError::TrustFailed(e.to_string()))?;
|
.map_err(|e| crate::errors::VerificationError::TrustFailed(e.to_string()))?;
|
||||||
fingerprint::validate(&req.fingerprint)
|
fingerprint::validate(&req.fingerprint)
|
||||||
.map_err(|e| crate::errors::VerificationError::FingerprintFailed(e.to_string()))?;
|
.map_err(|e| crate::errors::VerificationError::FingerprintFailed(e.to_string()))?;
|
||||||
|
|
||||||
// 5. Compute new hash
|
// 6. Compute new hash
|
||||||
let prev_hash_bytes = hex::decode(&req.prev_hash)?;
|
|
||||||
let new_hash = shared::hashing::next_chain_hash(
|
let new_hash = shared::hashing::next_chain_hash(
|
||||||
&prev_hash_bytes,
|
&prev_hash_bytes,
|
||||||
req.timestamp,
|
req.timestamp,
|
||||||
@@ -110,115 +195,415 @@ pub fn verify_heartbeat(
|
|||||||
&salt,
|
&salt,
|
||||||
);
|
);
|
||||||
|
|
||||||
// 6. New salt for client
|
// 7. Prepare next mutation order and salt
|
||||||
|
let next_step = pending_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);
|
||||||
|
|
||||||
let next_salt = rand::random::<[u8; shared::constants::SALT_LEN]>();
|
let next_salt = rand::random::<[u8; shared::constants::SALT_LEN]>();
|
||||||
let next_salt_hex = hex::encode(next_salt);
|
let next_salt_hex = hex::encode(next_salt);
|
||||||
|
let next_environment_blob = gene::encode_environment(&candidate_state.environment)
|
||||||
|
.map_err(|e| crate::errors::VerificationError::GeneState(e.to_string()))?;
|
||||||
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE sessions SET last_hash=?1, salt=?2, chain_length=chain_length+1, last_seen=?3 WHERE session_id=?4",
|
"UPDATE sessions SET
|
||||||
params![new_hash, next_salt.to_vec(), now, req.session_id],
|
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
|
||||||
|
],
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
Ok(next_salt_hex)
|
Ok(HeartbeatVerificationResult {
|
||||||
|
next_salt_hex,
|
||||||
|
next_mutation_step: next_step,
|
||||||
|
next_mutation_order_b64: next_mutation_b64,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use ed25519_dalek::{Signer, SigningKey};
|
use ed25519_dalek::{Signer, SigningKey};
|
||||||
use shared::protocol::{EntropyData, Fingerprint, HeartbeatRequest, StackState};
|
use shared::protocol::{EntropyData, Fingerprint, HeartbeatRequest, MouseEvent, StackState};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct SimulatedClient {
|
||||||
|
signing_key: SigningKey,
|
||||||
|
session_id: String,
|
||||||
|
prev_hash: String,
|
||||||
|
current_salt: String,
|
||||||
|
pending_mutation_step: u64,
|
||||||
|
pending_mutation_order_b64: String,
|
||||||
|
committed_gene_state: GeneState,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn test_config() -> crate::config::Config {
|
||||||
|
crate::config::Config {
|
||||||
|
expiration_minutes: 30,
|
||||||
|
max_timestamp_drift_ms: 30_000,
|
||||||
|
min_mouse_total_dist: 1.0,
|
||||||
|
max_mouse_avg_speed: 4.0,
|
||||||
|
min_pause_count: 0,
|
||||||
|
require_mouse_activity: false,
|
||||||
|
gene_size: 64,
|
||||||
|
..crate::config::Config::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn test_entropy() -> EntropyData {
|
||||||
|
EntropyData {
|
||||||
|
events: vec![
|
||||||
|
MouseEvent {
|
||||||
|
x: 1.0,
|
||||||
|
y: 1.0,
|
||||||
|
timestamp_ms: 1.0,
|
||||||
|
},
|
||||||
|
MouseEvent {
|
||||||
|
x: 2.0,
|
||||||
|
y: 1.0,
|
||||||
|
timestamp_ms: 2.0,
|
||||||
|
},
|
||||||
|
MouseEvent {
|
||||||
|
x: 2.0,
|
||||||
|
y: 1.0,
|
||||||
|
timestamp_ms: 120.0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn test_stack() -> StackState {
|
||||||
|
StackState {
|
||||||
|
stack: vec![42, 7, 99],
|
||||||
|
ip: 3,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn test_fingerprint() -> Fingerprint {
|
||||||
|
Fingerprint {
|
||||||
|
aspect_ratio: "1.77".to_string(),
|
||||||
|
device_pixel_ratio: "2.0".to_string(),
|
||||||
|
hardware_concurrency: 8,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn sign_request(sk: &SigningKey, req: &mut HeartbeatRequest) {
|
fn sign_request(sk: &SigningKey, req: &mut HeartbeatRequest) {
|
||||||
let mut payload: std::collections::BTreeMap<&str, serde_json::Value> =
|
let message = crate::crypto::canonical_signing_message(req).unwrap();
|
||||||
std::collections::BTreeMap::new();
|
|
||||||
payload.insert(
|
|
||||||
"entropyData",
|
|
||||||
serde_json::to_value(&req.entropy_data).unwrap(),
|
|
||||||
);
|
|
||||||
payload.insert(
|
|
||||||
"fingerprint",
|
|
||||||
serde_json::to_value(&req.fingerprint).unwrap(),
|
|
||||||
);
|
|
||||||
payload.insert("prevHash", serde_json::json!(req.prev_hash));
|
|
||||||
payload.insert("sessionId", serde_json::json!(req.session_id));
|
|
||||||
payload.insert(
|
|
||||||
"stackState",
|
|
||||||
serde_json::to_value(&req.stack_state).unwrap(),
|
|
||||||
);
|
|
||||||
payload.insert("timestamp", serde_json::json!(req.timestamp));
|
|
||||||
let message = serde_json::to_string(&payload).unwrap();
|
|
||||||
let sig = sk.sign(message.as_bytes());
|
let sig = sk.sign(message.as_bytes());
|
||||||
req.signature = hex::encode(sig.to_bytes());
|
req.signature = hex::encode(sig.to_bytes());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn create_test_session(
|
||||||
|
conn: &rusqlite::Connection,
|
||||||
|
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();
|
||||||
|
(init, sk)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn client_from_init(init: &InitResponse, signing_key: SigningKey) -> SimulatedClient {
|
||||||
|
SimulatedClient {
|
||||||
|
signing_key,
|
||||||
|
session_id: init.session_id.clone(),
|
||||||
|
prev_hash: init.initial_hash.clone(),
|
||||||
|
current_salt: init.salt.clone(),
|
||||||
|
pending_mutation_step: init.mutation_step,
|
||||||
|
pending_mutation_order_b64: init.mutation_order_b64.clone(),
|
||||||
|
committed_gene_state: gene::new_state(init.gene_size as usize).unwrap(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_request(
|
||||||
|
client: &SimulatedClient,
|
||||||
|
timestamp: u64,
|
||||||
|
) -> (HeartbeatRequest, GeneState, EntropyData, StackState) {
|
||||||
|
let order = vm_extensions::decode_order_b64(
|
||||||
|
client.pending_mutation_step,
|
||||||
|
&client.pending_mutation_order_b64,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let candidate_state =
|
||||||
|
vm_extensions::apply_program_clone(&client.committed_gene_state, &order.program)
|
||||||
|
.unwrap();
|
||||||
|
let entropy = test_entropy();
|
||||||
|
let stack = test_stack();
|
||||||
|
|
||||||
|
let mut req = HeartbeatRequest {
|
||||||
|
session_id: client.session_id.clone(),
|
||||||
|
prev_hash: client.prev_hash.clone(),
|
||||||
|
timestamp,
|
||||||
|
entropy_data: entropy.clone(),
|
||||||
|
stack_state: stack.clone(),
|
||||||
|
fingerprint: test_fingerprint(),
|
||||||
|
mutation_step: client.pending_mutation_step,
|
||||||
|
gene_commitment: gene::commitment_hex(&candidate_state),
|
||||||
|
signature: String::new(),
|
||||||
|
};
|
||||||
|
sign_request(&client.signing_key, &mut req);
|
||||||
|
(req, candidate_state, entropy, stack)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_successful_response(
|
||||||
|
client: &mut SimulatedClient,
|
||||||
|
req: &HeartbeatRequest,
|
||||||
|
candidate_state: GeneState,
|
||||||
|
entropy: &EntropyData,
|
||||||
|
stack: &StackState,
|
||||||
|
resp: &HeartbeatVerificationResult,
|
||||||
|
) {
|
||||||
|
let salt = hex::decode(&client.current_salt).unwrap();
|
||||||
|
let prev_hash = hex::decode(&req.prev_hash).unwrap();
|
||||||
|
let next_hash =
|
||||||
|
shared::hashing::next_chain_hash(&prev_hash, req.timestamp, entropy, stack, &salt);
|
||||||
|
|
||||||
|
client.prev_hash = hex::encode(next_hash);
|
||||||
|
client.current_salt = resp.next_salt_hex.clone();
|
||||||
|
client.pending_mutation_step = resp.next_mutation_step;
|
||||||
|
client.pending_mutation_order_b64 = resp.next_mutation_order_b64.clone();
|
||||||
|
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();
|
||||||
|
GeneState {
|
||||||
|
gene: gene_blob,
|
||||||
|
environment: gene::decode_environment(&env_blob).unwrap(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_successful_heartbeat(
|
||||||
|
conn: &rusqlite::Connection,
|
||||||
|
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();
|
||||||
|
apply_successful_response(client, &req, candidate_state, &entropy, &stack, &result);
|
||||||
|
req
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_session_lifecycle_and_verification() {
|
fn test_session_lifecycle_and_verification() {
|
||||||
let pool = storage::init_pool(Path::new(":memory:")).unwrap();
|
let pool = storage::init_pool(Path::new(":memory:")).unwrap();
|
||||||
let conn = pool.get().unwrap();
|
let conn = pool.get().unwrap();
|
||||||
|
let config = test_config();
|
||||||
|
|
||||||
let config = crate::config::Config {
|
let (init, signing_key) = create_test_session(&conn, &config);
|
||||||
expiration_minutes: 30,
|
assert_eq!(init.gene_size, config.gene_size as u32);
|
||||||
max_timestamp_drift_ms: 30000,
|
assert!(!init.mutation_order_b64.is_empty());
|
||||||
min_mouse_total_dist: 10.0,
|
assert_eq!(init.mutation_step, 1);
|
||||||
max_mouse_avg_speed: 2.0,
|
|
||||||
min_pause_count: 1,
|
|
||||||
require_mouse_activity: false, // simpler for tests
|
|
||||||
..crate::config::Config::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
// Generate Ed25519 keypair
|
let mut client = client_from_init(&init, signing_key);
|
||||||
let mut rng = rand::thread_rng();
|
for _ in 0..5 {
|
||||||
let sk = SigningKey::generate(&mut rng);
|
run_successful_heartbeat(&conn, &config, &mut client);
|
||||||
let pk = sk.verifying_key();
|
}
|
||||||
let pub_key_hex = hex::encode(pk.to_bytes());
|
|
||||||
|
|
||||||
// 1. Create Session
|
|
||||||
let start_time = storage::current_time_ms();
|
|
||||||
let init_resp = create_session(&conn, &config, &pub_key_hex).unwrap();
|
|
||||||
assert!(init_resp.expires_at >= start_time + 30 * 60 * 1000);
|
|
||||||
assert!(init_resp.expires_at <= storage::current_time_ms() + 30 * 60 * 1000);
|
|
||||||
|
|
||||||
// Verify stats
|
|
||||||
let stats = storage::stats(&conn).unwrap();
|
let stats = storage::stats(&conn).unwrap();
|
||||||
assert_eq!(stats.sessions, 1);
|
assert_eq!(stats.sessions, 1);
|
||||||
assert_eq!(stats.expired_sessions, 0);
|
assert_eq!(stats.max_chain_length, 6);
|
||||||
|
}
|
||||||
|
|
||||||
// 2. Heartbeat Verification
|
#[test]
|
||||||
let now = storage::current_time_ms();
|
fn test_deterministic_server_client_parity_across_many_heartbeats() {
|
||||||
let entropy_data = EntropyData { events: vec![] };
|
let pool = storage::init_pool(Path::new(":memory:")).unwrap();
|
||||||
let stack_state = StackState {
|
let conn = pool.get().unwrap();
|
||||||
stack: vec![42],
|
let config = test_config();
|
||||||
ip: 5,
|
let (init, signing_key) = create_test_session(&conn, &config);
|
||||||
};
|
let mut client = client_from_init(&init, signing_key);
|
||||||
let fingerprint = Fingerprint {
|
|
||||||
aspect_ratio: "1.77".to_string(),
|
|
||||||
device_pixel_ratio: "2.0".to_string(),
|
|
||||||
hardware_concurrency: 8,
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut req = HeartbeatRequest {
|
for _ in 0..12 {
|
||||||
session_id: init_resp.session_id.clone(),
|
run_successful_heartbeat(&conn, &config, &mut client);
|
||||||
prev_hash: init_resp.initial_hash.clone(),
|
let server_state = load_server_gene_state(&conn, &client.session_id);
|
||||||
timestamp: now,
|
assert_eq!(server_state, client.committed_gene_state);
|
||||||
entropy_data,
|
}
|
||||||
stack_state,
|
}
|
||||||
fingerprint,
|
|
||||||
signature: "".to_string(),
|
|
||||||
};
|
|
||||||
|
|
||||||
sign_request(&sk, &mut req);
|
#[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 mut client = client_from_init(&init, signing_key);
|
||||||
|
|
||||||
// Verify successful heartbeat
|
let timestamp = storage::current_time_ms();
|
||||||
let next_salt = verify_heartbeat(&conn, &config, &req).unwrap();
|
let (req, candidate_state, entropy, stack) = build_request(&client, timestamp);
|
||||||
assert!(!next_salt.is_empty());
|
let result = verify_heartbeat(&conn, &config, &req).unwrap();
|
||||||
|
apply_successful_response(
|
||||||
|
&mut client,
|
||||||
|
&req,
|
||||||
|
candidate_state,
|
||||||
|
&entropy,
|
||||||
|
&stack,
|
||||||
|
&result,
|
||||||
|
);
|
||||||
|
|
||||||
// Try duplicate/broken hash chain (prev_hash unchanged but expected next hash in DB)
|
let replay = verify_heartbeat(&conn, &config, &req);
|
||||||
let res = verify_heartbeat(&conn, &config, &req);
|
|
||||||
assert!(res.is_err());
|
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
res.unwrap_err(),
|
replay.unwrap_err(),
|
||||||
crate::errors::VerificationError::ChainBroken
|
crate::errors::VerificationError::ChainBroken
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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 client = client_from_init(&init, signing_key);
|
||||||
|
|
||||||
|
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();
|
||||||
|
assert!(matches!(
|
||||||
|
err,
|
||||||
|
crate::errors::VerificationError::MutationStepMismatch { .. }
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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 client = client_from_init(&init, signing_key);
|
||||||
|
|
||||||
|
let timestamp = storage::current_time_ms();
|
||||||
|
let (mut req, _, _, _) = build_request(&client, timestamp);
|
||||||
|
req.gene_commitment = "00".repeat(32);
|
||||||
|
sign_request(&client.signing_key, &mut req);
|
||||||
|
|
||||||
|
let err = verify_heartbeat(&conn, &config, &req).unwrap_err();
|
||||||
|
assert!(matches!(
|
||||||
|
err,
|
||||||
|
crate::errors::VerificationError::MutationCommitmentMismatch
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_malformed_server_mutation_program_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 client = client_from_init(&init, signing_key);
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE sessions SET pending_mutation=?1 WHERE session_id=?2",
|
||||||
|
params![vec![0xFFu8], client.session_id.clone()],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let timestamp = storage::current_time_ms();
|
||||||
|
let (req, _, _, _) = build_request(&client, timestamp);
|
||||||
|
let err = verify_heartbeat(&conn, &config, &req).unwrap_err();
|
||||||
|
assert!(matches!(
|
||||||
|
err,
|
||||||
|
crate::errors::VerificationError::MutationProgram(_)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_expired_session_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 client = client_from_init(&init, signing_key);
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE sessions SET expires_at=?1 WHERE session_id=?2",
|
||||||
|
params![0u64, client.session_id.clone()],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let timestamp = storage::current_time_ms();
|
||||||
|
let (req, _, _, _) = build_request(&client, timestamp);
|
||||||
|
let err = verify_heartbeat(&conn, &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();
|
||||||
|
assert!(matches!(
|
||||||
|
err,
|
||||||
|
crate::errors::SessionError::InvalidPublicKeyLength
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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 mut client = client_from_init(&init, signing_key);
|
||||||
|
|
||||||
|
run_successful_heartbeat(&conn, &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();
|
||||||
|
assert!(matches!(
|
||||||
|
err,
|
||||||
|
crate::errors::VerificationError::MutationStepMismatch { .. }
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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 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);
|
||||||
|
assert_eq!(
|
||||||
|
gene::commitment(&server_state),
|
||||||
|
gene::commitment(&client.committed_gene_state)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+46
-1
@@ -38,9 +38,54 @@ fn init_schema(conn: &rusqlite::Connection) -> Result<(), rusqlite::Error> {
|
|||||||
chain_length INTEGER NOT NULL DEFAULT 1,
|
chain_length INTEGER NOT NULL DEFAULT 1,
|
||||||
created_at INTEGER NOT NULL,
|
created_at INTEGER NOT NULL,
|
||||||
last_seen INTEGER NOT NULL,
|
last_seen INTEGER NOT NULL,
|
||||||
expires_at INTEGER NOT NULL
|
expires_at INTEGER NOT NULL,
|
||||||
|
gene BLOB NOT NULL DEFAULT X'',
|
||||||
|
environment BLOB NOT NULL DEFAULT X'',
|
||||||
|
pending_mutation BLOB NOT NULL DEFAULT X'',
|
||||||
|
pending_mutation_step INTEGER NOT NULL DEFAULT 0
|
||||||
);",
|
);",
|
||||||
)?;
|
)?;
|
||||||
|
ensure_column(
|
||||||
|
conn,
|
||||||
|
"gene",
|
||||||
|
"ALTER TABLE sessions ADD COLUMN gene BLOB NOT NULL DEFAULT X''",
|
||||||
|
)?;
|
||||||
|
ensure_column(
|
||||||
|
conn,
|
||||||
|
"environment",
|
||||||
|
"ALTER TABLE sessions ADD COLUMN environment BLOB NOT NULL DEFAULT X''",
|
||||||
|
)?;
|
||||||
|
ensure_column(
|
||||||
|
conn,
|
||||||
|
"pending_mutation",
|
||||||
|
"ALTER TABLE sessions ADD COLUMN pending_mutation BLOB NOT NULL DEFAULT X''",
|
||||||
|
)?;
|
||||||
|
ensure_column(
|
||||||
|
conn,
|
||||||
|
"pending_mutation_step",
|
||||||
|
"ALTER TABLE sessions ADD COLUMN pending_mutation_step INTEGER NOT NULL DEFAULT 0",
|
||||||
|
)?;
|
||||||
|
conn.execute_batch(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at);",
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_column(
|
||||||
|
conn: &rusqlite::Connection,
|
||||||
|
column: &str,
|
||||||
|
alter_sql: &str,
|
||||||
|
) -> Result<(), rusqlite::Error> {
|
||||||
|
let exists: bool = conn.query_row(
|
||||||
|
"SELECT EXISTS(
|
||||||
|
SELECT 1 FROM pragma_table_info('sessions') WHERE name = ?1
|
||||||
|
)",
|
||||||
|
[column],
|
||||||
|
|row| row.get(0),
|
||||||
|
)?;
|
||||||
|
if !exists {
|
||||||
|
conn.execute_batch(alter_sql)?;
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+53
-3
@@ -1,4 +1,8 @@
|
|||||||
use rand::Rng;
|
use rand::Rng;
|
||||||
|
use shared::{
|
||||||
|
gene::GeneState,
|
||||||
|
vm_extensions::{self, ExecutionTrace, MutationError, MutationOrder},
|
||||||
|
};
|
||||||
|
|
||||||
pub fn generate_random_program(len_range: std::ops::RangeInclusive<usize>) -> Vec<u8> {
|
pub fn generate_random_program(len_range: std::ops::RangeInclusive<usize>) -> Vec<u8> {
|
||||||
let mut rng = rand::thread_rng();
|
let mut rng = rand::thread_rng();
|
||||||
@@ -9,7 +13,7 @@ pub fn generate_random_program(len_range: std::ops::RangeInclusive<usize>) -> Ve
|
|||||||
if depth < 2 {
|
if depth < 2 {
|
||||||
// Not enough operands for any binary op — push a literal.
|
// Not enough operands for any binary op — push a literal.
|
||||||
ops.push(0x00);
|
ops.push(0x00);
|
||||||
let val = rng.gen::<u32>();
|
let val = rng.r#gen::<u32>();
|
||||||
ops.extend_from_slice(&val.to_le_bytes());
|
ops.extend_from_slice(&val.to_le_bytes());
|
||||||
depth += 1;
|
depth += 1;
|
||||||
} else {
|
} else {
|
||||||
@@ -18,7 +22,7 @@ pub fn generate_random_program(len_range: std::ops::RangeInclusive<usize>) -> Ve
|
|||||||
0x00 => {
|
0x00 => {
|
||||||
// PUSH literal
|
// PUSH literal
|
||||||
ops.push(0x00);
|
ops.push(0x00);
|
||||||
let val = rng.gen::<u32>();
|
let val = rng.r#gen::<u32>();
|
||||||
ops.extend_from_slice(&val.to_le_bytes());
|
ops.extend_from_slice(&val.to_le_bytes());
|
||||||
depth += 1;
|
depth += 1;
|
||||||
}
|
}
|
||||||
@@ -43,4 +47,50 @@ pub fn generate_random_program(len_range: std::ops::RangeInclusive<usize>) -> Ve
|
|||||||
ops
|
ops
|
||||||
}
|
}
|
||||||
|
|
||||||
// Server does not need to execute the program; client does.
|
pub fn execute_mutation_program(
|
||||||
|
state: &mut GeneState,
|
||||||
|
program: &[u8],
|
||||||
|
) -> Result<ExecutionTrace, MutationError> {
|
||||||
|
vm_extensions::execute_program(state, program)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn execute_mutation_order(
|
||||||
|
state: &mut GeneState,
|
||||||
|
order: &MutationOrder,
|
||||||
|
) -> Result<ExecutionTrace, MutationError> {
|
||||||
|
vm_extensions::execute_program(state, &order.program)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use rand::SeedableRng;
|
||||||
|
use shared::gene::{commitment, new_state};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_execute_mutation_program_wraps_shared_engine() {
|
||||||
|
let mut state = new_state(8).unwrap();
|
||||||
|
let program = vec![vm_extensions::OP_MUTATE_POINT, 0, 0, 1];
|
||||||
|
let trace = execute_mutation_program(&mut state, &program).unwrap();
|
||||||
|
assert_eq!(state.gene[0], 1);
|
||||||
|
assert_eq!(trace.final_ip, program.len());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_execute_mutation_order_determinism() {
|
||||||
|
let mut rng_a = rand::rngs::StdRng::seed_from_u64(101);
|
||||||
|
let mut rng_b = rand::rngs::StdRng::seed_from_u64(101);
|
||||||
|
let order_a = vm_extensions::generate_order_with_rng(&mut rng_a, 9, 64);
|
||||||
|
let order_b = vm_extensions::generate_order_with_rng(&mut rng_b, 9, 64);
|
||||||
|
assert_eq!(order_a, order_b);
|
||||||
|
|
||||||
|
let mut state_a = new_state(64).unwrap();
|
||||||
|
let mut state_b = new_state(64).unwrap();
|
||||||
|
let trace_a = execute_mutation_order(&mut state_a, &order_a).unwrap();
|
||||||
|
let trace_b = execute_mutation_order(&mut state_b, &order_b).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(state_a, state_b);
|
||||||
|
assert_eq!(trace_a.final_stack, trace_b.final_stack);
|
||||||
|
assert_eq!(commitment(&state_a), commitment(&state_b));
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "shared"
|
name = "shared"
|
||||||
version = "0.5.0"
|
version = "0.6.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
@@ -10,4 +10,4 @@ blake3 = "1"
|
|||||||
hex = "0.4"
|
hex = "0.4"
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
rand = "0.8"
|
rand = "0.8"
|
||||||
ed25519-dalek = { version = "2", features = ["rand_core"] }
|
ed25519-dalek = { version = "2", features = ["rand_core"] }
|
||||||
@@ -1,2 +1,6 @@
|
|||||||
pub const SESSION_ID_LEN: usize = 32;
|
pub const SESSION_ID_LEN: usize = 32;
|
||||||
pub const SALT_LEN: usize = 16;
|
pub const SALT_LEN: usize = 16;
|
||||||
|
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;
|
||||||
@@ -0,0 +1,381 @@
|
|||||||
|
use crate::constants::{DEFAULT_GENE_SIZE, MAX_ENV_RECORDS, MAX_GENE_SIZE};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct EnvironmentRecord {
|
||||||
|
pub symbol: u16,
|
||||||
|
pub quantity: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct GeneState {
|
||||||
|
pub gene: Vec<u8>,
|
||||||
|
pub environment: Vec<EnvironmentRecord>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum GeneError {
|
||||||
|
InvalidGeneSize { size: usize },
|
||||||
|
TooManyEnvironmentRecords { len: usize },
|
||||||
|
EnvironmentNotSorted,
|
||||||
|
DuplicateEnvironmentSymbol(u16),
|
||||||
|
ZeroQuantitySymbol(u16),
|
||||||
|
EnvironmentFull,
|
||||||
|
EnvironmentBlobLengthInvalid { len: usize },
|
||||||
|
EnvironmentBlobTooLarge { records: usize },
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for GeneError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::InvalidGeneSize { size } => write!(f, "invalid gene size: {size}"),
|
||||||
|
Self::TooManyEnvironmentRecords { len } => {
|
||||||
|
write!(f, "too many environment records: {len}")
|
||||||
|
}
|
||||||
|
Self::EnvironmentNotSorted => write!(f, "environment records are not sorted"),
|
||||||
|
Self::DuplicateEnvironmentSymbol(symbol) => {
|
||||||
|
write!(f, "duplicate environment symbol: {symbol}")
|
||||||
|
}
|
||||||
|
Self::ZeroQuantitySymbol(symbol) => {
|
||||||
|
write!(f, "environment quantity cannot be zero for symbol {symbol}")
|
||||||
|
}
|
||||||
|
Self::EnvironmentFull => write!(f, "environment is at maximum capacity"),
|
||||||
|
Self::EnvironmentBlobLengthInvalid { len } => {
|
||||||
|
write!(
|
||||||
|
f,
|
||||||
|
"environment blob length must be a multiple of 6, got {len}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Self::EnvironmentBlobTooLarge { records } => {
|
||||||
|
write!(f, "environment blob contains too many records: {records}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for GeneError {}
|
||||||
|
|
||||||
|
pub fn new_state(gene_size: usize) -> Result<GeneState, GeneError> {
|
||||||
|
if !(1..=MAX_GENE_SIZE).contains(&gene_size) {
|
||||||
|
return Err(GeneError::InvalidGeneSize { size: gene_size });
|
||||||
|
}
|
||||||
|
Ok(GeneState {
|
||||||
|
gene: vec![0; gene_size],
|
||||||
|
environment: Vec::new(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn default_state() -> GeneState {
|
||||||
|
GeneState {
|
||||||
|
gene: vec![0; DEFAULT_GENE_SIZE],
|
||||||
|
environment: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn validate_state(state: &GeneState) -> Result<(), GeneError> {
|
||||||
|
if !(1..=MAX_GENE_SIZE).contains(&state.gene.len()) {
|
||||||
|
return Err(GeneError::InvalidGeneSize {
|
||||||
|
size: state.gene.len(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
validate_environment(&state.environment)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_env_quantity(state: &GeneState, symbol: u16) -> u32 {
|
||||||
|
match state
|
||||||
|
.environment
|
||||||
|
.binary_search_by_key(&symbol, |record| record.symbol)
|
||||||
|
{
|
||||||
|
Ok(i) => state.environment[i].quantity,
|
||||||
|
Err(_) => 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_env_quantity(
|
||||||
|
state: &mut GeneState,
|
||||||
|
symbol: u16,
|
||||||
|
quantity: u32,
|
||||||
|
) -> Result<(), GeneError> {
|
||||||
|
let idx = state
|
||||||
|
.environment
|
||||||
|
.binary_search_by_key(&symbol, |record| record.symbol);
|
||||||
|
match (idx, quantity) {
|
||||||
|
(Ok(i), 0) => {
|
||||||
|
state.environment.remove(i);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
(Ok(i), qty) => {
|
||||||
|
state.environment[i].quantity = qty;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
(Err(_), 0) => Ok(()),
|
||||||
|
(Err(i), qty) => {
|
||||||
|
if state.environment.len() >= MAX_ENV_RECORDS {
|
||||||
|
return Err(GeneError::EnvironmentFull);
|
||||||
|
}
|
||||||
|
state.environment.insert(
|
||||||
|
i,
|
||||||
|
EnvironmentRecord {
|
||||||
|
symbol,
|
||||||
|
quantity: qty,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_env_quantity(
|
||||||
|
state: &mut GeneState,
|
||||||
|
symbol: u16,
|
||||||
|
quantity: u32,
|
||||||
|
) -> Result<u32, GeneError> {
|
||||||
|
let current = get_env_quantity(state, symbol);
|
||||||
|
let next = current.saturating_add(quantity);
|
||||||
|
set_env_quantity(state, symbol, next)?;
|
||||||
|
Ok(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sub_env_quantity(
|
||||||
|
state: &mut GeneState,
|
||||||
|
symbol: u16,
|
||||||
|
quantity: u32,
|
||||||
|
) -> Result<u32, GeneError> {
|
||||||
|
let current = get_env_quantity(state, symbol);
|
||||||
|
let next = current.saturating_sub(quantity);
|
||||||
|
set_env_quantity(state, symbol, next)?;
|
||||||
|
Ok(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn encode_environment(records: &[EnvironmentRecord]) -> Result<Vec<u8>, GeneError> {
|
||||||
|
validate_environment(records)?;
|
||||||
|
let mut out = Vec::with_capacity(records.len() * 6);
|
||||||
|
for record in records {
|
||||||
|
out.extend_from_slice(&record.symbol.to_le_bytes());
|
||||||
|
out.extend_from_slice(&record.quantity.to_le_bytes());
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn decode_environment(blob: &[u8]) -> Result<Vec<EnvironmentRecord>, GeneError> {
|
||||||
|
if blob.len() % 6 != 0 {
|
||||||
|
return Err(GeneError::EnvironmentBlobLengthInvalid { len: blob.len() });
|
||||||
|
}
|
||||||
|
let records_len = blob.len() / 6;
|
||||||
|
if records_len > MAX_ENV_RECORDS {
|
||||||
|
return Err(GeneError::EnvironmentBlobTooLarge {
|
||||||
|
records: records_len,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut records = Vec::with_capacity(records_len);
|
||||||
|
let mut i = 0;
|
||||||
|
while i < blob.len() {
|
||||||
|
let symbol = u16::from_le_bytes([blob[i], blob[i + 1]]);
|
||||||
|
let quantity = u32::from_le_bytes([blob[i + 2], blob[i + 3], blob[i + 4], blob[i + 5]]);
|
||||||
|
records.push(EnvironmentRecord { symbol, quantity });
|
||||||
|
i += 6;
|
||||||
|
}
|
||||||
|
validate_environment(&records)?;
|
||||||
|
Ok(records)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn commitment(state: &GeneState) -> [u8; 32] {
|
||||||
|
let mut h = blake3::Hasher::new();
|
||||||
|
h.update(b"chronoseal/gene/v1");
|
||||||
|
h.update(&(state.gene.len() as u32).to_le_bytes());
|
||||||
|
h.update(&state.gene);
|
||||||
|
h.update(&(state.environment.len() as u16).to_le_bytes());
|
||||||
|
for record in &state.environment {
|
||||||
|
h.update(&record.symbol.to_le_bytes());
|
||||||
|
h.update(&record.quantity.to_le_bytes());
|
||||||
|
}
|
||||||
|
*h.finalize().as_bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn commitment_hex(state: &GeneState) -> String {
|
||||||
|
hex::encode(commitment(state))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_environment(records: &[EnvironmentRecord]) -> Result<(), GeneError> {
|
||||||
|
if records.len() > MAX_ENV_RECORDS {
|
||||||
|
return Err(GeneError::TooManyEnvironmentRecords { len: records.len() });
|
||||||
|
}
|
||||||
|
let mut prev_symbol: Option<u16> = None;
|
||||||
|
for record in records {
|
||||||
|
if record.quantity == 0 {
|
||||||
|
return Err(GeneError::ZeroQuantitySymbol(record.symbol));
|
||||||
|
}
|
||||||
|
if let Some(prev) = prev_symbol {
|
||||||
|
if record.symbol < prev {
|
||||||
|
return Err(GeneError::EnvironmentNotSorted);
|
||||||
|
}
|
||||||
|
if record.symbol == prev {
|
||||||
|
return Err(GeneError::DuplicateEnvironmentSymbol(record.symbol));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prev_symbol = Some(record.symbol);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use rand::{Rng, SeedableRng};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_new_state_with_default_size() {
|
||||||
|
let state = new_state(DEFAULT_GENE_SIZE).unwrap();
|
||||||
|
assert_eq!(state.gene.len(), DEFAULT_GENE_SIZE);
|
||||||
|
assert!(state.environment.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_new_state_rejects_invalid_sizes() {
|
||||||
|
assert!(matches!(
|
||||||
|
new_state(0).unwrap_err(),
|
||||||
|
GeneError::InvalidGeneSize { .. }
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
new_state(MAX_GENE_SIZE + 1).unwrap_err(),
|
||||||
|
GeneError::InvalidGeneSize { .. }
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_set_and_get_env_quantity() {
|
||||||
|
let mut state = new_state(8).unwrap();
|
||||||
|
set_env_quantity(&mut state, 42, 7).unwrap();
|
||||||
|
assert_eq!(get_env_quantity(&state, 42), 7);
|
||||||
|
set_env_quantity(&mut state, 42, 0).unwrap();
|
||||||
|
assert_eq!(get_env_quantity(&state, 42), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_add_env_quantity_saturates() {
|
||||||
|
let mut state = new_state(8).unwrap();
|
||||||
|
set_env_quantity(&mut state, 1, u32::MAX - 3).unwrap();
|
||||||
|
let next = add_env_quantity(&mut state, 1, 99).unwrap();
|
||||||
|
assert_eq!(next, u32::MAX);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_sub_env_quantity_removes_symbol() {
|
||||||
|
let mut state = new_state(8).unwrap();
|
||||||
|
set_env_quantity(&mut state, 7, 10).unwrap();
|
||||||
|
let next = sub_env_quantity(&mut state, 7, 100).unwrap();
|
||||||
|
assert_eq!(next, 0);
|
||||||
|
assert!(state.environment.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_environment_capacity_limit_is_enforced() {
|
||||||
|
let mut state = new_state(8).unwrap();
|
||||||
|
for symbol in 0..(MAX_ENV_RECORDS as u16) {
|
||||||
|
set_env_quantity(&mut state, symbol, 1).unwrap();
|
||||||
|
}
|
||||||
|
let err = set_env_quantity(&mut state, 500, 1).unwrap_err();
|
||||||
|
assert_eq!(err, GeneError::EnvironmentFull);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_encode_decode_environment_roundtrip() {
|
||||||
|
let records = vec![
|
||||||
|
EnvironmentRecord {
|
||||||
|
symbol: 3,
|
||||||
|
quantity: 9,
|
||||||
|
},
|
||||||
|
EnvironmentRecord {
|
||||||
|
symbol: 11,
|
||||||
|
quantity: 999,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
let blob = encode_environment(&records).unwrap();
|
||||||
|
let decoded = decode_environment(&blob).unwrap();
|
||||||
|
assert_eq!(decoded, records);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_decode_environment_rejects_unsorted_records() {
|
||||||
|
let mut blob = Vec::new();
|
||||||
|
blob.extend_from_slice(&7u16.to_le_bytes());
|
||||||
|
blob.extend_from_slice(&1u32.to_le_bytes());
|
||||||
|
blob.extend_from_slice(&2u16.to_le_bytes());
|
||||||
|
blob.extend_from_slice(&1u32.to_le_bytes());
|
||||||
|
let err = decode_environment(&blob).unwrap_err();
|
||||||
|
assert_eq!(err, GeneError::EnvironmentNotSorted);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_decode_environment_rejects_zero_quantity() {
|
||||||
|
let mut blob = Vec::new();
|
||||||
|
blob.extend_from_slice(&9u16.to_le_bytes());
|
||||||
|
blob.extend_from_slice(&0u32.to_le_bytes());
|
||||||
|
let err = decode_environment(&blob).unwrap_err();
|
||||||
|
assert_eq!(err, GeneError::ZeroQuantitySymbol(9));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_commitment_changes_when_gene_or_environment_changes() {
|
||||||
|
let mut state_a = new_state(16).unwrap();
|
||||||
|
let mut state_b = state_a.clone();
|
||||||
|
assert_eq!(commitment_hex(&state_a), commitment_hex(&state_b));
|
||||||
|
|
||||||
|
state_b.gene[0] = 1;
|
||||||
|
assert_ne!(commitment_hex(&state_a), commitment_hex(&state_b));
|
||||||
|
|
||||||
|
set_env_quantity(&mut state_a, 7, 3).unwrap();
|
||||||
|
assert_ne!(commitment_hex(&state_a), commitment_hex(&state_b));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_state_rejects_duplicate_environment_symbols() {
|
||||||
|
let state = GeneState {
|
||||||
|
gene: vec![0; 10],
|
||||||
|
environment: vec![
|
||||||
|
EnvironmentRecord {
|
||||||
|
symbol: 1,
|
||||||
|
quantity: 1,
|
||||||
|
},
|
||||||
|
EnvironmentRecord {
|
||||||
|
symbol: 1,
|
||||||
|
quantity: 2,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
validate_state(&state).unwrap_err(),
|
||||||
|
GeneError::DuplicateEnvironmentSymbol(1)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_table_driven_randomized_environment_roundtrip() {
|
||||||
|
for seed in 0..32u64 {
|
||||||
|
let mut rng = rand::rngs::StdRng::seed_from_u64(seed);
|
||||||
|
let mut state = new_state(32).unwrap();
|
||||||
|
|
||||||
|
for _ in 0..128 {
|
||||||
|
let symbol = rng.gen_range(0u16..200u16);
|
||||||
|
let qty = if rng.gen_bool(0.15) {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
rng.gen_range(1u32..100_000u32)
|
||||||
|
};
|
||||||
|
if let Err(err) = set_env_quantity(&mut state, symbol, qty) {
|
||||||
|
assert_eq!(err, GeneError::EnvironmentFull);
|
||||||
|
}
|
||||||
|
validate_state(&state).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let blob = encode_environment(&state.environment).unwrap();
|
||||||
|
let decoded = decode_environment(&blob).unwrap();
|
||||||
|
assert_eq!(decoded, state.environment);
|
||||||
|
|
||||||
|
let commitment_a = commitment(&state);
|
||||||
|
let commitment_b = commitment(&state.clone());
|
||||||
|
assert_eq!(commitment_a, commitment_b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
pub mod constants;
|
pub mod constants;
|
||||||
|
pub mod gene;
|
||||||
pub mod hashing;
|
pub mod hashing;
|
||||||
pub mod protocol;
|
pub mod protocol;
|
||||||
|
pub mod vm_extensions;
|
||||||
+15
-6
@@ -1,11 +1,11 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
#[derive(Deserialize, Serialize)]
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
pub struct InitRequest {
|
pub struct InitRequest {
|
||||||
pub public_key: String,
|
pub public_key: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct InitResponse {
|
pub struct InitResponse {
|
||||||
pub session_id: String,
|
pub session_id: String,
|
||||||
pub salt: String,
|
pub salt: String,
|
||||||
@@ -14,9 +14,12 @@ pub struct InitResponse {
|
|||||||
pub expires_at: u64,
|
pub expires_at: u64,
|
||||||
pub heartbeat_min_interval_ms: u64,
|
pub heartbeat_min_interval_ms: u64,
|
||||||
pub heartbeat_max_interval_ms: u64,
|
pub heartbeat_max_interval_ms: u64,
|
||||||
|
pub gene_size: u32,
|
||||||
|
pub mutation_step: u64,
|
||||||
|
pub mutation_order_b64: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, Serialize)]
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
pub struct HeartbeatRequest {
|
pub struct HeartbeatRequest {
|
||||||
pub session_id: String,
|
pub session_id: String,
|
||||||
pub prev_hash: String,
|
pub prev_hash: String,
|
||||||
@@ -24,17 +27,23 @@ pub struct HeartbeatRequest {
|
|||||||
pub entropy_data: EntropyData,
|
pub entropy_data: EntropyData,
|
||||||
pub stack_state: StackState,
|
pub stack_state: StackState,
|
||||||
pub fingerprint: Fingerprint,
|
pub fingerprint: Fingerprint,
|
||||||
|
pub mutation_step: u64,
|
||||||
|
pub gene_commitment: String,
|
||||||
pub signature: String,
|
pub signature: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct HeartbeatResponse {
|
pub struct HeartbeatResponse {
|
||||||
pub status: String,
|
pub status: String,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub next_salt: Option<String>,
|
pub next_salt: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub next_mutation_step: Option<u64>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub next_mutation_order_b64: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, Serialize)]
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
pub struct Fingerprint {
|
pub struct Fingerprint {
|
||||||
#[serde(rename = "aspectRatio")]
|
#[serde(rename = "aspectRatio")]
|
||||||
pub aspect_ratio: String,
|
pub aspect_ratio: String,
|
||||||
@@ -44,7 +53,7 @@ pub struct Fingerprint {
|
|||||||
pub hardware_concurrency: u32,
|
pub hardware_concurrency: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, Serialize)]
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
pub struct EntropyData {
|
pub struct EntropyData {
|
||||||
pub events: Vec<MouseEvent>,
|
pub events: Vec<MouseEvent>,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,722 @@
|
|||||||
|
use crate::{
|
||||||
|
constants::{MAX_GENE_SIZE, MAX_MUTATION_PROGRAM_BYTES},
|
||||||
|
gene::{
|
||||||
|
add_env_quantity, get_env_quantity, sub_env_quantity, validate_state, GeneError, GeneState,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
use rand::Rng;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
//
|
||||||
|
// Security/performance notes:
|
||||||
|
// - All index operands are normalized with modulo to avoid panics.
|
||||||
|
// - Program size is bounded by MAX_MUTATION_PROGRAM_BYTES.
|
||||||
|
// - Environment arithmetic is saturating and deterministic.
|
||||||
|
// - Hashing uses fixed BLAKE3 commitment and fixed transcription algorithm.
|
||||||
|
pub const OP_GENE_LOAD: u8 = 0x23; // +1
|
||||||
|
pub const OP_GENE_STORE: u8 = 0x24; // -1
|
||||||
|
pub const OP_MUTATE_POINT: u8 = 0x25; // 0
|
||||||
|
pub const OP_INSERT: u8 = 0x26; // -1
|
||||||
|
pub const OP_DELETE: u8 = 0x27; // +1
|
||||||
|
pub const OP_TRANSCRIBE: u8 = 0x28; // +1
|
||||||
|
pub const OP_APPLY_MUTAGEN: u8 = 0x29; // -1
|
||||||
|
pub const OP_FINALIZE_GENE_HASH: u8 = 0x2A; // +1
|
||||||
|
pub const OP_CONSUME: u8 = 0x2B; // 0 (pop amount, push remaining)
|
||||||
|
pub const OP_PRODUCE: u8 = 0x2C; // 0 (pop amount, push resulting quantity)
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct MutationOrder {
|
||||||
|
pub step: u64,
|
||||||
|
pub program: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct ExecutionTrace {
|
||||||
|
pub final_ip: usize,
|
||||||
|
pub final_stack: Vec<u32>,
|
||||||
|
pub final_gene_commitment_hex: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum MutationError {
|
||||||
|
ProgramTooLong { len: usize },
|
||||||
|
TruncatedInstruction { opcode: u8, ip: usize },
|
||||||
|
UnknownOpcode(u8),
|
||||||
|
EmptyGene,
|
||||||
|
StackUnderflow { opcode: u8, ip: usize },
|
||||||
|
GeneFull { current_len: usize },
|
||||||
|
Base64(base64::DecodeError),
|
||||||
|
Gene(GeneError),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for MutationError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::ProgramTooLong { len } => write!(f, "mutation program too long: {len} bytes"),
|
||||||
|
Self::TruncatedInstruction { opcode, ip } => {
|
||||||
|
write!(f, "truncated instruction {opcode:#04x} at ip={ip}")
|
||||||
|
}
|
||||||
|
Self::UnknownOpcode(opcode) => write!(f, "unknown mutation opcode: {opcode:#04x}"),
|
||||||
|
Self::EmptyGene => write!(f, "cannot mutate an empty gene"),
|
||||||
|
Self::StackUnderflow { opcode, ip } => {
|
||||||
|
write!(f, "stack underflow in opcode {opcode:#04x} at ip={ip}")
|
||||||
|
}
|
||||||
|
Self::GeneFull { current_len } => {
|
||||||
|
write!(f, "cannot insert; gene already at max size ({current_len})")
|
||||||
|
}
|
||||||
|
Self::Base64(err) => write!(f, "invalid base64 mutation order: {err}"),
|
||||||
|
Self::Gene(err) => write!(f, "{err}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for MutationError {}
|
||||||
|
|
||||||
|
impl From<GeneError> for MutationError {
|
||||||
|
fn from(value: GeneError) -> Self {
|
||||||
|
Self::Gene(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn encode_order_b64(order: &MutationOrder) -> String {
|
||||||
|
base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &order.program)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn decode_order_b64(step: u64, b64: &str) -> Result<MutationOrder, MutationError> {
|
||||||
|
let program = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, b64)
|
||||||
|
.map_err(MutationError::Base64)?;
|
||||||
|
if program.len() > MAX_MUTATION_PROGRAM_BYTES {
|
||||||
|
return Err(MutationError::ProgramTooLong { len: program.len() });
|
||||||
|
}
|
||||||
|
Ok(MutationOrder { step, program })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn generate_order(step: u64, gene_size: usize) -> MutationOrder {
|
||||||
|
let mut rng = rand::thread_rng();
|
||||||
|
generate_order_with_rng(&mut rng, step, gene_size)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn generate_order_with_rng<R: Rng + ?Sized>(
|
||||||
|
rng: &mut R,
|
||||||
|
step: u64,
|
||||||
|
gene_size: usize,
|
||||||
|
) -> MutationOrder {
|
||||||
|
let mut program = Vec::with_capacity(96);
|
||||||
|
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);
|
||||||
|
|
||||||
|
for _ in 0..ops {
|
||||||
|
let op = if stack_depth <= 0 {
|
||||||
|
rng.gen_range(0u8..3u8)
|
||||||
|
} else {
|
||||||
|
rng.gen_range(0u8..10u8)
|
||||||
|
};
|
||||||
|
match op {
|
||||||
|
// Pushers
|
||||||
|
0 => {
|
||||||
|
program.push(OP_GENE_LOAD);
|
||||||
|
push_u16(&mut program, rng.r#gen::<u16>());
|
||||||
|
stack_depth += 1;
|
||||||
|
}
|
||||||
|
1 => {
|
||||||
|
program.push(OP_TRANSCRIBE);
|
||||||
|
push_u16(&mut program, rng.r#gen::<u16>());
|
||||||
|
program.push(rng.gen_range(1u8..=16u8));
|
||||||
|
stack_depth += 1;
|
||||||
|
}
|
||||||
|
2 => {
|
||||||
|
program.push(OP_FINALIZE_GENE_HASH);
|
||||||
|
stack_depth += 1;
|
||||||
|
}
|
||||||
|
// Consumers
|
||||||
|
3 => {
|
||||||
|
if stack_depth > 0 {
|
||||||
|
program.push(OP_GENE_STORE);
|
||||||
|
push_u16(&mut program, rng.r#gen::<u16>());
|
||||||
|
stack_depth -= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
4 => {
|
||||||
|
program.push(OP_MUTATE_POINT);
|
||||||
|
push_u16(&mut program, rng.r#gen::<u16>());
|
||||||
|
program.push(rng.r#gen::<u8>());
|
||||||
|
}
|
||||||
|
5 => {
|
||||||
|
if stack_depth > 0 && estimated_gene_len < MAX_GENE_SIZE {
|
||||||
|
program.push(OP_INSERT);
|
||||||
|
push_u16(&mut program, rng.r#gen::<u16>());
|
||||||
|
stack_depth -= 1;
|
||||||
|
estimated_gene_len += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
6 => {
|
||||||
|
program.push(OP_DELETE);
|
||||||
|
push_u16(&mut program, rng.r#gen::<u16>());
|
||||||
|
stack_depth += 1;
|
||||||
|
if estimated_gene_len > 1 {
|
||||||
|
estimated_gene_len -= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
7 => {
|
||||||
|
if stack_depth > 0 {
|
||||||
|
program.push(OP_APPLY_MUTAGEN);
|
||||||
|
push_u16(&mut program, rng.r#gen::<u16>());
|
||||||
|
push_u16(&mut program, rng.r#gen::<u16>());
|
||||||
|
stack_depth -= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
8 => {
|
||||||
|
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>());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MutationOrder { step, program }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn apply_program_clone(state: &GeneState, program: &[u8]) -> Result<GeneState, MutationError> {
|
||||||
|
let mut next = state.clone();
|
||||||
|
apply_program(&mut next, program)?;
|
||||||
|
Ok(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn apply_program(state: &mut GeneState, program: &[u8]) -> Result<(), MutationError> {
|
||||||
|
let _ = execute_program(state, program)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn execute_program(
|
||||||
|
state: &mut GeneState,
|
||||||
|
program: &[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 mut ip = 0usize;
|
||||||
|
let mut stack: Vec<u32> = Vec::with_capacity(16);
|
||||||
|
while ip < program.len() {
|
||||||
|
let opcode_ip = ip;
|
||||||
|
let opcode = take_u8(program, &mut ip, 0x00)?;
|
||||||
|
match opcode {
|
||||||
|
OP_GENE_LOAD => {
|
||||||
|
let idx = take_u16(program, &mut ip, opcode)?;
|
||||||
|
let normalized = normalize_index(idx as usize, state.gene.len());
|
||||||
|
stack.push(state.gene[normalized] as u32);
|
||||||
|
}
|
||||||
|
OP_GENE_STORE => {
|
||||||
|
let idx = take_u16(program, &mut ip, opcode)?;
|
||||||
|
let value = pop_stack(&mut stack, opcode, opcode_ip)? as u8;
|
||||||
|
let normalized = normalize_index(idx as usize, state.gene.len());
|
||||||
|
state.gene[normalized] = value;
|
||||||
|
}
|
||||||
|
OP_MUTATE_POINT => {
|
||||||
|
let idx = take_u16(program, &mut ip, opcode)?;
|
||||||
|
let delta = take_u8(program, &mut ip, opcode)? as i8;
|
||||||
|
let normalized = normalize_index(idx as usize, state.gene.len());
|
||||||
|
state.gene[normalized] = state.gene[normalized].wrapping_add(delta as u8);
|
||||||
|
}
|
||||||
|
OP_INSERT => {
|
||||||
|
let idx = take_u16(program, &mut ip, opcode)?;
|
||||||
|
let value = pop_stack(&mut stack, opcode, opcode_ip)? as u8;
|
||||||
|
if state.gene.len() >= MAX_GENE_SIZE {
|
||||||
|
return Err(MutationError::GeneFull {
|
||||||
|
current_len: state.gene.len(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let insert_at = (idx as usize).min(state.gene.len());
|
||||||
|
state.gene.insert(insert_at, value);
|
||||||
|
}
|
||||||
|
OP_DELETE => {
|
||||||
|
let idx = take_u16(program, &mut ip, opcode)?;
|
||||||
|
let normalized = normalize_index(idx as usize, state.gene.len());
|
||||||
|
let removed = if state.gene.len() > 1 {
|
||||||
|
state.gene.remove(normalized)
|
||||||
|
} else {
|
||||||
|
let prev = state.gene[0];
|
||||||
|
state.gene[0] = 0;
|
||||||
|
prev
|
||||||
|
};
|
||||||
|
stack.push(removed as u32);
|
||||||
|
}
|
||||||
|
OP_TRANSCRIBE => {
|
||||||
|
let start = take_u16(program, &mut ip, opcode)?;
|
||||||
|
let span = take_u8(program, &mut ip, opcode)?;
|
||||||
|
let transcription = transcribe_window(&state.gene, start as usize, span);
|
||||||
|
stack.push(transcription);
|
||||||
|
}
|
||||||
|
OP_APPLY_MUTAGEN => {
|
||||||
|
let symbol = take_u16(program, &mut ip, opcode)?;
|
||||||
|
let idx = take_u16(program, &mut ip, opcode)?;
|
||||||
|
let stack_mask = pop_stack(&mut stack, opcode, opcode_ip)? as u8;
|
||||||
|
let quantity = get_env_quantity(state, symbol);
|
||||||
|
let mix = ((quantity as u8)
|
||||||
|
^ ((quantity >> 8) as u8)
|
||||||
|
^ ((quantity >> 16) as u8)
|
||||||
|
^ ((quantity >> 24) as u8))
|
||||||
|
^ ((symbol & 0x00ff) as u8)
|
||||||
|
^ ((symbol >> 8) as u8)
|
||||||
|
^ stack_mask;
|
||||||
|
let normalized = normalize_index(idx as usize, state.gene.len());
|
||||||
|
state.gene[normalized] ^= mix;
|
||||||
|
}
|
||||||
|
OP_FINALIZE_GENE_HASH => {
|
||||||
|
let commit = crate::gene::commitment(state);
|
||||||
|
let hash32 = u32::from_le_bytes([commit[0], commit[1], commit[2], commit[3]]);
|
||||||
|
stack.push(hash32);
|
||||||
|
}
|
||||||
|
OP_CONSUME => {
|
||||||
|
let symbol = take_u16(program, &mut ip, opcode)?;
|
||||||
|
let amount = pop_stack(&mut stack, opcode, opcode_ip)?;
|
||||||
|
let left = sub_env_quantity(state, symbol, amount)?;
|
||||||
|
stack.push(left);
|
||||||
|
}
|
||||||
|
OP_PRODUCE => {
|
||||||
|
let symbol = take_u16(program, &mut ip, opcode)?;
|
||||||
|
let amount = pop_stack(&mut stack, opcode, opcode_ip)?;
|
||||||
|
let next = add_env_quantity(state, symbol, amount)?;
|
||||||
|
stack.push(next);
|
||||||
|
}
|
||||||
|
_ => return Err(MutationError::UnknownOpcode(opcode)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(ExecutionTrace {
|
||||||
|
final_ip: ip,
|
||||||
|
final_stack: stack,
|
||||||
|
final_gene_commitment_hex: crate::gene::commitment_hex(state),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn transcribe_window(gene: &[u8], start: usize, span: u8) -> u32 {
|
||||||
|
let count = usize::from(span.max(1));
|
||||||
|
let mut acc = 2_166_136_261u32; // FNV offset basis
|
||||||
|
for i in 0..count {
|
||||||
|
let idx = (start + i) % gene.len();
|
||||||
|
acc ^= gene[idx] as u32;
|
||||||
|
acc = acc.wrapping_mul(16_777_619); // FNV prime
|
||||||
|
}
|
||||||
|
acc
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_u16(buf: &mut Vec<u8>, value: u16) {
|
||||||
|
buf.extend_from_slice(&value.to_le_bytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn take_u8(bytes: &[u8], ip: &mut usize, opcode: u8) -> Result<u8, MutationError> {
|
||||||
|
if *ip >= bytes.len() {
|
||||||
|
return Err(MutationError::TruncatedInstruction { opcode, ip: *ip });
|
||||||
|
}
|
||||||
|
let value = bytes[*ip];
|
||||||
|
*ip += 1;
|
||||||
|
Ok(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn take_u16(bytes: &[u8], ip: &mut usize, opcode: u8) -> Result<u16, MutationError> {
|
||||||
|
if *ip + 2 > bytes.len() {
|
||||||
|
return Err(MutationError::TruncatedInstruction { opcode, ip: *ip });
|
||||||
|
}
|
||||||
|
let value = u16::from_le_bytes([bytes[*ip], bytes[*ip + 1]]);
|
||||||
|
*ip += 2;
|
||||||
|
Ok(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pop_stack(stack: &mut Vec<u32>, opcode: u8, ip: usize) -> Result<u32, MutationError> {
|
||||||
|
stack
|
||||||
|
.pop()
|
||||||
|
.ok_or(MutationError::StackUnderflow { opcode, ip })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_index(idx: usize, len: usize) -> usize {
|
||||||
|
idx % len
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::gene::{commitment, new_state, set_env_quantity};
|
||||||
|
use rand::{Rng, SeedableRng};
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
fn u16_bytes(v: u16) -> [u8; 2] {
|
||||||
|
v.to_le_bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_opcode_gene_load() {
|
||||||
|
let mut state = new_state(4).unwrap();
|
||||||
|
state.gene = vec![10, 20, 30, 40];
|
||||||
|
let trace = execute_program(&mut state, &[OP_GENE_LOAD, 1, 0]).unwrap();
|
||||||
|
assert_eq!(trace.final_stack, vec![20]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_opcode_gene_store() {
|
||||||
|
let mut state = new_state(4).unwrap();
|
||||||
|
state.gene = vec![1, 2, 3, 4];
|
||||||
|
let program = vec![
|
||||||
|
OP_GENE_LOAD,
|
||||||
|
0,
|
||||||
|
0, // stack: [1]
|
||||||
|
OP_GENE_STORE,
|
||||||
|
2,
|
||||||
|
0, // gene[2] <- 1
|
||||||
|
];
|
||||||
|
execute_program(&mut state, &program).unwrap();
|
||||||
|
assert_eq!(state.gene, vec![1, 2, 1, 4]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_opcode_mutate_point() {
|
||||||
|
let mut state = new_state(4).unwrap();
|
||||||
|
state.gene[0] = 200;
|
||||||
|
let program = vec![OP_MUTATE_POINT, 0, 0, 100u8];
|
||||||
|
execute_program(&mut state, &program).unwrap();
|
||||||
|
assert_eq!(state.gene[0], 44);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_opcode_insert() {
|
||||||
|
let mut state = new_state(3).unwrap();
|
||||||
|
state.gene = vec![10, 20, 30];
|
||||||
|
let program = vec![
|
||||||
|
OP_GENE_LOAD,
|
||||||
|
1,
|
||||||
|
0, // stack: [20]
|
||||||
|
OP_INSERT,
|
||||||
|
0,
|
||||||
|
0, // insert 20 at position 0
|
||||||
|
];
|
||||||
|
execute_program(&mut state, &program).unwrap();
|
||||||
|
assert_eq!(state.gene, vec![20, 10, 20, 30]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_opcode_delete() {
|
||||||
|
let mut state = new_state(4).unwrap();
|
||||||
|
state.gene = vec![9, 8, 7, 6];
|
||||||
|
let trace = execute_program(&mut state, &[OP_DELETE, 2, 0]).unwrap();
|
||||||
|
assert_eq!(state.gene, vec![9, 8, 6]);
|
||||||
|
assert_eq!(trace.final_stack, vec![7]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_opcode_transcribe() {
|
||||||
|
let mut state = new_state(5).unwrap();
|
||||||
|
state.gene = vec![1, 2, 3, 4, 5];
|
||||||
|
let trace = execute_program(&mut state, &[OP_TRANSCRIBE, 1, 0, 3]).unwrap();
|
||||||
|
assert_eq!(trace.final_stack.len(), 1);
|
||||||
|
assert_ne!(trace.final_stack[0], 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_opcode_apply_mutagen() {
|
||||||
|
let mut state = new_state(4).unwrap();
|
||||||
|
set_env_quantity(&mut state, 7, 0x1234_5678).unwrap();
|
||||||
|
state.gene[1] = 0xAA;
|
||||||
|
let program = vec![
|
||||||
|
OP_GENE_LOAD,
|
||||||
|
0,
|
||||||
|
0, // stack mask source
|
||||||
|
OP_APPLY_MUTAGEN,
|
||||||
|
7,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
];
|
||||||
|
execute_program(&mut state, &program).unwrap();
|
||||||
|
assert_ne!(state.gene[1], 0xAA);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_opcode_finalize_gene_hash() {
|
||||||
|
let mut state = new_state(4).unwrap();
|
||||||
|
let trace = execute_program(&mut state, &[OP_FINALIZE_GENE_HASH]).unwrap();
|
||||||
|
assert_eq!(trace.final_stack.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_opcode_consume() {
|
||||||
|
let mut state = new_state(4).unwrap();
|
||||||
|
set_env_quantity(&mut state, 3, 100).unwrap();
|
||||||
|
let program = vec![
|
||||||
|
OP_GENE_LOAD,
|
||||||
|
0,
|
||||||
|
0, // stack = [0]
|
||||||
|
OP_MUTATE_POINT,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
15, // gene[0]=15
|
||||||
|
OP_GENE_LOAD,
|
||||||
|
0,
|
||||||
|
0, // stack=[0,15]
|
||||||
|
OP_CONSUME,
|
||||||
|
3,
|
||||||
|
0, // consume 15
|
||||||
|
];
|
||||||
|
let trace = execute_program(&mut state, &program).unwrap();
|
||||||
|
assert_eq!(get_env_quantity(&state, 3), 85);
|
||||||
|
assert_eq!(trace.final_stack.last().copied().unwrap(), 85);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_opcode_produce() {
|
||||||
|
let mut state = new_state(4).unwrap();
|
||||||
|
set_env_quantity(&mut state, 9, 5).unwrap();
|
||||||
|
let program = vec![
|
||||||
|
OP_GENE_LOAD,
|
||||||
|
0,
|
||||||
|
0, // stack [0]
|
||||||
|
OP_MUTATE_POINT,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
10, // gene[0]=10
|
||||||
|
OP_GENE_LOAD,
|
||||||
|
0,
|
||||||
|
0, // stack [0,10]
|
||||||
|
OP_PRODUCE,
|
||||||
|
9,
|
||||||
|
0, // +10
|
||||||
|
];
|
||||||
|
let trace = execute_program(&mut state, &program).unwrap();
|
||||||
|
assert_eq!(get_env_quantity(&state, 9), 15);
|
||||||
|
assert_eq!(trace.final_stack.last().copied().unwrap(), 15);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_zero_length_gene_is_rejected() {
|
||||||
|
let mut state = GeneState {
|
||||||
|
gene: vec![],
|
||||||
|
environment: vec![],
|
||||||
|
};
|
||||||
|
let err = execute_program(&mut state, &[OP_FINALIZE_GENE_HASH]).unwrap_err();
|
||||||
|
assert_eq!(err, MutationError::EmptyGene);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_insert_rejects_max_size_gene() {
|
||||||
|
let mut state = new_state(MAX_GENE_SIZE).unwrap();
|
||||||
|
let program = vec![
|
||||||
|
OP_GENE_LOAD,
|
||||||
|
0,
|
||||||
|
0, // push value
|
||||||
|
OP_INSERT,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
];
|
||||||
|
let err = execute_program(&mut state, &program).unwrap_err();
|
||||||
|
assert!(matches!(err, MutationError::GeneFull { .. }));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_invalid_positions_wrap_deterministically() {
|
||||||
|
let mut state_a = new_state(5).unwrap();
|
||||||
|
let mut state_b = new_state(5).unwrap();
|
||||||
|
let max_u16 = u16::MAX;
|
||||||
|
let [a0, a1] = u16_bytes(max_u16);
|
||||||
|
let program = vec![OP_MUTATE_POINT, a0, a1, 1];
|
||||||
|
execute_program(&mut state_a, &program).unwrap();
|
||||||
|
|
||||||
|
let wrapped = (max_u16 as usize % 5) as u16;
|
||||||
|
let [w0, w1] = u16_bytes(wrapped);
|
||||||
|
let wrapped_program = vec![OP_MUTATE_POINT, w0, w1, 1];
|
||||||
|
execute_program(&mut state_b, &wrapped_program).unwrap();
|
||||||
|
assert_eq!(state_a, state_b);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_quantity_underflow_is_saturating() {
|
||||||
|
let mut state = new_state(4).unwrap();
|
||||||
|
set_env_quantity(&mut state, 1, 3).unwrap();
|
||||||
|
state.gene[0] = 8;
|
||||||
|
let program = vec![
|
||||||
|
OP_GENE_LOAD,
|
||||||
|
0,
|
||||||
|
0, // 8
|
||||||
|
OP_CONSUME,
|
||||||
|
1,
|
||||||
|
0, // consume 8 from qty 3 => 0
|
||||||
|
];
|
||||||
|
let trace = execute_program(&mut state, &program).unwrap();
|
||||||
|
assert_eq!(get_env_quantity(&state, 1), 0);
|
||||||
|
assert_eq!(trace.final_stack.last().copied().unwrap(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_rejects_unknown_opcode() {
|
||||||
|
let mut state = new_state(8).unwrap();
|
||||||
|
let err = execute_program(&mut state, &[0xFF]).unwrap_err();
|
||||||
|
assert_eq!(err, MutationError::UnknownOpcode(0xFF));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_rejects_truncated_instruction() {
|
||||||
|
let mut state = new_state(8).unwrap();
|
||||||
|
let err = execute_program(&mut state, &[OP_GENE_LOAD, 1]).unwrap_err();
|
||||||
|
assert!(matches!(err, MutationError::TruncatedInstruction { .. }));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_rejects_stack_underflow() {
|
||||||
|
let mut state = new_state(8).unwrap();
|
||||||
|
let err = execute_program(&mut state, &[OP_GENE_STORE, 0, 0]).unwrap_err();
|
||||||
|
assert!(matches!(err, MutationError::StackUnderflow { .. }));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_base64_order_roundtrip() {
|
||||||
|
let order = MutationOrder {
|
||||||
|
step: 17,
|
||||||
|
program: vec![OP_GENE_LOAD, 1, 0, OP_GENE_STORE, 2, 0],
|
||||||
|
};
|
||||||
|
let b64 = encode_order_b64(&order);
|
||||||
|
let decoded = decode_order_b64(order.step, &b64).unwrap();
|
||||||
|
assert_eq!(decoded, order);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_generate_order_is_deterministic_for_seeded_rng() {
|
||||||
|
let mut rng_a = rand::rngs::StdRng::seed_from_u64(99);
|
||||||
|
let mut rng_b = rand::rngs::StdRng::seed_from_u64(99);
|
||||||
|
let order_a = generate_order_with_rng(&mut rng_a, 5, 64);
|
||||||
|
let order_b = generate_order_with_rng(&mut rng_b, 5, 64);
|
||||||
|
assert_eq!(order_a, order_b);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_mutation_chain() {
|
||||||
|
let mut server_state = new_state(32).unwrap();
|
||||||
|
let mut client_state = new_state(32).unwrap();
|
||||||
|
|
||||||
|
let program = vec![
|
||||||
|
OP_GENE_LOAD,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
OP_PRODUCE,
|
||||||
|
2,
|
||||||
|
0, // env[2]+=gene[0]
|
||||||
|
OP_GENE_LOAD,
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
OP_APPLY_MUTAGEN,
|
||||||
|
2,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
0, // mutagen at idx1
|
||||||
|
OP_TRANSCRIBE,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
8, // hash window
|
||||||
|
OP_GENE_STORE,
|
||||||
|
2,
|
||||||
|
0, // gene[2]=transcription_low_byte
|
||||||
|
OP_DELETE,
|
||||||
|
0,
|
||||||
|
0, // stack pushes removed
|
||||||
|
OP_INSERT,
|
||||||
|
3,
|
||||||
|
0, // insert removed at position 3
|
||||||
|
OP_FINALIZE_GENE_HASH,
|
||||||
|
];
|
||||||
|
|
||||||
|
let server_trace = execute_program(&mut server_state, &program).unwrap();
|
||||||
|
let client_trace = execute_program(&mut client_state, &program).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(server_state, client_state);
|
||||||
|
assert_eq!(server_trace.final_stack, client_trace.final_stack);
|
||||||
|
assert_eq!(
|
||||||
|
server_trace.final_gene_commitment_hex,
|
||||||
|
client_trace.final_gene_commitment_hex
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_server_client_parity_across_random_orders() {
|
||||||
|
let mut rng = rand::rngs::StdRng::seed_from_u64(7);
|
||||||
|
for step in 0..128u64 {
|
||||||
|
let order = generate_order_with_rng(&mut rng, step, 128);
|
||||||
|
let mut server_state = new_state(128).unwrap();
|
||||||
|
let mut client_state = new_state(128).unwrap();
|
||||||
|
|
||||||
|
let server_result = execute_program(&mut server_state, &order.program);
|
||||||
|
let client_result = execute_program(&mut client_state, &order.program);
|
||||||
|
assert_eq!(server_result.is_ok(), client_result.is_ok());
|
||||||
|
|
||||||
|
match (server_result, client_result) {
|
||||||
|
(Ok(server_trace), Ok(client_trace)) => {
|
||||||
|
assert_eq!(server_state, client_state);
|
||||||
|
assert_eq!(server_trace.final_stack, client_trace.final_stack);
|
||||||
|
assert_eq!(
|
||||||
|
commitment(&server_state),
|
||||||
|
commitment(&client_state),
|
||||||
|
"step {step}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
(Err(a), Err(b)) => assert_eq!(a.to_string(), b.to_string()),
|
||||||
|
_ => unreachable!(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_fuzz_style_random_program_bytes_do_not_diverge() {
|
||||||
|
let mut rng = rand::rngs::StdRng::seed_from_u64(2026);
|
||||||
|
for _ in 0..256 {
|
||||||
|
let len = rng.gen_range(1usize..=MAX_MUTATION_PROGRAM_BYTES);
|
||||||
|
let mut program = vec![0u8; len];
|
||||||
|
for b in &mut program {
|
||||||
|
*b = rng.r#gen::<u8>();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut a = new_state(64).unwrap();
|
||||||
|
let mut b = new_state(64).unwrap();
|
||||||
|
let ra = execute_program(&mut a, &program);
|
||||||
|
let rb = execute_program(&mut b, &program);
|
||||||
|
assert_eq!(ra.is_ok(), rb.is_ok());
|
||||||
|
if ra.is_ok() {
|
||||||
|
assert_eq!(a, b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_performance_smoke_mutation_execution() {
|
||||||
|
let mut rng = rand::rngs::StdRng::seed_from_u64(11);
|
||||||
|
let mut programs = Vec::new();
|
||||||
|
for step in 0..200u64 {
|
||||||
|
programs.push(generate_order_with_rng(&mut rng, step + 1, 512).program);
|
||||||
|
}
|
||||||
|
|
||||||
|
let start = Instant::now();
|
||||||
|
let mut state = new_state(512).unwrap();
|
||||||
|
for program in &programs {
|
||||||
|
let _ = execute_program(&mut state, program);
|
||||||
|
}
|
||||||
|
let elapsed = start.elapsed();
|
||||||
|
// Wide bound for CI variability; this is a regression guard, not a strict benchmark.
|
||||||
|
assert!(
|
||||||
|
elapsed.as_secs_f64() < 2.0,
|
||||||
|
"mutation execution too slow: {elapsed:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "chronoseal-wasm"
|
name = "chronoseal-wasm"
|
||||||
version = "0.5.0"
|
version = "0.6.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
|
|||||||
@@ -4,3 +4,4 @@ pub mod entropy;
|
|||||||
pub mod fingerprint;
|
pub mod fingerprint;
|
||||||
pub mod transport;
|
pub mod transport;
|
||||||
pub mod vm;
|
pub mod vm;
|
||||||
|
pub mod vm_extensions;
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
use std::cell::RefCell;
|
||||||
|
use wasm_bindgen::prelude::*;
|
||||||
|
|
||||||
|
thread_local! {
|
||||||
|
static GENE_STATE: RefCell<Option<shared::gene::GeneState>> = const { RefCell::new(None) };
|
||||||
|
static PREVIEW_STATE: RefCell<Option<shared::gene::GeneState>> = const { RefCell::new(None) };
|
||||||
|
}
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn init_gene_state(gene_size: u32) -> bool {
|
||||||
|
let Ok(state) = shared::gene::new_state(gene_size as usize) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
GENE_STATE.with(|slot| *slot.borrow_mut() = Some(state));
|
||||||
|
PREVIEW_STATE.with(|slot| *slot.borrow_mut() = None);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn preview_gene_commitment(order_b64: &str) -> String {
|
||||||
|
let order = match shared::vm_extensions::decode_order_b64(0, order_b64) {
|
||||||
|
Ok(order) => order,
|
||||||
|
Err(_) => return String::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
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 Some(candidate) = candidate else {
|
||||||
|
return String::new();
|
||||||
|
};
|
||||||
|
let commitment = shared::gene::commitment_hex(&candidate);
|
||||||
|
PREVIEW_STATE.with(|slot| *slot.borrow_mut() = Some(candidate));
|
||||||
|
commitment
|
||||||
|
}
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn commit_gene_preview() -> bool {
|
||||||
|
let next = PREVIEW_STATE.with(|slot| slot.borrow_mut().take());
|
||||||
|
let Some(next) = next else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
GENE_STATE.with(|slot| *slot.borrow_mut() = Some(next));
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn discard_gene_preview() {
|
||||||
|
PREVIEW_STATE.with(|slot| *slot.borrow_mut() = None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn current_gene_commitment() -> String {
|
||||||
|
GENE_STATE.with(|slot| {
|
||||||
|
slot.borrow()
|
||||||
|
.as_ref()
|
||||||
|
.map(shared::gene::commitment_hex)
|
||||||
|
.unwrap_or_default()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use rand::SeedableRng;
|
||||||
|
|
||||||
|
fn order_b64(program: Vec<u8>) -> String {
|
||||||
|
let order = shared::vm_extensions::MutationOrder { step: 1, program };
|
||||||
|
shared::vm_extensions::encode_order_b64(&order)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_init_gene_state_success() {
|
||||||
|
assert!(init_gene_state(64));
|
||||||
|
let commitment = current_gene_commitment();
|
||||||
|
assert_eq!(commitment.len(), 64);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_init_gene_state_rejects_zero() {
|
||||||
|
assert!(!init_gene_state(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
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,
|
||||||
|
1,
|
||||||
|
]));
|
||||||
|
assert!(c.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_preview_rejects_invalid_order() {
|
||||||
|
init_gene_state(16);
|
||||||
|
let c = preview_gene_commitment("***bad-base64***");
|
||||||
|
assert!(c.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_commit_applies_preview() {
|
||||||
|
init_gene_state(16);
|
||||||
|
let before = current_gene_commitment();
|
||||||
|
let order = order_b64(vec![shared::vm_extensions::OP_MUTATE_POINT, 0, 0, 1]);
|
||||||
|
let preview = preview_gene_commitment(&order);
|
||||||
|
assert_ne!(preview, before);
|
||||||
|
assert!(commit_gene_preview());
|
||||||
|
let after = current_gene_commitment();
|
||||||
|
assert_eq!(preview, after);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_discard_preview_keeps_committed_state() {
|
||||||
|
init_gene_state(16);
|
||||||
|
let before = current_gene_commitment();
|
||||||
|
let order = order_b64(vec![shared::vm_extensions::OP_MUTATE_POINT, 0, 0, 0xFF]);
|
||||||
|
let preview = preview_gene_commitment(&order);
|
||||||
|
assert_ne!(preview, before);
|
||||||
|
discard_gene_preview();
|
||||||
|
let after = current_gene_commitment();
|
||||||
|
assert_eq!(before, after);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_commit_without_preview_returns_false() {
|
||||||
|
init_gene_state(16);
|
||||||
|
discard_gene_preview();
|
||||||
|
assert!(!commit_gene_preview());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_preview_commitment_matches_shared_engine() {
|
||||||
|
init_gene_state(16);
|
||||||
|
let order = shared::vm_extensions::MutationOrder {
|
||||||
|
step: 3,
|
||||||
|
program: vec![
|
||||||
|
shared::vm_extensions::OP_GENE_LOAD,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
shared::vm_extensions::OP_PRODUCE,
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
shared::vm_extensions::OP_GENE_LOAD,
|
||||||
|
2,
|
||||||
|
0,
|
||||||
|
shared::vm_extensions::OP_APPLY_MUTAGEN,
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
2,
|
||||||
|
0,
|
||||||
|
],
|
||||||
|
};
|
||||||
|
let b64 = shared::vm_extensions::encode_order_b64(&order);
|
||||||
|
|
||||||
|
let preview = preview_gene_commitment(&b64);
|
||||||
|
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_table_driven_parity_across_many_generated_orders() {
|
||||||
|
init_gene_state(64);
|
||||||
|
let mut rng = rand::rngs::StdRng::seed_from_u64(123);
|
||||||
|
let mut expected = shared::gene::new_state(64).unwrap();
|
||||||
|
|
||||||
|
for step in 0..24u64 {
|
||||||
|
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);
|
||||||
|
|
||||||
|
assert_eq!(preview, expected_commitment);
|
||||||
|
assert!(commit_gene_preview());
|
||||||
|
assert_eq!(current_gene_commitment(), expected_commitment);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user