Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba768da58e |
No files matched your search
Generated
+3
-3
@@ -245,7 +245,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "chronoseal-server"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"base64",
|
||||
@@ -273,7 +273,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "chronoseal-wasm"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"blake3",
|
||||
@@ -1294,7 +1294,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "shared"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"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.
|
||||
|
||||
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
|
||||
@@ -47,8 +50,11 @@ while remaining completely invisible and frictionless to legitimate human users.
|
||||
* Ed25519 + Blake3 cryptographic chaining
|
||||
* Behavioral entropy validation
|
||||
* Randomized stack-machine verification
|
||||
* Deterministic synthetic gene mutation chain
|
||||
* Server/WASM mutation parity checks
|
||||
* Silent rejection model
|
||||
* SQLite-backed ephemeral sessions
|
||||
* Configurable runtime DB backend selection (`db_type`)
|
||||
* Connection-pooled runtime architecture
|
||||
* Lightweight deployment footprint
|
||||
* Docker and native deployment support
|
||||
@@ -104,6 +110,7 @@ chronoseal --help
|
||||
| `health` | Perform daemon health probe |
|
||||
| `config` | Validate and print effective configuration |
|
||||
| `generate` | Generate operational material |
|
||||
| `db-type` | List database backend support status |
|
||||
| `metrics` | Output Prometheus metrics |
|
||||
| `stats` | Print runtime statistics |
|
||||
| `completion` | Generate shell completions |
|
||||
@@ -144,18 +151,24 @@ Browser Server
|
||||
│ Private key never leaves WASM memory │
|
||||
│ │
|
||||
├──── POST /init { public_key } ──────────►│
|
||||
│◄─── { session_id, salt, opcodes, H0 } ────┤
|
||||
│◄─── { session_id, salt, opcodes_b64, H0, │
|
||||
│ mutation_step, mutation_order_b64 } ──┤
|
||||
│ │
|
||||
│ Every 12–25s (randomized): │
|
||||
│ ┌─ Collect behavioral entropy │
|
||||
│ ├─ Execute VM opcode program │
|
||||
│ ├─ Execute verification VM opcodes │
|
||||
│ ├─ Preview mutation commitment │
|
||||
│ ├─ Attach mutation_step + commitment │
|
||||
│ ├─ Advance Blake3 hash chain │
|
||||
│ └─ Sign payload using Ed25519 │
|
||||
│ │
|
||||
├──── POST /heartbeat { signed_payload } ─►│
|
||||
│◄─── { status, next_salt } ────────────────┤
|
||||
├──── POST /hb { signed_payload } ────────►│
|
||||
│◄─── { status, next_salt, │
|
||||
│ next_mutation_step, │
|
||||
│ next_mutation_order_b64 } ────────────┤
|
||||
│ │
|
||||
│ Invalid sessions silently rejected │
|
||||
│ (`status=ok` without next_* fields) │
|
||||
```
|
||||
|
||||
The server validates:
|
||||
@@ -163,6 +176,8 @@ The server validates:
|
||||
* signature authenticity
|
||||
* heartbeat continuity
|
||||
* replay resistance
|
||||
* mutation step parity
|
||||
* mutation commitment parity
|
||||
* behavioral entropy
|
||||
* timestamp validity
|
||||
* fingerprint sanity
|
||||
@@ -229,10 +244,10 @@ The goal is to make automation:
|
||||
|
||||
```text
|
||||
chronoseal-rs/
|
||||
├── shared/ Shared types, constants, hash-chain logic
|
||||
├── shared/ Shared types, hash chain, gene + mutation engine
|
||||
├── server/ Axum HTTP daemon
|
||||
│ ├── routes/ API routes
|
||||
│ ├── session.rs Session lifecycle management
|
||||
│ ├── session.rs Session lifecycle + mutation parity checks
|
||||
│ ├── crypto.rs Ed25519 verification
|
||||
│ ├── trust.rs Behavioral validation
|
||||
│ ├── fingerprint/ Browser sanity validation
|
||||
@@ -242,7 +257,8 @@ chronoseal-rs/
|
||||
│ └── metrics.rs Prometheus metrics
|
||||
├── wasm/ Rust → WASM runtime
|
||||
│ ├── 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
|
||||
├── scripts/ Build/install/dev scripts
|
||||
└── docs/ Project documentation
|
||||
@@ -275,6 +291,21 @@ This makes heartbeat payloads structurally dynamic.
|
||||
| `0x08` | NOT | Unary inversion |
|
||||
| `0x09` | HASH | Blake3 stack hash |
|
||||
|
||||
## Mutation Opcodes (v0.6.0)
|
||||
|
||||
| Opcode | Mnemonic | Effect |
|
||||
| ------ | -------------------- | ------ |
|
||||
| `0x23` | GENE_LOAD | Push `gene[idx]` |
|
||||
| `0x24` | GENE_STORE | Pop and store at `gene[idx]` |
|
||||
| `0x25` | MUTATE_POINT | Apply wrapping byte delta at index |
|
||||
| `0x26` | INSERT | Insert popped byte at index |
|
||||
| `0x27` | DELETE | Delete byte at index and push removed value |
|
||||
| `0x28` | TRANSCRIBE | Push deterministic transcription hash |
|
||||
| `0x29` | APPLY_MUTAGEN | Mix environment symbol quantity into gene byte |
|
||||
| `0x2A` | FINALIZE_GENE_HASH | Push commitment-derived `u32` |
|
||||
| `0x2B` | CONSUME | Pop amount, subtract environment quantity |
|
||||
| `0x2C` | PRODUCE | Pop amount, add environment quantity |
|
||||
|
||||
---
|
||||
|
||||
# Hash Chain
|
||||
@@ -330,14 +361,18 @@ This prevents:
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
session_id TEXT PRIMARY KEY,
|
||||
public_key BLOB NOT NULL,
|
||||
salt BLOB NOT NULL,
|
||||
last_hash BLOB NOT NULL,
|
||||
chain_length INTEGER NOT NULL DEFAULT 1,
|
||||
created_at INTEGER NOT NULL,
|
||||
last_seen INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL
|
||||
session_id TEXT PRIMARY KEY,
|
||||
public_key BLOB NOT NULL,
|
||||
salt BLOB NOT NULL,
|
||||
last_hash BLOB NOT NULL,
|
||||
chain_length INTEGER NOT NULL DEFAULT 1,
|
||||
created_at INTEGER NOT NULL,
|
||||
last_seen INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL,
|
||||
gene BLOB NOT NULL DEFAULT X'',
|
||||
environment BLOB NOT NULL DEFAULT X'',
|
||||
pending_mutation BLOB NOT NULL DEFAULT X'',
|
||||
pending_mutation_step INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
```
|
||||
|
||||
@@ -354,7 +389,8 @@ Session continuity is designed to reset transparently.
|
||||
* Rust
|
||||
* Axum
|
||||
* Tokio
|
||||
* SQLite
|
||||
* SQLite (`sqlite-in-memory` / `sqlite-in-disk`)
|
||||
* `db_type=valkey` compatibility mode (falls back to in-memory in v0.6.0)
|
||||
* `r2d2`
|
||||
* `thiserror`
|
||||
|
||||
@@ -464,6 +500,20 @@ $XDG_CONFIG_HOME/chronoseal/config.toml
|
||||
~/.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
|
||||
|
||||
+41
-11
@@ -39,7 +39,12 @@ Content-Type: application/json
|
||||
"salt": "32-char hex string (16 bytes)",
|
||||
"opcodes_b64": "base64-encoded VM program (8–16 opcodes)",
|
||||
"initial_hash": "64-char hex string (32 bytes Blake3)",
|
||||
"expires_at": 1234567890123
|
||||
"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 |
|
||||
| `initial_hash` | `string` | `H(0) = Blake3(session_id ║ pub_key ║ salt)`; the first `prev_hash` |
|
||||
| `expires_at` | `number` | Unix timestamp in milliseconds; session expires after 30 minutes of inactivity |
|
||||
| `heartbeat_min_interval_ms` | `number` | Lower bound for randomized heartbeat scheduling |
|
||||
| `heartbeat_max_interval_ms` | `number` | Upper bound for randomized heartbeat scheduling |
|
||||
| `gene_size` | `number` | Initial synthetic gene size used by server and WASM (default 512) |
|
||||
| `mutation_step` | `number` | Server-issued mutation order step expected on next heartbeat |
|
||||
| `mutation_order_b64` | `string` | Base64-encoded mutation opcode program for the current step |
|
||||
|
||||
#### Error
|
||||
|
||||
@@ -89,6 +99,8 @@ Content-Type: application/json
|
||||
"devicePixelRatio": "2",
|
||||
"hardwareConcurrency": 8
|
||||
},
|
||||
"mutation_step": 1,
|
||||
"gene_commitment": "64-char hex Blake3 commitment",
|
||||
"signature": "128-char hex Ed25519 signature"
|
||||
}
|
||||
```
|
||||
@@ -104,6 +116,8 @@ Content-Type: application/json
|
||||
| `fingerprint.aspectRatio` | `string` | `(screen.width / screen.height).toFixed(10)` |
|
||||
| `fingerprint.devicePixelRatio` | `string` | `String(window.devicePixelRatio)` |
|
||||
| `fingerprint.hardwareConcurrency` | `number` | `navigator.hardwareConcurrency \|\| 1` |
|
||||
| `mutation_step` | `number` | Must match server-side pending mutation step |
|
||||
| `gene_commitment` | `string` | Commitment of the locally previewed candidate gene after applying `mutation_order_b64` |
|
||||
| `signature` | `string` | Hex-encoded 64-byte Ed25519 signature over the canonical payload |
|
||||
|
||||
#### Canonical Signing Payload
|
||||
@@ -115,6 +129,8 @@ alphabetically. Nested object keys follow their natural serialisation order.
|
||||
{
|
||||
"entropyData": { "events": [{ "t": …, "x": …, "y": … }] },
|
||||
"fingerprint": { "aspectRatio": "…", "devicePixelRatio": "…", "hardwareConcurrency": … },
|
||||
"geneCommitment":"…",
|
||||
"mutationStep": …,
|
||||
"prevHash": "…",
|
||||
"sessionId": "…",
|
||||
"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`,
|
||||
`prevHash`, `entropyData`, `stackState`) while the request body uses
|
||||
snake_case (`session_id`, `prev_hash`, `entropy_data`, `stack_state`).
|
||||
`prevHash`, `entropyData`, `stackState`, `mutationStep`, `geneCommitment`)
|
||||
while the request body uses snake_case (`session_id`, `prev_hash`,
|
||||
`entropy_data`, `stack_state`, `mutation_step`, `gene_commitment`).
|
||||
|
||||
#### Response `200 OK` — Accepted
|
||||
|
||||
```json
|
||||
{
|
||||
"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:
|
||||
1. Capture `sentSalt = currentSalt` before updating.
|
||||
2. Set `currentSalt = next_salt`.
|
||||
3. Compute `prevHash = compute_next_hash(prevHash, timestamp, entropyJson, stackStateJson, sentSalt)`.
|
||||
1. Preview commitment locally from `mutation_order_b64` and send it in the heartbeat.
|
||||
2. Capture `sentSalt = currentSalt` before updating.
|
||||
3. Set `currentSalt = next_salt`.
|
||||
4. Compute `prevHash = compute_next_hash(prevHash, timestamp, entropyJson, stackStateJson, sentSalt)`.
|
||||
5. Commit the previewed gene state.
|
||||
6. Replace pending mutation values with `next_mutation_step` and `next_mutation_order_b64`.
|
||||
|
||||
#### 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
|
||||
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` |
|
||||
| Signature invalid | Ed25519 verification fails against stored public key |
|
||||
| Hash chain broken | `hex(prev_hash) ≠ stored last_hash` |
|
||||
| Mutation step mismatch | `mutation_step ≠ pending_mutation_step` |
|
||||
| Mutation commitment mismatch | `gene_commitment` does not match server-computed candidate commitment |
|
||||
| Timestamp drift | `\|server_now_ms - timestamp\| > 30 000` |
|
||||
| Insufficient mouse events | `events.len() < 3` |
|
||||
| Insufficient mouse distance | `total_dist < 10.0 px` |
|
||||
@@ -202,7 +227,7 @@ Rust types (serde derive, no custom ordering).
|
||||
|
||||
## 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 |
|
||||
|---|---|---|
|
||||
@@ -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. |
|
||||
| `compute_next_hash(prev, ts, entropy, stack, salt)` | `(string, u64, string, string, string) → string` | Compute next Blake3 chain hash; all inputs/output hex or JSON strings. |
|
||||
| `run_program(b64)` | `(string) → JsValue` | Execute base64 VM program; return `{ stack: u32[], ip: number }`. |
|
||||
| `init_gene_state(gene_size)` | `(u32) → bool` | Initialise synthetic gene state in WASM memory. |
|
||||
| `preview_gene_commitment(order_b64)` | `(string) → string` | Apply mutation order on preview state and return commitment hex. |
|
||||
| `commit_gene_preview()` | `() → bool` | Commit previewed mutation state after accepted heartbeat. |
|
||||
| `discard_gene_preview()` | `() → void` | Discard previewed mutation state after rejection/error. |
|
||||
| `current_gene_commitment()` | `() → string` | Return current committed gene commitment hex. |
|
||||
|
||||
All functions return empty strings on error rather than panicking.
|
||||
Callers must check for empty return values before using the result.
|
||||
String-returning functions return `""` on error rather than panicking. Callers
|
||||
must check for empty strings and boolean return values before use.
|
||||
@@ -1,5 +1,7 @@
|
||||
# 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
|
||||
|
||||
ChronoSeal is a stateless, cryptographic browser attestation framework. Its
|
||||
@@ -42,7 +44,7 @@ synchronisation burden alone makes scaled operation expensive.
|
||||
### High-Level Design
|
||||
|
||||
- **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)
|
||||
- **Security Model**: Behavioral analysis + hash chaining + entropy scoring
|
||||
- **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"
|
||||
# sqlite-in-memory (default), sqlite-in-disk, valkey (v0.6.0 compatibility mode)
|
||||
db_type = "sqlite-in-memory"
|
||||
pid_file = "/run/chronoseal.pid"
|
||||
db_path = "/var/lib/chronoseal/chronoseal.sqlite"
|
||||
frontend_dir = "/usr/share/chronoseal/frontend"
|
||||
log_file = "/var/log/chronoseal/chronoseal.jsonl"
|
||||
# synthetic gene size (1..=65536), default 512
|
||||
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 { sendRequest } from './transport.js';
|
||||
|
||||
let session, prevHash, currentSalt, opcodesB64, lastTime;
|
||||
let minInterval = 12000;
|
||||
let maxInterval = 25000;
|
||||
let pendingMutationStep = 0;
|
||||
let pendingMutationOrderB64 = '';
|
||||
|
||||
export async function initHeartbeat() {
|
||||
await init();
|
||||
@@ -16,6 +27,11 @@ export async function initHeartbeat() {
|
||||
opcodesB64 = initResp.opcodes_b64;
|
||||
minInterval = initResp.heartbeat_min_interval_ms || 12000;
|
||||
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();
|
||||
scheduleNext();
|
||||
}
|
||||
@@ -40,6 +56,10 @@ async function sendHeartbeat() {
|
||||
const timestamp = Date.now();
|
||||
const entropyData = { events: events.map(e => ({ x: e.x, y: e.y, t: e.t })) };
|
||||
const entropyJson = JSON.stringify(entropyData);
|
||||
const geneCommitment = preview_gene_commitment(pendingMutationOrderB64);
|
||||
if (!geneCommitment) {
|
||||
throw new Error('Unable to compute mutation commitment');
|
||||
}
|
||||
|
||||
const signable = {
|
||||
sessionId: session,
|
||||
@@ -47,11 +67,14 @@ async function sendHeartbeat() {
|
||||
timestamp: timestamp,
|
||||
entropyData: entropyData,
|
||||
stackState: JSON.parse(stackState),
|
||||
fingerprint: fingerprint
|
||||
fingerprint: fingerprint,
|
||||
mutationStep: pendingMutationStep,
|
||||
geneCommitment: geneCommitment
|
||||
};
|
||||
const msg = JSON.stringify(signable, Object.keys(signable).sort());
|
||||
const sig = sign_message(msg);
|
||||
if (!sig) {
|
||||
discard_gene_preview();
|
||||
console.error('Keypair not initialised — skipping heartbeat');
|
||||
return;
|
||||
}
|
||||
@@ -62,22 +85,32 @@ async function sendHeartbeat() {
|
||||
entropy_data: entropyData,
|
||||
stack_state: JSON.parse(stackState),
|
||||
fingerprint,
|
||||
mutation_step: pendingMutationStep,
|
||||
gene_commitment: geneCommitment,
|
||||
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.
|
||||
// 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.
|
||||
const sentSalt = currentSalt;
|
||||
currentSalt = resp.next_salt;
|
||||
prevHash = compute_next_hash(prevHash, timestamp, entropyJson, stackState, sentSalt);
|
||||
pendingMutationStep = resp.next_mutation_step;
|
||||
pendingMutationOrderB64 = resp.next_mutation_order_b64;
|
||||
} else {
|
||||
discard_gene_preview();
|
||||
console.warn('Heartbeat rejected');
|
||||
}
|
||||
} catch (e) {
|
||||
discard_gene_preview();
|
||||
console.error(e);
|
||||
} finally {
|
||||
scheduleNext();
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "chronoseal-server"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
edition = "2021"
|
||||
|
||||
[[bin]]
|
||||
|
||||
+10
-1
@@ -53,7 +53,7 @@ impl GlobalArgs {
|
||||
pub enum Command {
|
||||
/// Run the ChronoSeal daemon.
|
||||
#[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),
|
||||
|
||||
@@ -81,6 +81,10 @@ pub enum Command {
|
||||
#[command(after_help = "Examples:\n chronoseal version\n chronoseal version --format json")]
|
||||
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.
|
||||
#[command(
|
||||
after_help = "Examples:\n chronoseal metrics\n chronoseal metrics --bind 127.0.0.1:3000"
|
||||
@@ -103,6 +107,11 @@ pub struct RunArgs {
|
||||
#[command(flatten)]
|
||||
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.
|
||||
#[arg(long, env = "CHRONOSEAL_DB_PATH")]
|
||||
pub db_path: Option<PathBuf>,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use crate::cli::{RunArgs, RuntimeArgs};
|
||||
use clap::ValueEnum;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
env, fs, io,
|
||||
@@ -6,10 +7,30 @@ use std::{
|
||||
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)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct Config {
|
||||
pub bind: String,
|
||||
pub db_type: DbType,
|
||||
pub pid_file: PathBuf,
|
||||
pub db_path: PathBuf,
|
||||
pub frontend_dir: PathBuf,
|
||||
@@ -24,12 +45,14 @@ pub struct Config {
|
||||
pub max_mouse_avg_speed: f64,
|
||||
pub min_pause_count: u32,
|
||||
pub require_mouse_activity: bool,
|
||||
pub gene_size: usize,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
bind: "0.0.0.0:3000".to_string(),
|
||||
db_type: DbType::SqliteInMemory,
|
||||
pid_file: PathBuf::from("/run/chronoseal.pid"),
|
||||
db_path: default_state_dir().join("chronoseal.sqlite"),
|
||||
frontend_dir: PathBuf::from("/usr/share/chronoseal/frontend"),
|
||||
@@ -44,6 +67,7 @@ impl Default for Config {
|
||||
max_mouse_avg_speed: 2.0,
|
||||
min_pause_count: 1,
|
||||
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) {
|
||||
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 {
|
||||
self.db_path = db_path.clone();
|
||||
}
|
||||
@@ -100,6 +127,11 @@ impl Config {
|
||||
bind: self.bind.clone(),
|
||||
source,
|
||||
})?;
|
||||
if !(1..=shared::constants::MAX_GENE_SIZE).contains(&self.gene_size) {
|
||||
return Err(ConfigError::InvalidGeneSize {
|
||||
size: self.gene_size,
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -107,6 +139,14 @@ impl Config {
|
||||
if let Ok(value) = env::var("CHRONOSEAL_BIND") {
|
||||
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") {
|
||||
self.pid_file = PathBuf::from(value);
|
||||
}
|
||||
@@ -169,6 +209,11 @@ impl Config {
|
||||
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,
|
||||
source: std::net::AddrParseError,
|
||||
},
|
||||
InvalidGeneSize {
|
||||
size: usize,
|
||||
},
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ConfigError {
|
||||
@@ -198,6 +246,13 @@ impl std::fmt::Display for ConfigError {
|
||||
Self::InvalidBind { 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")
|
||||
}
|
||||
|
||||
#[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 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_key_bytes: &[u8],
|
||||
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 sig_bytes = hex::decode(&req.signature)?;
|
||||
let sig = Signature::from_slice(&sig_bytes)?;
|
||||
|
||||
// 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)?;
|
||||
let message = canonical_signing_message(req)?;
|
||||
|
||||
pk.verify_strict(message.as_bytes(), &sig)?;
|
||||
Ok(())
|
||||
|
||||
@@ -19,6 +19,9 @@ pub enum SessionError {
|
||||
|
||||
#[error("Invalid public key length")]
|
||||
InvalidPublicKeyLength,
|
||||
|
||||
#[error("Invalid gene configuration: {0}")]
|
||||
InvalidGeneConfiguration(String),
|
||||
}
|
||||
|
||||
impl IntoResponse for SessionError {
|
||||
@@ -65,4 +68,16 @@ pub enum VerificationError {
|
||||
|
||||
#[error("Fingerprint validation failed: {0}")]
|
||||
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) => {
|
||||
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)) => {
|
||||
let mut config = Config::load(cli.globals.config.as_deref())?;
|
||||
config.apply_runtime_args(args);
|
||||
|
||||
@@ -21,6 +21,8 @@ pub async fn handler(
|
||||
Json(HeartbeatResponse {
|
||||
status: "ok".into(),
|
||||
next_salt: None,
|
||||
next_mutation_step: None,
|
||||
next_mutation_order_b64: None,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -36,16 +38,20 @@ pub async fn handler(
|
||||
Json(HeartbeatResponse {
|
||||
status: "error".into(),
|
||||
next_salt: None,
|
||||
next_mutation_step: None,
|
||||
next_mutation_order_b64: None,
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
match crate::session::verify_heartbeat(&conn, &config, &payload) {
|
||||
Ok(next_salt) => (
|
||||
Ok(result) => (
|
||||
StatusCode::OK,
|
||||
Json(HeartbeatResponse {
|
||||
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) => {
|
||||
@@ -55,8 +61,157 @@ pub async fn handler(
|
||||
Json(HeartbeatResponse {
|
||||
status: "ok".into(),
|
||||
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 {
|
||||
fn to_text(&self) -> String {
|
||||
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.db_type.as_str(),
|
||||
self.pid_file.display(),
|
||||
self.db_path.display(),
|
||||
self.frontend_dir.display(),
|
||||
self.log_file
|
||||
.as_ref()
|
||||
.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>> {
|
||||
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 {
|
||||
db_pool,
|
||||
rate_limiter: Mutex::new(RateLimiter::new()),
|
||||
@@ -147,6 +180,40 @@ pub async fn run_daemon(config: Config) -> Result<(), Box<dyn std::error::Error>
|
||||
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 {
|
||||
if http_get(&config.bind, "/health").is_ok() {
|
||||
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(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 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(
|
||||
conn: &rusqlite::Connection,
|
||||
@@ -27,22 +38,44 @@ pub fn create_session(
|
||||
if pub_key.len() != shared::constants::SESSION_ID_LEN {
|
||||
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 salt = rand::random::<[u8; shared::constants::SALT_LEN]>();
|
||||
let now = storage::current_time_ms();
|
||||
let expires_at = now + (config.expiration_minutes as u64) * 60 * 1000;
|
||||
|
||||
let initial_hash = shared::hashing::initial_hash(&session_id, &pub_key, &salt);
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO sessions (session_id, public_key, salt, last_hash, created_at, last_seen, expires_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
||||
params![session_id, pub_key, salt.to_vec(), initial_hash, now, now, expires_at],
|
||||
)?;
|
||||
|
||||
let opcodes = vm::generate_random_program(8..=16);
|
||||
let opcodes_b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &opcodes);
|
||||
|
||||
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 {
|
||||
session_id,
|
||||
salt: hex::encode(salt),
|
||||
@@ -51,6 +84,9 @@ pub fn create_session(
|
||||
expires_at,
|
||||
heartbeat_min_interval_ms: config.heartbeat_min_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,
|
||||
config: &crate::config::Config,
|
||||
req: &HeartbeatRequest,
|
||||
) -> Result<String, crate::errors::VerificationError> {
|
||||
) -> Result<HeartbeatVerificationResult, crate::errors::VerificationError> {
|
||||
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| {
|
||||
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| {
|
||||
if matches!(e, rusqlite::Error::QueryReturnedNoRows) {
|
||||
@@ -84,24 +148,45 @@ pub fn verify_heartbeat(
|
||||
.map_err(|e| crate::errors::VerificationError::Signature(e.to_string()))?;
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
// 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);
|
||||
if diff.abs() > config.max_timestamp_drift_ms {
|
||||
return Err(crate::errors::VerificationError::TimestampDrift);
|
||||
}
|
||||
|
||||
// 4. Trusted mouse & fingerprint
|
||||
// 5. Trusted mouse & fingerprint
|
||||
trust::validate_mouse(&req.entropy_data, config)
|
||||
.map_err(|e| crate::errors::VerificationError::TrustFailed(e.to_string()))?;
|
||||
fingerprint::validate(&req.fingerprint)
|
||||
.map_err(|e| crate::errors::VerificationError::FingerprintFailed(e.to_string()))?;
|
||||
|
||||
// 5. Compute new hash
|
||||
let prev_hash_bytes = hex::decode(&req.prev_hash)?;
|
||||
// 6. Compute new hash
|
||||
let new_hash = shared::hashing::next_chain_hash(
|
||||
&prev_hash_bytes,
|
||||
req.timestamp,
|
||||
@@ -110,115 +195,415 @@ pub fn verify_heartbeat(
|
||||
&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_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(
|
||||
"UPDATE sessions SET last_hash=?1, salt=?2, chain_length=chain_length+1, last_seen=?3 WHERE session_id=?4",
|
||||
params![new_hash, next_salt.to_vec(), now, req.session_id],
|
||||
"UPDATE sessions SET
|
||||
last_hash=?1,
|
||||
salt=?2,
|
||||
chain_length=chain_length+1,
|
||||
last_seen=?3,
|
||||
gene=?4,
|
||||
environment=?5,
|
||||
pending_mutation=?6,
|
||||
pending_mutation_step=?7
|
||||
WHERE session_id=?8",
|
||||
params![
|
||||
new_hash,
|
||||
next_salt.to_vec(),
|
||||
now,
|
||||
candidate_state.gene,
|
||||
next_environment_blob,
|
||||
next_mutation.program,
|
||||
next_step,
|
||||
req.session_id
|
||||
],
|
||||
)?;
|
||||
|
||||
Ok(next_salt_hex)
|
||||
Ok(HeartbeatVerificationResult {
|
||||
next_salt_hex,
|
||||
next_mutation_step: next_step,
|
||||
next_mutation_order_b64: next_mutation_b64,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ed25519_dalek::{Signer, SigningKey};
|
||||
use shared::protocol::{EntropyData, Fingerprint, HeartbeatRequest, StackState};
|
||||
use shared::protocol::{EntropyData, Fingerprint, HeartbeatRequest, MouseEvent, StackState};
|
||||
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) {
|
||||
let mut payload: std::collections::BTreeMap<&str, serde_json::Value> =
|
||||
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 message = crate::crypto::canonical_signing_message(req).unwrap();
|
||||
let sig = sk.sign(message.as_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]
|
||||
fn test_session_lifecycle_and_verification() {
|
||||
let pool = storage::init_pool(Path::new(":memory:")).unwrap();
|
||||
let conn = pool.get().unwrap();
|
||||
let config = test_config();
|
||||
|
||||
let config = crate::config::Config {
|
||||
expiration_minutes: 30,
|
||||
max_timestamp_drift_ms: 30000,
|
||||
min_mouse_total_dist: 10.0,
|
||||
max_mouse_avg_speed: 2.0,
|
||||
min_pause_count: 1,
|
||||
require_mouse_activity: false, // simpler for tests
|
||||
..crate::config::Config::default()
|
||||
};
|
||||
let (init, signing_key) = create_test_session(&conn, &config);
|
||||
assert_eq!(init.gene_size, config.gene_size as u32);
|
||||
assert!(!init.mutation_order_b64.is_empty());
|
||||
assert_eq!(init.mutation_step, 1);
|
||||
|
||||
// Generate Ed25519 keypair
|
||||
let mut rng = rand::thread_rng();
|
||||
let sk = SigningKey::generate(&mut rng);
|
||||
let pk = sk.verifying_key();
|
||||
let pub_key_hex = hex::encode(pk.to_bytes());
|
||||
let mut client = client_from_init(&init, signing_key);
|
||||
for _ in 0..5 {
|
||||
run_successful_heartbeat(&conn, &config, &mut client);
|
||||
}
|
||||
|
||||
// 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();
|
||||
assert_eq!(stats.sessions, 1);
|
||||
assert_eq!(stats.expired_sessions, 0);
|
||||
assert_eq!(stats.max_chain_length, 6);
|
||||
}
|
||||
|
||||
// 2. Heartbeat Verification
|
||||
let now = storage::current_time_ms();
|
||||
let entropy_data = EntropyData { events: vec![] };
|
||||
let stack_state = StackState {
|
||||
stack: vec![42],
|
||||
ip: 5,
|
||||
};
|
||||
let fingerprint = Fingerprint {
|
||||
aspect_ratio: "1.77".to_string(),
|
||||
device_pixel_ratio: "2.0".to_string(),
|
||||
hardware_concurrency: 8,
|
||||
};
|
||||
#[test]
|
||||
fn test_deterministic_server_client_parity_across_many_heartbeats() {
|
||||
let pool = storage::init_pool(Path::new(":memory:")).unwrap();
|
||||
let conn = pool.get().unwrap();
|
||||
let config = test_config();
|
||||
let (init, signing_key) = create_test_session(&conn, &config);
|
||||
let mut client = client_from_init(&init, signing_key);
|
||||
|
||||
let mut req = HeartbeatRequest {
|
||||
session_id: init_resp.session_id.clone(),
|
||||
prev_hash: init_resp.initial_hash.clone(),
|
||||
timestamp: now,
|
||||
entropy_data,
|
||||
stack_state,
|
||||
fingerprint,
|
||||
signature: "".to_string(),
|
||||
};
|
||||
for _ in 0..12 {
|
||||
run_successful_heartbeat(&conn, &config, &mut client);
|
||||
let server_state = load_server_gene_state(&conn, &client.session_id);
|
||||
assert_eq!(server_state, client.committed_gene_state);
|
||||
}
|
||||
}
|
||||
|
||||
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 next_salt = verify_heartbeat(&conn, &config, &req).unwrap();
|
||||
assert!(!next_salt.is_empty());
|
||||
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(
|
||||
&mut client,
|
||||
&req,
|
||||
candidate_state,
|
||||
&entropy,
|
||||
&stack,
|
||||
&result,
|
||||
);
|
||||
|
||||
// Try duplicate/broken hash chain (prev_hash unchanged but expected next hash in DB)
|
||||
let res = verify_heartbeat(&conn, &config, &req);
|
||||
assert!(res.is_err());
|
||||
let replay = verify_heartbeat(&conn, &config, &req);
|
||||
assert!(matches!(
|
||||
res.unwrap_err(),
|
||||
replay.unwrap_err(),
|
||||
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,
|
||||
created_at 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(())
|
||||
}
|
||||
|
||||
|
||||
+53
-3
@@ -1,4 +1,8 @@
|
||||
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> {
|
||||
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 {
|
||||
// Not enough operands for any binary op — push a literal.
|
||||
ops.push(0x00);
|
||||
let val = rng.gen::<u32>();
|
||||
let val = rng.r#gen::<u32>();
|
||||
ops.extend_from_slice(&val.to_le_bytes());
|
||||
depth += 1;
|
||||
} else {
|
||||
@@ -18,7 +22,7 @@ pub fn generate_random_program(len_range: std::ops::RangeInclusive<usize>) -> Ve
|
||||
0x00 => {
|
||||
// PUSH literal
|
||||
ops.push(0x00);
|
||||
let val = rng.gen::<u32>();
|
||||
let val = rng.r#gen::<u32>();
|
||||
ops.extend_from_slice(&val.to_le_bytes());
|
||||
depth += 1;
|
||||
}
|
||||
@@ -43,4 +47,50 @@ pub fn generate_random_program(len_range: std::ops::RangeInclusive<usize>) -> Ve
|
||||
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]
|
||||
name = "shared"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
@@ -10,4 +10,4 @@ blake3 = "1"
|
||||
hex = "0.4"
|
||||
base64 = "0.22"
|
||||
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 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 gene;
|
||||
pub mod hashing;
|
||||
pub mod protocol;
|
||||
pub mod vm_extensions;
|
||||
+15
-6
@@ -1,11 +1,11 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct InitRequest {
|
||||
pub public_key: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct InitResponse {
|
||||
pub session_id: String,
|
||||
pub salt: String,
|
||||
@@ -14,9 +14,12 @@ pub struct InitResponse {
|
||||
pub expires_at: u64,
|
||||
pub heartbeat_min_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 session_id: String,
|
||||
pub prev_hash: String,
|
||||
@@ -24,17 +27,23 @@ pub struct HeartbeatRequest {
|
||||
pub entropy_data: EntropyData,
|
||||
pub stack_state: StackState,
|
||||
pub fingerprint: Fingerprint,
|
||||
pub mutation_step: u64,
|
||||
pub gene_commitment: String,
|
||||
pub signature: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct HeartbeatResponse {
|
||||
pub status: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
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 {
|
||||
#[serde(rename = "aspectRatio")]
|
||||
pub aspect_ratio: String,
|
||||
@@ -44,7 +53,7 @@ pub struct Fingerprint {
|
||||
pub hardware_concurrency: u32,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct EntropyData {
|
||||
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]
|
||||
name = "chronoseal-wasm"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
|
||||
@@ -4,3 +4,4 @@ pub mod entropy;
|
||||
pub mod fingerprint;
|
||||
pub mod transport;
|
||||
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