1 Commits
Author SHA1 Message Date
thakares ba768da58e feat: implement v0.6.0 mutation engine and db_type runtime selection
Rust / build (push) Canceled after 0s
2026-05-29 14:50:45 +05:30
27 changed files with 2613 additions and 156 deletions

No files matched your search

Generated
+3 -3
View File
@@ -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",
+66 -16
View File
@@ -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
View File
@@ -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.
+3 -1
View File
@@ -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
+115
View File
@@ -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.
+4
View File
@@ -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
+36 -3
View File
@@ -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,20 +85,30 @@ 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
View File
@@ -1,6 +1,6 @@
[package]
name = "chronoseal-server"
version = "0.5.0"
version = "0.6.0"
edition = "2021"
[[bin]]
+10 -1
View File
@@ -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>,
+107
View File
@@ -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
View File
@@ -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(())
+15
View File
@@ -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),
}
+3
View File
@@ -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);
+157 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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));
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "shared"
version = "0.5.0"
version = "0.6.0"
edition = "2021"
[dependencies]
+4
View File
@@ -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;
+381
View File
@@ -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);
}
}
}
+2
View File
@@ -1,3 +1,5 @@
pub mod constants;
pub mod gene;
pub mod hashing;
pub mod protocol;
pub mod vm_extensions;
+15 -6
View File
@@ -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>,
}
+722
View File
@@ -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
View File
@@ -1,6 +1,6 @@
[package]
name = "chronoseal-wasm"
version = "0.5.0"
version = "0.6.0"
edition = "2021"
[lib]
+1
View File
@@ -4,3 +4,4 @@ pub mod entropy;
pub mod fingerprint;
pub mod transport;
pub mod vm;
pub mod vm_extensions;
+190
View File
@@ -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);
}
}
}