diff --git a/Cargo.lock b/Cargo.lock index b020f94..6f34e89 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/README.md b/README.md index 5dff59f..5056d14 100644 --- a/README.md +++ b/README.md @@ -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` 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 diff --git a/docs/API.md b/docs/API.md index 644be85..47db061 100644 --- a/docs/API.md +++ b/docs/API.md @@ -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. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 9c906ff..3e4e66f 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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 diff --git a/docs/REFRACTORING-v0.6.0.md b/docs/REFRACTORING-v0.6.0.md new file mode 100644 index 0000000..af6e583 --- /dev/null +++ b/docs/REFRACTORING-v0.6.0.md @@ -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`), 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. diff --git a/docs/chronoseal.example.toml b/docs/chronoseal.example.toml index 1dd35da..ac0982c 100644 --- a/docs/chronoseal.example.toml +++ b/docs/chronoseal.example.toml @@ -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 diff --git a/frontend/heartbeat.js b/frontend/heartbeat.js index 2566d23..74cd195 100644 --- a/frontend/heartbeat.js +++ b/frontend/heartbeat.js @@ -1,10 +1,21 @@ -import init, { generate_keypair, sign_message, compute_next_hash, run_program } from './pkg/chronoseal_wasm.js'; +import init, { + generate_keypair, + sign_message, + compute_next_hash, + run_program, + init_gene_state, + preview_gene_commitment, + commit_gene_preview, + discard_gene_preview +} from './pkg/chronoseal_wasm.js'; import { collectEntropy } from './entropy.js'; import { sendRequest } from './transport.js'; let session, prevHash, currentSalt, opcodesB64, lastTime; let minInterval = 12000; let maxInterval = 25000; +let pendingMutationStep = 0; +let pendingMutationOrderB64 = ''; export async function initHeartbeat() { await init(); @@ -16,6 +27,11 @@ export async function initHeartbeat() { opcodesB64 = initResp.opcodes_b64; minInterval = initResp.heartbeat_min_interval_ms || 12000; maxInterval = initResp.heartbeat_max_interval_ms || 25000; + if (!init_gene_state(initResp.gene_size || 512)) { + throw new Error('Failed to initialize gene state'); + } + pendingMutationStep = initResp.mutation_step; + pendingMutationOrderB64 = initResp.mutation_order_b64; lastTime = performance.now(); scheduleNext(); } @@ -40,6 +56,10 @@ async function sendHeartbeat() { const timestamp = Date.now(); const entropyData = { events: events.map(e => ({ x: e.x, y: e.y, t: e.t })) }; const entropyJson = JSON.stringify(entropyData); + const geneCommitment = preview_gene_commitment(pendingMutationOrderB64); + if (!geneCommitment) { + throw new Error('Unable to compute mutation commitment'); + } const signable = { sessionId: session, @@ -47,11 +67,14 @@ async function sendHeartbeat() { timestamp: timestamp, entropyData: entropyData, stackState: JSON.parse(stackState), - fingerprint: fingerprint + fingerprint: fingerprint, + mutationStep: pendingMutationStep, + geneCommitment: geneCommitment }; const msg = JSON.stringify(signable, Object.keys(signable).sort()); const sig = sign_message(msg); if (!sig) { + discard_gene_preview(); console.error('Keypair not initialised — skipping heartbeat'); return; } @@ -62,22 +85,32 @@ async function sendHeartbeat() { entropy_data: entropyData, stack_state: JSON.parse(stackState), fingerprint, + mutation_step: pendingMutationStep, + gene_commitment: geneCommitment, signature: sig }); - if (resp.next_salt) { + if (resp.next_salt && resp.next_mutation_step && resp.next_mutation_order_b64) { + if (!commit_gene_preview()) { + discard_gene_preview(); + throw new Error('Failed to commit local mutation preview'); + } // IMPORTANT: capture the salt that was active when this heartbeat was sent. // The server computes new_hash = H(prev, ts, entropy, stack, OLD_salt) and stores it, // then rotates to next_salt. We must mirror that using the same old salt, then rotate. const sentSalt = currentSalt; currentSalt = resp.next_salt; prevHash = compute_next_hash(prevHash, timestamp, entropyJson, stackState, sentSalt); + pendingMutationStep = resp.next_mutation_step; + pendingMutationOrderB64 = resp.next_mutation_order_b64; } else { + discard_gene_preview(); console.warn('Heartbeat rejected'); } } catch (e) { + discard_gene_preview(); console.error(e); } finally { scheduleNext(); } -} \ No newline at end of file +} diff --git a/server/Cargo.toml b/server/Cargo.toml index 5d73026..9b6be2a 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "chronoseal-server" -version = "0.5.0" +version = "0.6.0" edition = "2021" [[bin]] diff --git a/server/src/cli.rs b/server/src/cli.rs index bf4858c..d7b345b 100644 --- a/server/src/cli.rs +++ b/server/src/cli.rs @@ -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, + /// SQLite database path. Use ':memory:' for ephemeral state. #[arg(long, env = "CHRONOSEAL_DB_PATH")] pub db_path: Option, diff --git a/server/src/config.rs b/server/src/config.rs index 3ee5c11..4622884 100644 --- a/server/src/config.rs +++ b/server/src/config.rs @@ -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); + } +} diff --git a/server/src/crypto.rs b/server/src/crypto.rs index 561c332..6d10a3d 100644 --- a/server/src/crypto.rs +++ b/server/src/crypto.rs @@ -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> { + // 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(()) diff --git a/server/src/errors.rs b/server/src/errors.rs index f2cabda..a496a80 100644 --- a/server/src/errors.rs +++ b/server/src/errors.rs @@ -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), } diff --git a/server/src/main.rs b/server/src/main.rs index 9a09617..d404c3b 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -75,6 +75,9 @@ async fn try_main() -> Result<(), Box> { 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); diff --git a/server/src/routes/heartbeat.rs b/server/src/routes/heartbeat.rs index 5a79bcd..620d396 100644 --- a/server/src/routes/heartbeat.rs +++ b/server/src/routes/heartbeat.rs @@ -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, 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()); + } +} diff --git a/server/src/runtime.rs b/server/src/runtime.rs index c5c8b32..2c1b3bb 100644 --- a/server/src/runtime.rs +++ b/server/src/runtime.rs @@ -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, +} + +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> { 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 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> { + 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 .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); + } +} diff --git a/server/src/session.rs b/server/src/session.rs index 74ea6de..b04419f 100644 --- a/server/src/session.rs +++ b/server/src/session.rs @@ -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 { +) -> Result { 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, Vec, Vec, u64) = stmt + let ( + pub_key, + salt, + stored_last_hash, + expires_at, + gene_blob, + environment_blob, + pending_mutation, + pending_step, + ): ( + Vec, + Vec, + Vec, + u64, + Vec, + Vec, + Vec, + 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, Vec) = 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) + ); + } + } } diff --git a/server/src/storage.rs b/server/src/storage.rs index 263898a..558f21b 100644 --- a/server/src/storage.rs +++ b/server/src/storage.rs @@ -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(()) } diff --git a/server/src/vm.rs b/server/src/vm.rs index 0e62dd7..f78ffc2 100644 --- a/server/src/vm.rs +++ b/server/src/vm.rs @@ -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) -> Vec { let mut rng = rand::thread_rng(); @@ -9,7 +13,7 @@ pub fn generate_random_program(len_range: std::ops::RangeInclusive) -> Ve if depth < 2 { // Not enough operands for any binary op — push a literal. ops.push(0x00); - let val = rng.gen::(); + let val = rng.r#gen::(); 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) -> Ve 0x00 => { // PUSH literal ops.push(0x00); - let val = rng.gen::(); + let val = rng.r#gen::(); ops.extend_from_slice(&val.to_le_bytes()); depth += 1; } @@ -43,4 +47,50 @@ pub fn generate_random_program(len_range: std::ops::RangeInclusive) -> Ve ops } -// Server does not need to execute the program; client does. +pub fn execute_mutation_program( + state: &mut GeneState, + program: &[u8], +) -> Result { + vm_extensions::execute_program(state, program) +} + +pub fn execute_mutation_order( + state: &mut GeneState, + order: &MutationOrder, +) -> Result { + 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)); + } +} diff --git a/shared/Cargo.toml b/shared/Cargo.toml index 30d188b..e05511c 100644 --- a/shared/Cargo.toml +++ b/shared/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "shared" -version = "0.5.0" +version = "0.6.0" edition = "2021" [dependencies] @@ -10,4 +10,4 @@ blake3 = "1" hex = "0.4" base64 = "0.22" rand = "0.8" -ed25519-dalek = { version = "2", features = ["rand_core"] } \ No newline at end of file +ed25519-dalek = { version = "2", features = ["rand_core"] } diff --git a/shared/src/constants.rs b/shared/src/constants.rs index 430214d..444bcc7 100644 --- a/shared/src/constants.rs +++ b/shared/src/constants.rs @@ -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; diff --git a/shared/src/gene.rs b/shared/src/gene.rs new file mode 100644 index 0000000..da21c9a --- /dev/null +++ b/shared/src/gene.rs @@ -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, + pub environment: Vec, +} + +#[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 { + 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 { + 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 { + 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, 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, 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 = 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); + } + } +} diff --git a/shared/src/lib.rs b/shared/src/lib.rs index 896f578..e0cc48e 100644 --- a/shared/src/lib.rs +++ b/shared/src/lib.rs @@ -1,3 +1,5 @@ pub mod constants; +pub mod gene; pub mod hashing; pub mod protocol; +pub mod vm_extensions; diff --git a/shared/src/protocol.rs b/shared/src/protocol.rs index c14efe2..85c1419 100644 --- a/shared/src/protocol.rs +++ b/shared/src/protocol.rs @@ -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, + #[serde(skip_serializing_if = "Option::is_none")] + pub next_mutation_step: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub next_mutation_order_b64: Option, } -#[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, } diff --git a/shared/src/vm_extensions.rs b/shared/src/vm_extensions.rs new file mode 100644 index 0000000..ba52b7d --- /dev/null +++ b/shared/src/vm_extensions.rs @@ -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, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExecutionTrace { + pub final_ip: usize, + pub final_stack: Vec, + 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 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 { + 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( + 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::()); + stack_depth += 1; + } + 1 => { + program.push(OP_TRANSCRIBE); + push_u16(&mut program, rng.r#gen::()); + 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::()); + stack_depth -= 1; + } + } + 4 => { + program.push(OP_MUTATE_POINT); + push_u16(&mut program, rng.r#gen::()); + program.push(rng.r#gen::()); + } + 5 => { + if stack_depth > 0 && estimated_gene_len < MAX_GENE_SIZE { + program.push(OP_INSERT); + push_u16(&mut program, rng.r#gen::()); + stack_depth -= 1; + estimated_gene_len += 1; + } + } + 6 => { + program.push(OP_DELETE); + push_u16(&mut program, rng.r#gen::()); + 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::()); + push_u16(&mut program, rng.r#gen::()); + stack_depth -= 1; + } + } + 8 => { + if stack_depth > 0 { + program.push(OP_CONSUME); + push_u16(&mut program, rng.r#gen::()); + } + } + _ => { + if stack_depth > 0 { + program.push(OP_PRODUCE); + push_u16(&mut program, rng.r#gen::()); + } + } + } + } + + MutationOrder { step, program } +} + +pub fn apply_program_clone(state: &GeneState, program: &[u8]) -> Result { + 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 { + 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 = 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, value: u16) { + buf.extend_from_slice(&value.to_le_bytes()); +} + +fn take_u8(bytes: &[u8], ip: &mut usize, opcode: u8) -> Result { + 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 { + 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, opcode: u8, ip: usize) -> Result { + 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::(); + } + + 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:?}" + ); + } +} diff --git a/wasm/Cargo.toml b/wasm/Cargo.toml index f1b51c8..90c7faa 100644 --- a/wasm/Cargo.toml +++ b/wasm/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "chronoseal-wasm" -version = "0.5.0" +version = "0.6.0" edition = "2021" [lib] diff --git a/wasm/src/lib.rs b/wasm/src/lib.rs index 88bced6..a5a1580 100644 --- a/wasm/src/lib.rs +++ b/wasm/src/lib.rs @@ -4,3 +4,4 @@ pub mod entropy; pub mod fingerprint; pub mod transport; pub mod vm; +pub mod vm_extensions; diff --git a/wasm/src/vm_extensions.rs b/wasm/src/vm_extensions.rs new file mode 100644 index 0000000..78901b4 --- /dev/null +++ b/wasm/src/vm_extensions.rs @@ -0,0 +1,190 @@ +use std::cell::RefCell; +use wasm_bindgen::prelude::*; + +thread_local! { + static GENE_STATE: RefCell> = const { RefCell::new(None) }; + static PREVIEW_STATE: RefCell> = 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) -> 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); + } + } +}