39 Commits
Author SHA1 Message Date
thakares fc2d693518 Add comparison and performance tuning documentation
Rust / build (push) Canceled after 0s
2026-05-29 22:34:13 +05:30
thakares 0ed3cb444d Refactor attestation engine and synchronize project documentation
- Refine session and storage lifecycle handling
- Improve VM extension architecture across server, shared, and WASM runtimes
- Enhance synthetic gene mutation engine integration and parity guarantees
- Align deterministic state progression between server and browser execution paths
- Update configuration examples and deployment guidance
- Expand architecture, API, threat model, privacy, and WASM build documentation
- Refresh README with comprehensive project overview, operational workflows,
  browser integration details, storage backend documentation, and security model
- Document v0.6.0 refactoring outcomes and design rationale
- Improve consistency across documentation, configuration, and implementation

This commit consolidates the v0.6.0 architectural refactoring effort,
strengthening deterministic browser/server parity while improving
maintainability, operational clarity, and project documentation.
2026-05-29 21:55:08 +05:30
thakares 2b8afd54e0 docs: update README and docs for v0.6.0 architecture and deployment, preserve current server/shared/wasm updates 2026-05-29 21:27:11 +05:30
thakares 6067746898 Suppress dead_code warnings for VM execution helpers 2026-05-29 18:13:33 +05:30
thakares 3d2a1a0ad7 Delete LICENSE-MIT 2026-05-29 17:47:49 +05:30
thakares 815d29af4c fix: correct license badge to MIT OR Apache-2.0 2026-05-29 16:11:40 +05:30
thakares b2835f454f docs: update ARCHITECTURE.md for v0.6.0 gene mutation system 2026-05-29 15:55:33 +05:30
thakares 4a64a57347 Update README.md 2026-05-29 15:52:00 +05:30
thakares 9d52828bb6 fix: correct license badge to MIT OR Apache-2.0 2026-05-29 15:44:07 +05:30
thakares 19666bd608 fix: correct license badge to MIT OR Apache-2.0 2026-05-29 15:42:58 +05:30
thakares 089a834f96 docs: comprehensive README with v0.6.0 gene mutation system, contributing & security policy
- Add v0.6.0 synthetic gene mutation system section (from REFRACTORING-v0.6.0.md)
- Add contributing guidelines, security policy, language breakdown
- Add badge bar, mutation threat row, topics tags
- No existing content removed
2026-05-29 15:39:39 +05:30
thakares ba768da58e feat: implement v0.6.0 mutation engine and db_type runtime selection
Rust / build (push) Canceled after 0s
2026-05-29 14:50:45 +05:30
thakares 217dc5f92b Improve session privacy handling and cleanup logic
Rust / build (push) Canceled after 0s
2026-05-23 15:57:34 +05:30
thakares 0b43530651 Introduce Unix-native daemon architecture and operational tooling 2026-05-22 19:58:14 +05:30
thakares e0f6d7c72c Add systemd service and installation workflow improvements 2026-05-22 19:43:11 +05:30
thakares 9e78daeeba Refactor ChronoSeal daemon for dynamic configurations, connection pooling, custom error handling, and complete tests (v0.5.0)
Rust / build (push) Canceled after 0s
2026-05-22 18:56:28 +05:30
thakares 60aaf0cd96 Add privacy-first design and policy documentation
Rust / build (push) Canceled after 0s
2026-05-14 12:40:04 +05:30
thakares 90ab322332 Expand design philosophy documentation 2026-05-13 16:32:00 +05:30
thakares eff782847d Expand design philosophy documentation 2026-05-13 16:24:51 +05:30
thakares ed0458633b Refine architecture and design philosophy documentation 2026-05-13 16:17:17 +05:30
thakares 3edea4bdff Implement CLI configuration and runtime management 2026-05-13 15:54:15 +05:30
thakares 2ac0afa691 Update copyright name in LICENSE-MIT file 2026-05-12 20:32:39 +05:30
thakares cbe12bcd49 Delete logo.png 2026-05-12 20:14:21 +05:30
thakares f21146e2f2 Add ChronoSeal logo to README 2026-05-12 20:05:59 +05:30
thakares 7972123887 Add ChronoSeal logo to README 2026-05-12 20:04:17 +05:30
thakares 630c451688 Refine ChronoSeal logo assets 2026-05-12 19:34:31 +05:30
thakares 8559e023be Refine ChronoSeal logo assets 2026-05-12 19:33:40 +05:30
thakares 75cb978fba Add ChronoSeal logo assets 2026-05-12 18:42:52 +05:30
thakares 8d318d5da4 Add Contributor Covenant Code of Conduct
Added Contributor Covenant Code of Conduct to promote a harassment-free community.
2026-05-10 14:16:19 +05:30
thakares d567655645 Update LICENSE 2026-05-09 18:35:56 +05:30
thakares 4fb4022188 fix: correct license link and remove residual LICENSE.md 2026-05-09 18:23:11 +05:30
thakares 256f12023e docs: add WASM_BUILD.md explaining pkg generation and frontend loading
Covers:
- What antibot_wasm.js is and why it is not in the repo
- How wasm-pack compiles wasm/src/ and what wasm/pkg/ contains
- Step-by-step build (rustup target, wasm-pack install, build, mv to frontend/pkg)
- How heartbeat.js loads and initialises the module via await init()
- MIME type requirements for serving .wasm files
- .gitignore rationale for wasm/pkg/ and frontend/pkg/
- Troubleshooting (missing target, wasm-opt, 404, empty string returns)
2026-05-09 18:17:53 +05:30
thakares 2840ddfc58 docs: comprehensive ARCHITECTURE, DEPLOYMENT, API, and THREAT_MODEL
ARCHITECTURE.md
- Full component map with ASCII diagram
- Complete session lifecycle (init + heartbeat + failure path)
- Cryptographic protocol spec (hash chain formula, canonical JSON)
- Stack machine instruction set table with stack effects
- Behavioral validation thresholds
- SQLite schema, threat model summary, module reference

DEPLOYMENT.md
- Build instructions (WASM + server + convenience script)
- native binary, systemd (with hardened sandbox notes), Docker
- nginx, Nginx Proxy Manager, and HAProxy reverse proxy configs
- Integration options (sidecar vs proxy-only)
- Full configuration table with all constants
- Observability (RUST_LOG levels), health check, security checklist

API.md
- Full /init and /hb request/response schemas with field tables
- Canonical signing payload specification
- Complete validation rules table (all 13 rejection conditions)
- Hash chain byte-level specification
- WASM exported function reference

THREAT_MODEL.md
- Four attacker profiles (script kiddie → sophisticated adversary)
- Eight attack vectors with mitigations (replay, forgery, hijack, DoS…)
- Explicit out-of-scope limitations
- Operational security notes (CORS, TLS, log level, SQLite)
2026-05-09 18:11:15 +05:30
thakares 4b27a342d1 docs: comprehensive ARCHITECTURE, DEPLOYMENT, API, and THREAT_MODEL
ARCHITECTURE.md
- Full component map with ASCII diagram
- Complete session lifecycle (init + heartbeat + failure path)
- Cryptographic protocol spec (hash chain formula, canonical JSON)
- Stack machine instruction set table with stack effects
- Behavioral validation thresholds
- SQLite schema, threat model summary, module reference

DEPLOYMENT.md
- Build instructions (WASM + server + convenience script)
- native binary, systemd (with hardened sandbox notes), Docker
- nginx, Nginx Proxy Manager, and HAProxy reverse proxy configs
- Integration options (sidecar vs proxy-only)
- Full configuration table with all constants
- Observability (RUST_LOG levels), health check, security checklist

API.md
- Full /init and /hb request/response schemas with field tables
- Canonical signing payload specification
- Complete validation rules table (all 13 rejection conditions)
- Hash chain byte-level specification
- WASM exported function reference

THREAT_MODEL.md
- Four attacker profiles (script kiddie → sophisticated adversary)
- Eight attack vectors with mitigations (replay, forgery, hijack, DoS…)
- Explicit out-of-scope limitations
- Operational security notes (CORS, TLS, log level, SQLite)
2026-05-09 18:04:13 +05:30
thakares 851d3b4876 Further refine architecture documentation 2026-05-08 15:24:49 +05:30
thakares ac57752ec2 Improve README presentation and project overview 2026-05-08 15:19:33 +05:30
thakares ebfbbf9901 Refine architecture docs and add project logo 2026-05-08 15:14:27 +05:30
thakares b866471825 fix: rename LICENSE.md to LICENSE so GitHub detects MIT correctly
GitHub license auto-detection requires a file named LICENSE containing
the full license text. LICENSE-MIT and LICENSE-APACHE remain for
dual-license reference.
2026-05-08 15:07:11 +05:30
thakares f95b3c0d4a license: switch from GPL-3.0 to MIT OR Apache-2.0 dual license
Companies integrating security tooling into proprietary stacks are
blocked by GPL copyleft. MIT/Apache-2.0 dual licensing matches the
convention used by the Rust ecosystem (tokio, axum, serde, etc.) and
removes all adoption friction for commercial users.

- Add LICENSE-MIT
- Add LICENSE-APACHE
- Update LICENSE.md to dual-license declaration
- Add [workspace.package] license field to Cargo.toml
2026-05-08 15:02:02 +05:30
64 changed files with 8942 additions and 1141 deletions

No files matched your search

+2
View File
@@ -5,3 +5,5 @@ dist/
*.log *.log
.env .env
.idea/ .idea/
.antigravitycli/
+128
View File
@@ -0,0 +1,128 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, religion, or sexual identity
and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
* Focusing on what is best not just for us as individuals, but for the
overall community
Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or
advances of any kind
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email
address, without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official e-mail address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
E-mail .
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series
of actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or
permanent ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within
the community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.0, available at
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
Community Impact Guidelines were inspired by [Mozilla's code of conduct
enforcement ladder](https://github.com/mozilla/diversity).
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see the FAQ at
https://www.contributor-covenant.org/faq. Translations are available at
https://www.contributor-covenant.org/translations.
Generated
+744 -13
View File
File diff suppressed because it is too large. Load diff
+3
View File
@@ -6,3 +6,6 @@ members = [
"server", "server",
"wasm" "wasm"
] ]
[workspace.package]
license = "MIT OR Apache-2.0"
+7 -3
View File
@@ -4,7 +4,7 @@ WORKDIR /app
COPY . . COPY . .
RUN cargo build -p server --release RUN cargo build -p chronoseal-server --bin chronoseal --release
FROM debian:bookworm-slim FROM debian:bookworm-slim
@@ -14,10 +14,14 @@ RUN apt-get update && apt-get install -y \
WORKDIR /opt/chronoseal WORKDIR /opt/chronoseal
COPY --from=builder /app/target/release/server /usr/local/bin/chronoseal COPY --from=builder /app/target/release/chronoseal /usr/local/bin/chronoseal
COPY frontend /usr/share/chronoseal/frontend
EXPOSE 3000 EXPOSE 3000
ENV RUST_LOG=info ENV RUST_LOG=info
ENV CHRONOSEAL_DB_PATH=/var/lib/chronoseal/chronoseal.sqlite
ENV CHRONOSEAL_FRONTEND_DIR=/usr/share/chronoseal/frontend
ENV CHRONOSEAL_PID_FILE=/run/chronoseal.pid
CMD ["chronoseal"] CMD ["chronoseal", "run"]
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Sunil Purushottam Thakare
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+178
View File
@@ -0,0 +1,178 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship made available under
the License, as indicated by a copyright notice that is included in
or attached to the work (an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other transformations
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean, as submitted to the Licensor for inclusion
in the Work by the copyright owner or by an individual or Legal Entity
authorized to submit on behalf of the copyright owner. For the purposes
of this definition, "submit" means any form of electronic, verbal, or
written communication sent to the Licensor or its representatives,
including but not limited to communication on electronic mailing lists,
source code control systems, and issue tracking systems that are managed
by, or on behalf of, the Licensor for the purpose of submitting and
discussing improvements to the Work, but excluding communication that is
conspicuously marked or designated in writing by the copyright owner as
"Not a Contribution."
"Contributor" shall mean Licensor and any Legal Entity on behalf of
whom a Contribution has been received by the Licensor and included
within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a cross-claim
or counterclaim in a lawsuit) alleging that the Work or any
Contribution embodied within the Work constitutes direct or
contributory patent infringement, then any patent licenses granted to
You under this License for that Work shall terminate as of the date
such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or Derivative Works
a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, You must include a readable copy of the attribution
notices contained within such NOTICE file, in at least one of the
following places: within a NOTICE text provided as part of the
Derivative Works; within the Source form or documentation, if
provided along with the Derivative Works; or, within a display
generated by the Derivative Works, if and wherever such third-party
notices normally appear. The contents of the NOTICE file are for
informational purposes only and do not modify the License. You may
add Your own attribution notices within Derivative Works that You
distribute, alongside or as an addendum to the NOTICE text from
the Work, provided that such additional attribution notices cannot
be construed as modifying the License.
You may add Your own license statement for Your modifications and
may provide additional grant of rights to use, copy, modify, merge,
publish, distribute, sublicense, and/or sell copies of the Work.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or agreed
to in writing, Licensor provides the Work (and each Contributor
provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES
OR CONDITIONS OF ANY KIND, either express or implied, including,
without limitation, any warranties or conditions of TITLE,
NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE.
You are solely responsible for determining the appropriateness of using
or redistributing the Work and assume any risks associated with Your
exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or exemplary damages of any character arising as a result
of this License or out of the use or inability to use the Work
(including but not limited to damages for loss of goodwill, work
stoppage, computer failure or malfunction, or all other commercial
damages or losses), even if such Contributor has been advised of the
possibility of such damages.
9. Accepting Warranty or Liability. While redistributing the Work or
Derivative Works thereof, You may choose to offer, and charge a fee
for, acceptance of support, warranty, indemnity, or other liability
obligations and/or rights consistent with this License. However, in
accepting such obligations, You may offer such obligations only on
Your own behalf and on Your sole responsibility, not on behalf of
any other Contributor, and only if You agree to indemnify, defend,
and hold each Contributor harmless for any liability incurred by, or
claims asserted against, such Contributor by reason of your accepting
any such warranty or additional liability.
END OF TERMS AND CONDITIONS
Copyright (c) 2026 Sunil Thakare
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-674
View File
@@ -1,674 +0,0 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.
+681 -169
View File
@@ -1,236 +1,748 @@
# ChronoSeal # ChronoSeal
**Cryptographic anti-automation and browser attestation framework built with Rust, WASM, and behavioral continuity verification.** <p align="center">
<img src="logo/chronoseal.svg" width="220" alt="ChronoSeal Logo">
</p>
ChronoSeal makes it computationally expensive and operationally complex for AI scrapers, headless browsers, and automation tools to impersonate real users — while remaining completely invisible and frictionless to legitimate human visitors. <p align="center">
<strong>Unix-native cryptographic attestation daemon for browser session continuity.</strong>
</p>
<p align="center">
Privacy-first | Deterministic WASM parity | Silent rejection | Low overhead
</p>
<p align="center">
<a href="https://github.com/thakares/chronoseal-rs/blob/main/LICENSE">
<img src="https://img.shields.io/badge/license-MIT%20OR%20Apache--2.0-blue.svg" alt="License: MIT OR Apache-2.0">
</a>
<a href="https://github.com/thakares/chronoseal-rs">
<img src="https://img.shields.io/badge/rust-stable%20%E2%89%A5%201.87-orange.svg" alt="Rust stable >= 1.87">
</a>
<a href="https://github.com/thakares/chronoseal-rs/blob/main/docs/REFRACTORING-v0.6.0.md">
<img src="https://img.shields.io/badge/version-v0.6.0-green.svg" alt="v0.6.0">
</a>
<img src="https://img.shields.io/badge/wasm-rust--compiled-blueviolet.svg" alt="WASM">
</p>
--- ---
ChronoSeal is a Linux-native cryptographic attestation service for browser session continuity and anti-automation defense. It runs as a small daemon, serves a browser WASM runtime, and validates signed heartbeat requests through deterministic server/client state progression.
The core idea is simple: a real browser session should be able to keep advancing a private cryptographic state, a hash chain, and a deterministic synthetic gene mutation chain. Basic HTTP clients, stale replay attempts, and incomplete automation should fail without receiving a useful failure reason.
ChronoSeal is a cost-raising attestation layer. It is not a CAPTCHA replacement, identity provider, fingerprinting product, or perfect bot blocker.
## Contents
- [Features](#features)
- [How It Works](#how-it-works)
- [Repository Layout](#repository-layout)
- [Quick Start](#quick-start)
- [Build From Source](#build-from-source)
- [Run Locally](#run-locally)
- [Install as a Service](#install-as-a-service)
- [Docker](#docker)
- [CLI Reference](#cli-reference)
- [Configuration](#configuration)
- [HTTP API](#http-api)
- [Browser Integration](#browser-integration)
- [Storage Backends](#storage-backends)
- [Operations](#operations)
- [Security Model](#security-model)
- [Development](#development)
- [Troubleshooting](#troubleshooting)
- [Further Reading](#further-reading)
- [License](#license)
## Features
- Native Unix daemon with `systemd` service support.
- Axum-based HTTP service exposing `/init`, `/hb`, `/health`, `/metrics`, and `/stats`.
- Rust/WASM browser runtime for key generation, signing, VM execution, and mutation preview.
- Deterministic Synthetic Gene Mutation Engine shared by the server and WASM crates.
- Ed25519 signatures over canonical heartbeat payloads.
- Blake3 hash chain continuity across accepted heartbeats.
- Silent rejection semantics: invalid heartbeats still return `{"status":"ok"}`.
- Configurable behavioral trust checks for mouse movement, pauses, timing, and browser signals.
- Runtime storage abstraction with `sqlite-in-memory`, `sqlite-in-disk`, and `valkey` modes.
- CLI-first lifecycle, status, health, metrics, stats, config validation, key generation, and shell completions.
- Privacy-oriented design based on ephemeral session state rather than long-term identity tracking.
## How It Works ## How It Works
ChronoSeal establishes a continuous, cryptographically verifiable proof-of-presence for every browser session. It is inspired by the heartbeat model used in IoT firmware (ESP32-class devices): the client must keep emitting signed, chained attestations, or the session is silently invalidated. ChronoSeal creates a short-lived browser attestation session and advances it through signed heartbeats.
``` 1. The browser loads the generated WASM package from `frontend/pkg`.
Browser Server 2. The WASM runtime generates an Ed25519 keypair and returns the public key to JavaScript.
│ │ 3. The browser calls `POST /init` with the public key.
│ WASM loads, generates Ed25519 keypair │ 4. The server creates a session and returns:
│ Private key never leaves WASM memory │ - `session_id`
│ │ - initial salt
├──── POST /init { public_key } ──────────►│ Store session, salt, initial hash - initial hash chain head
│◄─── { session_id, salt, opcodes, H0 } ────┤ - VM opcode program
│ │ - gene size
│ Every 12–25s (jittered): │ - mutation step
│ ┌─ Collect mouse entropy │ - mutation order
│ ├─ Execute VM opcodes → stack state │ - heartbeat timing bounds
│ ├─ Compute H(n) = Blake3(H(n-1) ║ …) │ 5. For each heartbeat, the browser:
│ └─ Sign payload with Ed25519 │ - executes the VM opcode program
│ │ - collects entropy and browser signal data
├──── POST /hb { session_id, sig, … } ────►│ Verify sig → chain → behavior → fingerprint - previews the next synthetic gene commitment in WASM
│◄─── { status, next_salt } ────────────────┤ Rotate salt, advance chain - signs the canonical heartbeat payload
│ │ - posts the request to `POST /hb`
│ On failure: server returns {"status":"ok"}│ Silent rejection — indistinguishable 6. The server validates:
- session existence and expiration
- rate limit
- timestamp drift
- Ed25519 signature
- hash chain continuity
- behavioral trust checks
- mutation step
- gene commitment parity
7. If the heartbeat is accepted, the server advances session state and returns the next salt and mutation order.
8. If the heartbeat is rejected, the server returns only `{"status":"ok"}`.
The silent failure model deliberately avoids exposing which validation failed.
## Repository Layout
```text
.
├── Cargo.toml # Rust workspace
├── server/ # chronoseal daemon and CLI
├── shared/ # shared protocol, gene model, mutation engine
├── wasm/ # WASM runtime crate
├── frontend/ # static browser integration files
├── docs/ # architecture, API, deployment, threat model
├── scripts/ # build, install, release, dev helpers
├── chronoseal.service # systemd unit
├── Dockerfile # container image
└── docker-compose.yml # local container orchestration
``` ```
--- Workspace members:
## Security Model - `chronoseal-server`: daemon binary crate. Binary name: `chronoseal`.
- `shared`: common cryptographic and mutation logic used by server and WASM.
- `chronoseal-wasm`: browser runtime compiled with `wasm-pack`.
### What ChronoSeal protects against ## Quick Start
| Threat | Mechanism | For a full native install:
|---|---|
| Playwright / Puppeteer Stealth | Mouse entropy validation rejects synthetic or absent movement |
| Replay attacks | Hash chain — each heartbeat references the previous hash; old payloads are invalid |
| Signature forgery | Ed25519 private key generated inside WASM, never serialised or exposed to JS |
| Clock manipulation | Server enforces ±30s timestamp window |
| Credential sharing | Session is bound to a keypair generated fresh on every page load |
| Flooding with fake sessions | Per-session rate limiting (5 req / 10s); stale entries evicted every 60s |
| Passive analysis of traffic | Server always returns `{"status":"ok"}` — rejections are silent |
### What ChronoSeal does not claim ```bash
sudo bash scripts/install.sh
ChronoSeal is a cost-raising mechanism, not an impenetrable barrier. A sufficiently motivated adversary with a real browser, real input devices, and the patience to reverse the WASM can bypass it. The goal is to make scraping expensive and operationally complex enough to be impractical at scale.
---
## Architecture
```
chronoseal-rs/
├── shared/ Shared types, Blake3 hash-chain logic, constants
├── server/ Axum HTTP server
│ ├── routes/ /init and /hb handlers
│ ├── session.rs Session lifecycle: create, verify, advance chain
│ ├── crypto.rs Ed25519 signature verification (BTreeMap canonical JSON)
│ ├── trust.rs Behavioral signal validation (mouse speed, pauses)
│ ├── fingerprint Browser fingerprint sanity checks
│ ├── vm.rs Random opcode program generator
│ ├── ratelimit.rs Token-bucket rate limiter with periodic eviction
│ └── cleanup.rs Background task: expire sessions + evict rate limiter
├── wasm/ Rust → WASM client module
│ ├── crypto.rs Ed25519 keypair, signing, hash computation
│ └── vm.rs Stack machine executor (PUSH/ADD/SUB/MUL/XOR/AND/OR/ROT/NOT/HASH)
└── frontend/ Vanilla JS glue
├── heartbeat.js Session init, heartbeat scheduling, chain advancement
└── entropy.js Mouse event collection
``` ```
### Stack Machine Check the daemon:
The server generates a random program (8–16 opcodes) on session init. The client executes it on every heartbeat and includes the resulting stack state in the signed payload. This makes each heartbeat structurally unique without requiring any server round-trip. ```bash
chronoseal status --format json
| Opcode | Mnemonic | Effect | chronoseal health
|--------|----------|--------| chronoseal metrics
| `0x00` | PUSH u32 | Push 4-byte little-endian literal | sudo journalctl -u chronoseal -f
| `0x01` | ADD | Pop 2, push `a + b` (wrapping) |
| `0x02` | SUB | Pop 2, push `a - b` (wrapping) |
| `0x03` | MUL | Pop 2, push `a * b` (wrapping) |
| `0x04` | XOR | Pop 2, push `a ^ b` |
| `0x05` | AND | Pop 2, push `a & b` |
| `0x06` | OR | Pop 2, push `a \| b` |
| `0x07` | ROT | Pop 2, push `a.rotate_left(b % 32)` |
| `0x08` | NOT | Pop 1, push `!a` (unary) |
| `0x09` | HASH | Blake3 of entire stack → single u32 |
### Hash Chain
```
H(0) = Blake3( session_id ║ pub_key ║ salt₀ )
H(n) = Blake3( saltₙ₋₁ ║ H(n-1) ║ timestamp ║ Blake3(entropy_json) ║ Blake3(stack_json) )
``` ```
Each heartbeat must present `H(n-1)` matching what the server stored. Forging a valid `H(n)` requires knowing the private key (for the signature), the salt (server-side only), and all prior state. For local development without installing:
### Signature Canonical Form ```bash
bash scripts/build.sh
The client signs a JSON object with keys sorted alphabetically (matching `JSON.stringify(obj, Object.keys(obj).sort())`): cargo run -p chronoseal-server --bin chronoseal -- run \
--bind 127.0.0.1:3000 \
```json --frontend-dir frontend
{
"entropyData": { "events": [ { "x": …, "y": …, "t": … } ] },
"fingerprint": { "aspectRatio": "…", "devicePixelRatio": "…", "hardwareConcurrency": … },
"prevHash": "hex…",
"sessionId": "hex…",
"stackState": { "stack": […], "ip": … },
"timestamp": 1234567890123
}
``` ```
The server reconstructs this using `BTreeMap` (alphabetical key order) before calling `VerifyingKey::verify_strict`. Then open:
--- ```text
http://127.0.0.1:3000
## SQLite Schema
```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
);
``` ```
Sessions are stored in an in-memory SQLite database. All session state is lost on server restart by design — clients re-initialise transparently. ## Build From Source
--- ### Requirements
## Build | Tool | Minimum | Purpose |
|---|---:|---|
| Rust | 1.87 stable | Build server, shared crate, and tests |
| wasm-pack | 0.13 | Build browser WASM package |
| wasm32 target | current stable | WASM compilation target |
| systemd | 248+ | Optional native service management |
| Docker | 24.x | Optional container workflow |
| Docker Compose | 2.x | Optional local orchestration |
### Prerequisites Install the WASM target and `wasm-pack`:
- Rust stable (≥ 1.87) ```bash
- [`wasm-pack`](https://rustwasm.github.io/wasm-pack/installer/) rustup target add wasm32-unknown-unknown
cargo install wasm-pack
```
### WASM Build everything needed for the browser and server:
```bash
bash scripts/build.sh
```
That script:
1. Builds `wasm/` with `wasm-pack build --target web --release`.
2. Replaces `frontend/pkg` with the generated WASM package.
3. Builds the `chronoseal` release binary.
Manual build:
```bash ```bash
wasm-pack build wasm --target web --release wasm-pack build wasm --target web --release
rm -rf frontend/pkg
mv wasm/pkg frontend/pkg mv wasm/pkg frontend/pkg
cargo build -p chronoseal-server --bin chronoseal --release
``` ```
### Server The binary is written to:
```text
target/release/chronoseal
```
## Run Locally
Run the daemon directly from Cargo:
```bash ```bash
cargo build -p server --release cargo run -p chronoseal-server --bin chronoseal -- run \
--bind 127.0.0.1:3000 \
--frontend-dir frontend
``` ```
### Dev (all-in-one) Probe it:
```bash ```bash
bash scripts/dev.sh curl http://127.0.0.1:3000/health
curl http://127.0.0.1:3000/stats
curl http://127.0.0.1:3000/metrics
``` ```
The server serves the `frontend/` directory statically at `/` and the API at `/init` and `/hb`. Use the CLI wrappers:
---
## Deployment
### Native + systemd
```bash ```bash
cargo build -p server --release cargo run -p chronoseal-server --bin chronoseal -- status --bind 127.0.0.1:3000
sudo cp target/release/server /usr/local/bin/chronoseal cargo run -p chronoseal-server --bin chronoseal -- health --bind 127.0.0.1:3000
sudo cp chronoseal.service /etc/systemd/system/ cargo run -p chronoseal-server --bin chronoseal -- stats --bind 127.0.0.1:3000 --format json
sudo systemctl daemon-reload
sudo systemctl enable --now chronoseal
``` ```
### Docker ## Install as a Service
The installer builds the project, installs the binary, copies frontend assets, installs the `systemd` unit, and starts the service.
```bash
sudo bash scripts/install.sh
```
Installed paths:
| Path | Purpose |
|---|---|
| `/usr/local/bin/chronoseal` | daemon and CLI binary |
| `/opt/chronoseal/frontend` | static frontend assets copied by installer |
| `/etc/systemd/system/chronoseal.service` | service unit |
| `/run/chronoseal.pid` | default PID file |
Service commands:
```bash
sudo systemctl status chronoseal
sudo systemctl restart chronoseal
sudo systemctl disable --now chronoseal
sudo journalctl -u chronoseal -f
```
## Docker
Build and run with Compose:
```bash ```bash
docker compose up -d --build docker compose up -d --build
``` ```
### Reverse Proxy The provided container exposes port `3000`.
Place ChronoSeal behind nginx, Nginx Proxy Manager, or HAProxy. Enable: ```bash
curl http://127.0.0.1:3000/health
- TLS 1.3
- HTTP/2
- Aggressive upstream timeouts (the heartbeat interval is 12–25s)
---
## Integration
Drop two lines into any protected page:
```html
<script type="module" src="/pkg/antibot_wasm.js"></script>
<script type="module" src="/main.js"></script>
``` ```
`main.js` calls `initHeartbeat()` which handles WASM loading, session init, and schedules all subsequent heartbeats automatically. There is no visible UI, no CAPTCHA, no user interaction required. Important: the Dockerfile copies `frontend/` from the working tree. Build the WASM package into `frontend/pkg` before building the container if you need the browser runtime inside the image:
--- ```bash
bash scripts/build.sh
docker compose up -d --build
```
## CLI Reference
Run:
```bash
chronoseal --help
```
Global options:
| Option | Environment | Description |
|---|---|---|
| `--config <path>` | `CHRONOSEAL_CONFIG` | Explicit TOML config path |
| `--format <text|json|yaml>` | - | Output format for machine-readable commands |
| `--output <text|json|yaml>` | - | Alias for `--format` |
| `--log <filter>` | `CHRONOSEAL_LOG` | Tracing filter, for example `info` or `chronoseal=debug` |
Commands:
| Command | Description |
|---|---|
| `chronoseal run` | Run the daemon |
| `chronoseal status` | Check configured daemon reachability and PID state |
| `chronoseal health` | Perform an HTTP health probe |
| `chronoseal config check` | Validate and print effective configuration |
| `chronoseal generate keypair` | Generate an Ed25519 keypair |
| `chronoseal version` | Print version/build information |
| `chronoseal db-type` | List database backend support status |
| `chronoseal metrics` | Fetch Prometheus metrics from the running daemon |
| `chronoseal stats` | Fetch service statistics from the running daemon |
| `chronoseal completion <shell>` | Generate shell completions |
Examples:
```bash
chronoseal run --bind 127.0.0.1:3000 --frontend-dir frontend
chronoseal run --db-type sqlite-in-memory
chronoseal status --format json
chronoseal health --config /etc/chronoseal/config.toml
chronoseal config check --output yaml
chronoseal generate keypair --format json
chronoseal completion bash > chronoseal.bash
```
## Configuration ## Configuration
All tunable constants are in `shared/src/constants.rs`: Configuration precedence:
| Constant | Default | Description | 1. CLI flags
2. `CHRONOSEAL_*` environment variables
3. TOML config file
4. built-in defaults
Default config discovery:
1. path from `CHRONOSEAL_CONFIG`, if it exists
2. `/etc/chronoseal/config.toml`
3. `$XDG_CONFIG_HOME/chronoseal/config.toml`
4. `~/.config/chronoseal/config.toml`
Example:
```toml
bind = "0.0.0.0:3000"
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"
heartbeat_min_interval_ms = 12000
heartbeat_max_interval_ms = 25000
expiration_minutes = 30
rate_limit_count = 5
rate_limit_window_secs = 10
max_timestamp_drift_ms = 30000
min_mouse_total_dist = 10.0
max_mouse_avg_speed = 2.0
min_pause_count = 1
require_mouse_activity = true
gene_size = 512
mutation_rounds = 4
```
Common environment variables:
| Variable | Description |
|---|---|
| `CHRONOSEAL_CONFIG` | Config file path |
| `CHRONOSEAL_BIND` | Bind address, for example `127.0.0.1:3000` |
| `CHRONOSEAL_DB_TYPE` | `sqlite-in-memory`, `sqlite-in-disk`, or `valkey` |
| `CHRONOSEAL_DB_PATH` | SQLite database path |
| `CHRONOSEAL_FRONTEND_DIR` | Static frontend directory served at `/` |
| `CHRONOSEAL_PID_FILE` | PID file path |
| `CHRONOSEAL_LOG` | Tracing filter |
| `CHRONOSEAL_LOG_FILE` | Optional JSON log file |
| `CHRONOSEAL_STATE_DIR` | Base state directory used for default `db_path` |
| `CHRONOSEAL_HEARTBEAT_MIN_INTERVAL_MS` | Minimum accepted heartbeat interval |
| `CHRONOSEAL_HEARTBEAT_MAX_INTERVAL_MS` | Maximum accepted heartbeat interval |
| `CHRONOSEAL_EXPIRATION_MINUTES` | Session lifetime |
| `CHRONOSEAL_RATE_LIMIT_COUNT` | Requests allowed in the rate-limit window |
| `CHRONOSEAL_RATE_LIMIT_WINDOW_SECS` | Rate-limit window length |
| `CHRONOSEAL_MAX_TIMESTAMP_DRIFT_MS` | Accepted client timestamp drift |
| `CHRONOSEAL_MIN_MOUSE_TOTAL_DIST` | Minimum mouse movement distance |
| `CHRONOSEAL_MAX_MOUSE_AVG_SPEED` | Maximum average mouse speed |
| `CHRONOSEAL_MIN_PAUSE_COUNT` | Minimum detected pause count |
| `CHRONOSEAL_REQUIRE_MOUSE_ACTIVITY` | Enable or disable mouse activity requirement |
| `CHRONOSEAL_GENE_SIZE` | Synthetic gene buffer size |
| `CHRONOSEAL_MUTATION_ROUNDS` | Mutation rounds per program |
Validate configuration:
```bash
chronoseal config check --format yaml
```
## HTTP API
ChronoSeal exposes a small HTTP surface:
| Method | Path | Purpose |
|---|---|---| |---|---|---|
| `SESSION_ID_LEN` | 32 bytes | Session ID entropy | | `POST` | `/init` | Start a browser attestation session |
| `SALT_LEN` | 16 bytes | Per-heartbeat salt size | | `POST` | `/hb` | Submit a signed heartbeat |
| `HEARTBEAT_MIN_INTERVAL_MS` | 12 000 ms | Minimum heartbeat interval | | `GET` | `/health` | Health probe |
| `HEARTBEAT_MAX_INTERVAL_MS` | 25 000 ms | Maximum heartbeat interval (uniform jitter) | | `GET` | `/metrics` | Prometheus metrics |
| `EXPIRATION_MINUTES` | 30 min | Session lifetime after last heartbeat | | `GET` | `/stats` | Runtime statistics |
| `RATE_LIMIT_COUNT` | 5 | Max heartbeats per window | | `GET` | `/` | Static frontend files from `frontend_dir` |
| `RATE_LIMIT_WINDOW_SECS` | 10 s | Rate limit window |
| `MAX_TIMESTAMP_DRIFT_MS` | 30 000 ms | Anti-replay timestamp window |
| `MIN_MOUSE_TOTAL_DIST` | 10.0 px | Minimum cumulative mouse travel |
| `MAX_MOUSE_AVG_SPEED` | 2.0 px/ms | Maximum average mouse speed |
| `MIN_PAUSE_COUNT` | 1 | Minimum mouse pause events |
--- ### `POST /init`
Request:
```json
{
"public_key": "hex-encoded 32-byte Ed25519 verifying key"
}
```
Response:
```json
{
"session_id": "64-char hex string",
"salt": "32-char hex string",
"opcodes_b64": "base64-encoded VM program",
"initial_hash": "64-char hex string",
"expires_at": 1234567890123,
"heartbeat_min_interval_ms": 12000,
"heartbeat_max_interval_ms": 25000,
"gene_size": 512,
"mutation_step": 1,
"mutation_order_b64": "base64-encoded mutation program"
}
```
### `POST /hb`
Request:
```json
{
"session_id": "64-char hex",
"prev_hash": "64-char hex",
"timestamp": 1234567890123,
"entropy_data": {
"events": [
{ "x": 412.0, "y": 308.5, "t": 1234.567 }
]
},
"stack_state": {
"stack": [2971406957, 1234567890],
"ip": 42
},
"fingerprint": {
"aspectRatio": "1.7777777778",
"devicePixelRatio": 2,
"hardwareConcurrency": 8
},
"mutation_step": 1,
"gene_commitment": "64-char hex",
"signature": "128-char hex"
}
```
Accepted response:
```json
{
"status": "ok",
"next_salt": "32-char hex string",
"next_mutation_step": 2,
"next_mutation_order_b64": "base64-encoded mutation program"
}
```
Rejected response:
```json
{
"status": "ok"
}
```
Detailed API semantics are documented in [docs/API.md](docs/API.md).
## Browser Integration
The frontend imports the generated WASM module from `frontend/pkg`.
```js
import init, {
generate_keypair,
get_public_key,
sign_message,
compute_next_hash,
run_program,
init_gene_state,
preview_gene_commitment,
commit_gene_preview,
discard_gene_preview,
current_gene_commitment
} from './pkg/chronoseal_wasm.js';
```
Call `await init()` before invoking exported functions.
WASM exports:
| Function | Purpose |
|---|---|
| `generate_keypair()` | Generate a browser-local Ed25519 keypair and return public key hex |
| `get_public_key()` | Return current public key hex |
| `sign_message(msg)` | Sign a canonical UTF-8 payload and return signature hex |
| `compute_next_hash(prev, ts, entropy, stack, salt)` | Compute next Blake3 hash-chain value |
| `run_program(b64)` | Execute a base64 VM program and return stack state |
| `init_gene_state(gene_size)` | Initialize the synthetic gene buffer |
| `preview_gene_commitment(order_b64, session_id, mutation_step, rounds)` | Preview next gene commitment |
| `commit_gene_preview()` | Commit the previewed gene mutation after accepted heartbeat |
| `discard_gene_preview()` | Discard previewed mutation after rejection or error |
| `current_gene_commitment(session_id, mutation_step)` | Return current committed gene commitment |
String-returning WASM functions return an empty string on error. Boolean-returning functions indicate success or failure directly.
## Storage Backends
Set storage mode with `db_type` or `CHRONOSEAL_DB_TYPE`.
| Backend | Description |
|---|---|
| `sqlite-in-memory` | Default ephemeral session storage. State is lost on restart. |
| `sqlite-in-disk` | SQLite database persisted at `db_path`. |
| `valkey` | Valkey-compatible backend mode. |
For Valkey mode, the server reads `CHRONOSEAL_VALKEY_ADDR` and defaults to `127.0.0.1:6666` when it is not set. If the Valkey connection fails, the current implementation falls back to in-memory SQLite and logs a warning.
## Operations
### Health
```bash
chronoseal health
curl http://127.0.0.1:3000/health
```
### Status
```bash
chronoseal status --format json
```
### Metrics
```bash
chronoseal metrics
curl http://127.0.0.1:3000/metrics
```
### Stats
```bash
chronoseal stats --format json
curl http://127.0.0.1:3000/stats
```
### Logs
```bash
sudo journalctl -u chronoseal -f
```
Use `RUST_LOG=info` or `CHRONOSEAL_LOG=info` for normal production operation. Avoid debug logging in production because internal identifiers may appear in logs.
### Reverse Proxy
ChronoSeal should run behind TLS in production. Terminate HTTPS at a reverse proxy such as nginx, Caddy, HAProxy, or a cloud load balancer, then proxy to the local daemon.
Example nginx location:
```nginx
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
```
## Security Model
ChronoSeal is designed to raise the cost of automation and replay attacks.
It helps defend against:
- replayed heartbeats
- stale hash chain state
- forged heartbeat signatures
- mutation commitment tampering
- session cloning using only a stolen `session_id`
- simple scripted clients that do not run the WASM runtime
- basic browser automation with weak interaction simulation
It does not claim to stop:
- real users intentionally acting as bots
- fully resourced browser farms
- attackers with complete control of a real browser and realistic input
- server-side application vulnerabilities
- account abuse outside ChronoSeal's attestation boundary
- long-term identity or fraud decisions by itself
Privacy posture:
- Sessions are short-lived.
- The private key is generated in the browser runtime and is not sent to the server.
- ChronoSeal validates continuity and plausibility rather than creating persistent user identities.
- Invalid heartbeats are rejected silently to reduce oracle feedback.
Read the full model in [docs/THREAT_MODEL.md](docs/THREAT_MODEL.md).
## Development
Run the standard checks:
```bash
cargo fmt --check
cargo check --workspace
cargo test --workspace
cargo clippy --workspace --all-targets -- -D warnings
```
Format:
```bash
cargo fmt
```
Build WASM during frontend/runtime changes:
```bash
wasm-pack build wasm --target web
rm -rf frontend/pkg
mv wasm/pkg frontend/pkg
```
Build release artifacts:
```bash
bash scripts/build.sh
```
Generate shell completion:
```bash
chronoseal completion bash > chronoseal.bash
chronoseal completion zsh > _chronoseal
```
## Troubleshooting
### Browser fails to load WASM
Rebuild the WASM package and make sure `frontend/pkg/chronoseal_wasm.js` and the `.wasm` file exist:
```bash
bash scripts/build.sh
```
The `.wasm` file must be served as `application/wasm`. The built-in static file service handles this for normal ChronoSeal deployments.
### `chronoseal health` fails
Check that the daemon is running and that the CLI is probing the correct bind address:
```bash
sudo systemctl status chronoseal
chronoseal health --bind 127.0.0.1:3000
curl http://127.0.0.1:3000/health
```
### Heartbeats return only `{"status":"ok"}`
That is the expected response for rejected heartbeats. Common causes include:
- invalid signature
- stale `prev_hash`
- stale `mutation_step`
- mismatched `gene_commitment`
- timestamp drift beyond the configured window
- insufficient mouse movement or pause data
- rate limiting
- expired session
Use local development logs and tests to debug integration issues. Avoid debug logs in production.
### SQLite disk mode cannot persist state
Ensure the daemon user can create and write the configured database path:
```bash
sudo mkdir -p /var/lib/chronoseal
sudo chown -R chronoseal:chronoseal /var/lib/chronoseal
```
Use:
```toml
db_type = "sqlite-in-disk"
db_path = "/var/lib/chronoseal/chronoseal.sqlite"
```
### Config changes do not apply
Check precedence. CLI flags override environment variables, environment variables override config files, and config files override built-in defaults.
Print the effective config:
```bash
chronoseal config check --format yaml
```
## Further Reading
- [Architecture](docs/ARCHITECTURE.md)
- [API Reference](docs/API.md)
- [Deployment Guide](docs/DEPLOYMENT.md)
- [Threat Model](docs/THREAT_MODEL.md)
- [Design Philosophy](docs/DESIGN-PHILOSOPHY.md)
- [Privacy Policy](docs/PRIVACY%20POLICY.md)
- [WASM Build Guide](docs/WASM_BUILD.md)
- [Refactoring v0.6.0](docs/REFRACTORING-v0.6.0.md)
- [Contributing](CONTRIBUTING.md)
- [Security Policy](SECURITY.md)
## License ## License
[GPL-3.0](LICENSE.md) ChronoSeal is licensed under either of:
- MIT, see [LICENSE](LICENSE)
- Apache-2.0, see [LICENSE-APACHE](LICENSE-APACHE)
at your option.
+20 -19
View File
@@ -1,35 +1,36 @@
[Unit] [Unit]
Description=ChronoSeal Anti-Bot Service Description=ChronoSeal Cryptographic Attestation Daemon
After=network.target After=network.target
Wants=network-online.target
[Service] [Service]
Type=simple Type=simple
User=chronoseal User=chronoseal
Group=chronoseal Group=chronoseal
ExecStart=/usr/local/bin/chronoseal run
WorkingDirectory=/opt/chronoseal WorkingDirectory=/opt/chronoseal
ExecStart=/usr/local/bin/chronoseal
Restart=always Restart=always
RestartSec=3 RestartSec=3
Environment=RUST_LOG=info
NoNewPrivileges=true # Hardening (production-grade)
PrivateTmp=true
ProtectSystem=strict ProtectSystem=strict
ProtectHome=true ProtectHome=yes
ProtectKernelTunables=true NoNewPrivileges=yes
ProtectKernelModules=true PrivateTmp=yes
ProtectControlGroups=true ProtectKernelTunables=yes
ProtectKernelModules=yes
MemoryDenyWriteExecute=true ProtectControlGroups=yes
RestrictRealtime=true MemoryDenyWriteExecute=yes
RestrictSUIDSGID=true RestrictRealtime=yes
RestrictSUIDSGID=yes
LockPersonality=true LockPersonality=yes
SystemCallArchitectures=native SystemCallArchitectures=native
ReadWritePaths=/run/chronoseal.pid
# Logging
StandardOutput=journal
StandardError=journal
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target
+366
View File
@@ -0,0 +1,366 @@
# ChronoSeal API Reference
ChronoSeal exposes a small HTTP API for browser attestation, heartbeat verification, health checks, metrics, and runtime statistics.
This document describes the wire format and acceptance semantics. The internal state model is covered in [ARCHITECTURE.md](ARCHITECTURE.md).
## Base URL
All paths are relative to the ChronoSeal server root.
- Development default: `http://127.0.0.1:3000`
- Production: the HTTPS origin or reverse-proxy path used by the protected site
Production deployments should use HTTPS. The daemon itself can run behind a local reverse proxy.
## Content Type
JSON endpoints expect:
```http
Content-Type: application/json
```
Responses are JSON except `/metrics`, which returns Prometheus text format.
## Endpoint Summary
| Method | Path | Purpose |
|---|---|---|
| `POST` | `/init` | Create a browser attestation session |
| `POST` | `/hb` | Submit and verify a signed heartbeat |
| `GET` | `/health` | Return daemon health |
| `GET` | `/stats` | Return storage/session statistics |
| `GET` | `/metrics` | Return Prometheus-compatible metrics |
| `GET` | `/` | Serve static frontend assets from `frontend_dir` |
## Data Types
Common encodings:
| Value | Encoding |
|---|---|
| Ed25519 public key | 32 raw bytes encoded as 64 hex characters |
| Ed25519 signature | 64 raw bytes encoded as 128 hex characters |
| `session_id` | 32 random bytes encoded as 64 hex characters |
| `salt` | 16 random bytes encoded as 32 hex characters |
| `initial_hash`, `prev_hash`, `gene_commitment` | 32-byte digest encoded as 64 hex characters |
| `opcodes_b64`, `mutation_order_b64` | standard base64 |
| timestamps | Unix time in milliseconds unless otherwise stated |
## `POST /init`
Creates a new attestation session.
### Request
```http
POST /init
Content-Type: application/json
```
```json
{
"public_key": "hex-encoded 32-byte Ed25519 verifying key"
}
```
| Field | Type | Required | Description |
|---|---|---:|---|
| `public_key` | string | yes | Browser-generated Ed25519 public key as 64 hex characters |
The private key is generated and retained by the browser WASM runtime. It is not sent to the server.
### Successful Response
```http
HTTP/1.1 200 OK
Content-Type: application/json
```
```json
{
"session_id": "64-char hex string",
"salt": "32-char hex string",
"opcodes_b64": "base64-encoded VM program",
"initial_hash": "64-char hex string",
"expires_at": 1234567890123,
"heartbeat_min_interval_ms": 12000,
"heartbeat_max_interval_ms": 25000,
"gene_size": 512,
"mutation_step": 1,
"mutation_order_b64": "base64-encoded mutation program"
}
```
| Field | Type | Description |
|---|---|---|
| `session_id` | string | Opaque session identifier |
| `salt` | string | Current server salt for the first heartbeat hash computation |
| `opcodes_b64` | string | Randomized VM program executed by the browser runtime |
| `initial_hash` | string | Initial chain head used as `prev_hash` for the first heartbeat |
| `expires_at` | number | Session expiration timestamp in milliseconds |
| `heartbeat_min_interval_ms` | number | Minimum heartbeat delay recommended by the server |
| `heartbeat_max_interval_ms` | number | Maximum heartbeat delay recommended by the server |
| `gene_size` | number | Initial synthetic gene buffer size |
| `mutation_step` | number | Mutation step expected on the first heartbeat |
| `mutation_order_b64` | string | Server-authored mutation order for the first heartbeat |
### Error Behavior
`/init` uses normal route-level error handling for invalid payloads or server failures. Invalid public key length, invalid configured gene size, or storage failure can prevent session creation.
Unlike `/hb`, initialization failures are not part of the silent heartbeat rejection model.
## `POST /hb`
Submits one heartbeat for an existing session.
### Request
```http
POST /hb
Content-Type: application/json
```
```json
{
"session_id": "64-char hex",
"prev_hash": "64-char hex",
"timestamp": 1234567890123,
"entropy_data": {
"events": [
{ "x": 412.0, "y": 308.5, "t": 1234.567 }
]
},
"stack_state": {
"stack": [2971406957, 1234567890],
"ip": 42
},
"fingerprint": {
"aspectRatio": "1.7777777778",
"devicePixelRatio": "2",
"hardwareConcurrency": 8
},
"mutation_step": 1,
"gene_commitment": "64-char hex",
"signature": "128-char hex"
}
```
| Field | Type | Required | Description |
|---|---|---:|---|
| `session_id` | string | yes | Session ID from `/init` |
| `prev_hash` | string | yes | Current browser view of the accepted hash-chain head |
| `timestamp` | number | yes | Browser wall-clock timestamp in milliseconds |
| `entropy_data.events` | array | yes | Mouse samples since the previous heartbeat |
| `entropy_data.events[].x` | number | yes | Mouse x coordinate |
| `entropy_data.events[].y` | number | yes | Mouse y coordinate |
| `entropy_data.events[].t` | number | yes | Event timestamp in milliseconds relative to the browser sampling window |
| `stack_state.stack` | array | yes | VM stack output as unsigned 32-bit values |
| `stack_state.ip` | number | yes | VM instruction pointer as an unsigned 16-bit value |
| `fingerprint.aspectRatio` | string | yes | Screen aspect ratio; server accepts numeric strings in range `0.5..=3.0` |
| `fingerprint.devicePixelRatio` | string | yes | Device pixel ratio; server accepts numeric strings in range `(0, 5]` |
| `fingerprint.hardwareConcurrency` | number | yes | Positive hardware concurrency value |
| `mutation_step` | number | yes | Mutation step currently expected by the server |
| `gene_commitment` | string | yes | Context-bound commitment produced by the WASM mutation preview |
| `signature` | string | yes | Ed25519 signature over the canonical payload |
### Canonical Signing Payload
The signature covers a canonical JSON object with sorted top-level keys:
```json
{
"entropyData": { "events": [{ "t": 1234.567, "x": 412.0, "y": 308.5 }] },
"fingerprint": {
"aspectRatio": "1.7777777778",
"devicePixelRatio": "2",
"hardwareConcurrency": 8
},
"geneCommitment": "64-char hex",
"mutationStep": 1,
"prevHash": "64-char hex",
"sessionId": "64-char hex",
"stackState": { "ip": 42, "stack": [2971406957, 1234567890] },
"timestamp": 1234567890123
}
```
Important details:
- The transport payload uses snake_case for several fields.
- The signed payload uses camelCase names.
- Top-level keys must be serialized deterministically in lexical order.
- The `signature` field is not part of the signed payload.
- Nested serialization must match the server's `serde_json` representation.
The server reconstructs the canonical message from the received request before verifying the Ed25519 signature.
### Accepted Response
```http
HTTP/1.1 200 OK
Content-Type: application/json
```
```json
{
"status": "ok",
"next_salt": "32-char hex string",
"next_mutation_step": 2,
"next_mutation_order_b64": "base64-encoded mutation program"
}
```
| Field | Type | Description |
|---|---|---|
| `status` | string | Always `ok` |
| `next_salt` | string | Server salt for the next heartbeat |
| `next_mutation_step` | number | Mutation step expected on the next heartbeat |
| `next_mutation_order_b64` | string | Server-authored mutation order for the next heartbeat |
Clients should treat the heartbeat as accepted only when all next-state fields are present.
### Rejected Response
```http
HTTP/1.1 200 OK
Content-Type: application/json
```
```json
{
"status": "ok"
}
```
Rejected heartbeats omit:
- `next_salt`
- `next_mutation_step`
- `next_mutation_order_b64`
This response shape is intentional. The server does not reveal which validation stage failed.
### Heartbeat Validation Order
The server currently validates heartbeats in this order:
1. Rate-limit check in the route handler.
2. Load session by `session_id`.
3. Check session expiration.
4. Verify Ed25519 signature.
5. Compare `prev_hash` with stored `last_hash`.
6. Compare `mutation_step` with stored `pending_mutation_step`.
7. Apply stored pending mutation to a cloned gene state.
8. Compare expected and submitted `gene_commitment`.
9. Enforce timestamp drift.
10. Validate mouse entropy.
11. Validate fingerprint fields.
12. Compute next hash-chain head.
13. Generate next mutation order and salt.
14. Persist advanced session state.
Any failure after route-level JSON decoding returns the silent rejection body.
## `GET /health`
Returns a basic health response.
```http
GET /health
```
```json
{
"status": "healthy"
}
```
## `GET /stats`
Returns storage-derived session statistics.
```http
GET /stats
```
```json
{
"sessions": 1,
"expired_sessions": 0,
"max_chain_length": 4
}
```
| Field | Type | Description |
|---|---|---|
| `sessions` | number | Stored session count |
| `expired_sessions` | number | Expired sessions not yet purged |
| `max_chain_length` | number | Highest stored heartbeat chain length |
## `GET /metrics`
Returns Prometheus-compatible text.
```http
GET /metrics
```
```text
# HELP chronoseal_sessions Active ChronoSeal sessions
# TYPE chronoseal_sessions gauge
chronoseal_sessions 1
# HELP chronoseal_expired_sessions Expired sessions not yet removed
# TYPE chronoseal_expired_sessions gauge
chronoseal_expired_sessions 0
# HELP chronoseal_max_chain_length Maximum heartbeat chain length
# TYPE chronoseal_max_chain_length gauge
chronoseal_max_chain_length 4
```
## Client State Rules
After `/init`, the client stores:
- `session_id`
- `initial_hash` as the first `prev_hash`
- current `salt`
- VM opcode program
- committed gene state
- pending mutation step
- pending mutation order
On accepted `/hb`:
1. Commit the local gene preview.
2. Compute the next local hash using the old salt that was active when the heartbeat was sent.
3. Replace current salt with `next_salt`.
4. Replace pending mutation step and order with server-provided values.
On rejected `/hb`:
1. Discard the local gene preview.
2. Do not advance hash-chain state.
3. Do not advance mutation state.
4. Treat the session as suspect or restart attestation.
## WASM Runtime Exports
The generated `chronoseal_wasm` package exposes:
| Function | Signature | Description |
|---|---|---|
| `generate_keypair()` | `() -> string` | Generate an Ed25519 keypair and return public key hex |
| `get_public_key()` | `() -> string` | Return current public key hex, or `""` if no keypair exists |
| `sign_message(msg)` | `(string) -> string` | Sign a UTF-8 payload and return hex signature, or `""` on failure |
| `compute_next_hash(prev, ts, entropy, stack, salt)` | `(string, u64, string, string, string) -> string` | Compute next Blake3 chain hash |
| `run_program(b64)` | `(string) -> JsValue` | Execute a base64 VM program and return stack state |
| `init_gene_state(gene_size)` | `(u32) -> bool` | Initialize the browser gene state |
| `preview_gene_commitment(order_b64, session_id, mutation_step, rounds)` | `(string, string, u64, u8) -> string` | Preview next mutation commitment |
| `commit_gene_preview()` | `() -> bool` | Commit the preview after accepted heartbeat |
| `discard_gene_preview()` | `() -> void` | Discard preview after rejection or error |
| `current_gene_commitment(session_id, mutation_step)` | `(string, u64) -> string` | Return current committed gene commitment |
String-returning functions use `""` to signal failure. Callers must handle empty strings explicitly.
+522 -25
View File
@@ -1,36 +1,533 @@
# ChronoSeal Architecture # ChronoSeal Architecture
## Core Principles ChronoSeal is a Unix-native browser attestation daemon. It validates browser session continuity by combining signed heartbeats, Blake3 hash-chain progression, deterministic VM execution, behavioral sanity checks, and a shared Synthetic Gene Mutation Engine that runs on both the server and the browser WASM runtime.
- Continuous browser attestation This document describes the system architecture, state model, validation pipeline, trust boundaries, and operational assumptions. The API wire format is documented separately in [API.md](API.md), and deployment guidance is documented in [DEPLOYMENT.md](DEPLOYMENT.md).
- Cryptographic heartbeat chains
- WASM-isolated secrets
- Behavioral entropy verification
- Silent mitigation
## Components ## Architectural Goals
### WASM Runtime ChronoSeal is designed as infrastructure software rather than a consumer-facing widget. The main goals are:
Responsible for: - Keep the server small, inspectable, and operable as a normal Unix daemon.
- heartbeat generation - Use deterministic client/server computation so the server can verify browser-side progression without trusting browser claims blindly.
- signature generation - Make replay, stale state reuse, and incomplete automation expensive.
- entropy collection - Preserve privacy by using short-lived session state instead of persistent identity tracking.
- VM execution - Avoid attacker feedback oracles by returning indistinguishable success-shaped responses for rejected heartbeats.
- Keep browser integration lightweight: static JavaScript plus a Rust-generated WASM package.
### Server ChronoSeal does not attempt to prove that a human is present. It attempts to prove that a client is maintaining the expected live browser-side cryptographic and mutation state.
Responsible for: ## System Context
- session verification
- trust scoring
- chain validation
- mitigation
## Threat Model ```text
Protected browser origin
|
| static files and API calls
v
+------------------------------+
| Browser |
| - frontend JavaScript |
| - chronoseal_wasm runtime |
| - Ed25519 session key |
| - VM and gene state |
+---------------+--------------+
|
| POST /init
| POST /hb
v
+------------------------------+
| ChronoSeal daemon |
| - Axum HTTP routes |
| - session verifier |
| - storage abstraction |
| - metrics and health |
+---------------+--------------+
|
| SessionRecord
v
+------------------------------+
| Storage backend |
| - sqlite-in-memory |
| - sqlite-in-disk |
| - valkey |
+------------------------------+
```
Designed to increase: ChronoSeal can serve the frontend files itself or sit behind a reverse proxy. TLS termination should happen before traffic reaches the daemon in production.
- scraping cost
- operational complexity
- synchronization burden
ChronoSeal does not attempt impossible perfect prevention. ## Workspace Components
The repository is a Rust workspace with three runtime crates and one static frontend directory.
### `shared/`
`shared/` contains protocol and deterministic runtime code used by both the server and WASM crates.
Responsibilities:
- wire protocol structs for `/init` and `/hb`
- Blake3 hash-chain helpers
- synthetic gene state representation
- environment encoding and validation
- mutation program generation, encoding, decoding, and execution
- deterministic VM extension opcode semantics
Important files:
| File | Responsibility |
|---|---|
| `protocol.rs` | `InitRequest`, `InitResponse`, `HeartbeatRequest`, `HeartbeatResponse`, and supporting payload types |
| `hashing.rs` | initial and next hash-chain computation |
| `gene.rs` | gene state, environment records, validation, and context-bound commitment |
| `vm_extensions.rs` | mutation order generation, opcode interpreter, execution tracing, and tests |
| `constants.rs` | protocol and execution bounds |
`shared/` is the determinism boundary. Any logic that must agree between server and browser belongs here rather than in server-only or frontend-only code.
### `server/`
`server/` builds the `chronoseal` binary. It owns daemon lifecycle, HTTP routing, session verification, storage, metrics, configuration, and CLI behavior.
Important files:
| File | Responsibility |
|---|---|
| `main.rs` | CLI command dispatch |
| `cli.rs` | command, flag, and environment variable definitions |
| `config.rs` | defaults, TOML loading, environment overrides, validation |
| `runtime.rs` | daemon startup, Axum router, health, metrics, stats, graceful shutdown |
| `routes/init.rs` | `POST /init` handler |
| `routes/heartbeat.rs` | `POST /hb` handler and silent rejection response shape |
| `session.rs` | session creation, heartbeat verification, state advancement |
| `crypto.rs` | canonical signing payload and Ed25519 signature verification |
| `storage.rs` | `DbPool`, SQLite, Valkey compatibility, session persistence, stats |
| `trust.rs` | mouse entropy validation |
| `fingerprint.rs` | browser signal validation |
| `ratelimit.rs` | per-session rate limiting |
| `cleanup.rs` | expired session removal |
The server treats the browser as untrusted. Browser-supplied values are accepted only after signature, continuity, timing, behavioral, and mutation checks pass.
### `wasm/`
`wasm/` compiles to the browser runtime package with `wasm-pack --target web`.
Responsibilities:
- generate and hold the browser-local Ed25519 keypair
- sign canonical heartbeat payloads
- compute hash-chain values used by the browser integration
- execute randomized VM programs
- maintain committed and preview synthetic gene state
- preview mutation commitments before a heartbeat is submitted
- commit or discard preview state after server response
Important files:
| File | Responsibility |
|---|---|
| `crypto.rs` | key generation, public key export, message signing |
| `vm.rs` | base VM program execution |
| `vm_extensions.rs` | gene initialization, mutation preview, commit, discard, current commitment |
The WASM runtime is not a trusted execution environment. It is useful because it forces a browser client to implement the same state transitions as the server and makes simple HTTP automation insufficient.
### `frontend/`
`frontend/` contains static JavaScript and browser assets. It loads `frontend/pkg/chronoseal_wasm.js`, calls `/init`, periodically sends `/hb`, and coordinates browser-side state transitions.
The frontend is intentionally thin. Durable protocol rules live in Rust, not in handwritten JavaScript.
## Runtime Topology
The daemon builds a single Axum application with:
| Route | Method | Purpose |
|---|---|---|
| `/init` | `POST` | create a new attestation session |
| `/hb` | `POST` | verify and advance a heartbeat |
| `/health` | `GET` | health probe |
| `/metrics` | `GET` | Prometheus-compatible metrics |
| `/stats` | `GET` | storage/session statistics |
| `/` | `GET` | static frontend assets from `frontend_dir` |
Shared runtime state is held in `AppState`:
- `db_pool`: storage backend handle
- `rate_limiter`: process-local rate limiter
- `config`: runtime configuration snapshot behind an `RwLock`
Configuration is resolved in this order:
1. CLI flags
2. `CHRONOSEAL_*` environment variables
3. TOML configuration file
4. built-in defaults
## Session State Model
The server persists one `SessionRecord` per active session.
| Field | Meaning |
|---|---|
| `session_id` | random 32-byte session identifier encoded as hex |
| `public_key` | browser-generated Ed25519 verifying key |
| `salt` | current server salt for hash-chain progression |
| `last_hash` | current accepted hash-chain head |
| `chain_length` | number of accepted chain states including initialization |
| `created_at` | creation timestamp in milliseconds |
| `last_seen` | timestamp of last accepted heartbeat |
| `expires_at` | session expiration timestamp in milliseconds |
| `gene` | committed synthetic gene byte buffer |
| `environment` | encoded environment records |
| `pending_mutation` | server-issued mutation program for the next heartbeat |
| `pending_mutation_step` | mutation step expected on the next heartbeat |
The committed server state advances only after a heartbeat passes all validation checks. Failed heartbeats do not update `last_hash`, `salt`, `gene`, `environment`, `pending_mutation`, or `pending_mutation_step`.
## Initialization Flow
```text
Browser/WASM Server
------------ ------
generate_keypair()
public key
|
| POST /init { public_key }
v
validate public key length
create GeneState
generate session_id
generate salt
compute initial_hash
generate VM opcodes
generate mutation step 1
persist SessionRecord
^
| InitResponse
|
store session_id, salt,
initial_hash, opcodes,
gene_size, mutation order
```
Initialization creates the first server-side commitment state but does not prove liveness. Liveness begins with accepted heartbeats.
The initial response contains:
- `session_id`
- `salt`
- `opcodes_b64`
- `initial_hash`
- `expires_at`
- heartbeat interval bounds
- `gene_size`
- `mutation_step`
- `mutation_order_b64`
## Heartbeat Flow
```text
Browser/WASM Server
------------ ------
execute VM program
collect entropy and fingerprint data
preview pending gene mutation
build canonical signing payload
sign with Ed25519 private key
|
| POST /hb HeartbeatRequest
v
load session
check expiration
verify signature
check hash continuity
check mutation step
apply pending mutation
compare gene commitment
check timestamp drift
validate mouse entropy
validate fingerprint
compute next hash
generate next mutation
generate next salt
persist advanced state
^
| accepted: status + next salt + next mutation
| rejected: { "status": "ok" }
|
commit preview on accepted response
discard or stop on rejected response
```
Accepted heartbeats return `next_salt`, `next_mutation_step`, and `next_mutation_order_b64`.
Rejected heartbeats return only:
```json
{
"status": "ok"
}
```
This silent rejection behavior is part of the security model. It prevents the API from acting as an oracle for signature, timing, mutation, or behavior failures.
## Verification Pipeline
Heartbeat verification occurs in `server/src/session.rs`.
The current validation order is:
1. Load the session by `session_id`.
2. Reject if the session is missing.
3. Reject if `now > expires_at`.
4. Verify the Ed25519 signature over the canonical payload.
5. Decode and compare `prev_hash` with the stored `last_hash`.
6. Compare request `mutation_step` with stored `pending_mutation_step`.
7. Decode the stored gene environment.
8. Apply the stored `pending_mutation` to a cloned server gene state.
9. Compute the expected `gene_commitment` with session and step context.
10. Compare the request `gene_commitment` with the expected commitment.
11. Enforce timestamp drift bounds.
12. Validate mouse entropy.
13. Validate browser fingerprint fields.
14. Compute the next hash-chain value.
15. Generate the next mutation order.
16. Generate the next salt.
17. Persist the advanced session state.
The verifier performs state mutation only after validation succeeds. This preserves replay resistance and avoids desynchronizing the server after invalid requests.
## Canonical Signing Boundary
The heartbeat signature covers a canonical JSON payload built from:
- `entropyData`
- `fingerprint`
- `geneCommitment`
- `mutationStep`
- `prevHash`
- `sessionId`
- `stackState`
- `timestamp`
The server constructs this payload using a `BTreeMap`, which orders top-level keys deterministically before serializing. The transport request uses snake_case field names, while the signed payload uses camelCase names that match the browser-side canonical message.
The signature does not cover the `signature` field itself.
## Hash-Chain Boundary
Each accepted heartbeat advances a Blake3 hash chain.
Inputs include:
- previous hash-chain head
- heartbeat timestamp
- entropy data
- VM stack state
- current server salt
The server stores only the current accepted head as `last_hash`. A replayed heartbeat with an old `prev_hash` fails because the stored `last_hash` has already advanced.
The salt rotates after every accepted heartbeat. The next salt is returned only on acceptance, so rejected clients do not receive the material needed for the next valid chain step.
## Synthetic Gene Mutation Engine
The Synthetic Gene Mutation Engine provides an additional deterministic continuity check.
Core concepts:
- `GeneState`: committed gene byte buffer plus environment records.
- `MutationOrder`: mutation step plus encoded mutation program.
- `pending_mutation`: the server-authored program expected on the next heartbeat.
- `gene_commitment`: context-bound commitment over the candidate gene state, `session_id`, and `mutation_step`.
The server and WASM runtime both execute the same mutation semantics from `shared/vm_extensions.rs`.
Mutation lifecycle:
1. Server stores a pending mutation program and step.
2. Browser previews that mutation against its committed gene state.
3. Browser sends the resulting `gene_commitment`.
4. Server applies the same mutation to a clone of its committed gene state.
5. Server compares the expected commitment with the browser commitment.
6. On success, server commits the candidate state and issues the next mutation.
7. Browser commits its preview only after receiving an accepted response.
This design prevents a client from advancing mutation state independently of the server. The mutation order is server-authored, step-bound, and accepted only once.
## Behavioral Trust Checks
ChronoSeal includes lightweight behavioral checks. These checks are not a complete human verification system; they are an automation cost signal.
Current checks include:
- minimum mouse activity, when enabled
- minimum total mouse movement distance
- maximum average mouse speed
- minimum pause count
- timestamp drift bound
- basic fingerprint field validation
The checks are intentionally bounded and configurable. They should be treated as one layer in the attestation pipeline, not as the primary security primitive.
## Storage Architecture
Storage is abstracted by `DbPool`.
| Backend | `db_type` | Characteristics |
|---|---|---|
| SQLite memory | `sqlite-in-memory` | default, process-local, ephemeral |
| SQLite disk | `sqlite-in-disk` | persisted SQLite file at `db_path` |
| Valkey | `valkey` | Valkey-compatible session store |
The storage layer must support:
- insert session
- load session
- update session
- delete expired sessions
- report statistics
`valkey` mode reads `CHRONOSEAL_VALKEY_ADDR`, defaulting to `127.0.0.1:6666`. If connection setup fails, the current implementation logs a warning and falls back to in-memory SQLite.
## Metrics and Observability
ChronoSeal exposes two operational surfaces:
- CLI commands: `status`, `health`, `metrics`, `stats`, `config check`
- HTTP endpoints: `/health`, `/metrics`, `/stats`
The metrics endpoint reports storage-derived counters including:
- active sessions
- expired sessions
- maximum observed chain length
The daemon uses structured tracing and can log to journald through normal systemd operation. Operators should avoid debug logging in production because internal identifiers may appear in logs.
## Trust Boundaries
### Browser Boundary
The browser is untrusted. It may lie about entropy, fingerprint values, VM output, mutation commitment, timing, and session identifiers.
Mitigation:
- signature verification binds payloads to the browser session key
- hash-chain checks reject stale state
- mutation commitment checks reject incorrect gene progression
- timing and behavioral checks reject implausible requests
### WASM Boundary
WASM code runs in the browser and is therefore not trusted as secure enclave code.
Mitigation:
- the server independently recomputes critical deterministic state
- private key custody raises automation cost but is not treated as hardware-backed secrecy
- failures do not reveal detailed reasons to callers
### Storage Boundary
Storage is trusted for session continuity. If storage is lost, sessions cannot continue. If storage is tampered with, attestation integrity can be affected.
Mitigation:
- use proper filesystem permissions for SQLite disk mode
- deploy Valkey on a trusted network or protected socket
- keep ChronoSeal behind normal host and service hardening
### Network Boundary
ChronoSeal expects production traffic to be protected by TLS. Plaintext deployment weakens confidentiality and makes traffic analysis easier.
Mitigation:
- terminate TLS at a reverse proxy or load balancer
- keep `/init` and `/hb` same-origin with protected content when possible
- avoid exposing internal metrics broadly
## Failure Semantics
ChronoSeal intentionally separates transport success from attestation success.
| Failure class | HTTP behavior | State mutation |
|---|---|---|
| malformed route-level request | normal HTTP error handling | no session advancement |
| invalid heartbeat semantics | `200 OK` with `{"status":"ok"}` | no session advancement |
| rejected heartbeat | `200 OK` with `{"status":"ok"}` | no session advancement |
| accepted heartbeat | `200 OK` with next-state fields | session state advances from the verifier's perspective |
This ambiguity reduces attacker feedback. Application integrations must check for the presence of `next_salt`, `next_mutation_step`, and `next_mutation_order_b64` rather than treating any `status: ok` as an accepted heartbeat.
## Invariants
The architecture relies on these invariants:
- A session has exactly one expected `pending_mutation_step` at a time.
- A pending mutation is consumed only by an accepted heartbeat.
- `last_hash` changes only after a heartbeat passes verification.
- `salt` changes only after a heartbeat passes verification.
- `gene` and `environment` change only after mutation commitment validation succeeds.
- The next mutation order is generated only from an accepted candidate state.
- Rejected heartbeats do not reveal the failed validation stage.
- Browser-side preview state is committed only after an accepted heartbeat response.
Breaking these invariants can introduce replay acceptance, client/server desynchronization, or oracle behavior.
## Concurrency Notes
ChronoSeal currently verifies a heartbeat by loading a session, computing candidate state, and writing the updated record back to storage. The intended operational model is one live heartbeat stream per browser session.
Concurrent heartbeats for the same `session_id` should naturally collapse to at most one accepted progression because both requests present the same `prev_hash` and `mutation_step`; after the first accepted update, the second request becomes stale. Storage backends must preserve update visibility strongly enough for this assumption to hold.
## Deployment Shape
Typical production topology:
```text
Internet
|
v
TLS reverse proxy
|
v
chronoseal daemon on 127.0.0.1:3000
|
v
SQLite disk or Valkey storage
```
Recommended deployment properties:
- run under systemd with a dedicated service user
- bind to localhost behind a reverse proxy unless direct exposure is required
- serve over HTTPS
- keep debug logs disabled
- monitor `/health`, `/metrics`, and `/stats`
- use `sqlite-in-memory` for ephemeral local sessions
- use `sqlite-in-disk` or `valkey` when sessions must survive process restarts
## Limitations
ChronoSeal is not:
- a user authentication system
- a CAPTCHA
- a fraud scoring engine
- a hardware attestation system
- a persistent identity framework
- a complete defense against fully resourced browser farms
It is a protocol layer that makes browser automation and replay more expensive by requiring correct, continuous, stateful execution.
## Related Documents
- [API Reference](API.md)
- [Deployment Guide](DEPLOYMENT.md)
- [Threat Model](THREAT_MODEL.md)
- [WASM Build Guide](WASM_BUILD.md)
- [Design Philosophy](DESIGN-PHILOSOPHY.md)
- [Privacy Policy](PRIVACY%20POLICY.md)
+120
View File
@@ -0,0 +1,120 @@
# ChronoSeal vs Popular Anti-Bot Systems (2026)
ChronoSeal is a **self-hosted, cryptographic attestation daemon**. This document compares it honestly with leading commercial solutions.
## Quick Comparison
| Solution | Type | Core Method | Privacy | Self-Hosted | Crypto Strength | Behavioral Analysis | Cost | Best For |
|----------------------------|-------------------|--------------------------------------|---------|-------------|-----------------|---------------------|---------------|------------------------------|
| **ChronoSeal** | Self-hosted Daemon| Ed25519 + Blake3 + **Gene Mutation** | Excellent | Yes | Very High | Light + Tunable | Free | Privacy + Control |
| Cloudflare Bot Management | Cloud Edge | JS Challenges + ML Fingerprinting | Medium | No | Medium | Strong | Freemium | Easy mass protection |
| Akamai Bot Manager | Enterprise Edge | Behavioral + Device Fingerprinting | Low | Hybrid | Medium | Very Strong | Very High | Large enterprises |
| HUMAN (PerimeterX) | Cloud SaaS | Behavioral Biometrics + ML | Low | No | Medium | Very Strong | Enterprise | Sophisticated bot defense |
| DataDome | Cloud SaaS | Real-time ML + Behavioral | Medium | No | Medium | Strong | Enterprise | E-commerce scraping |
| reCAPTCHA v3 | Google Service | Risk scoring + invisible challenges | Poor | No | Low | Medium | Free → Paid | Simple bot filtering |
| Kasada | Cloud SaaS | Proof-of-Work + Behavioral | Medium | No | High | Strong | Enterprise | Advanced automation |
## Detailed Analysis
### 1. ChronoSeal (v0.6.0)
**Strengths:**
- Strongest **cryptographic foundation** (Ed25519 signatures + Blake3 hash chain + Synthetic Gene Mutation Engine)
- Fully **deterministic** server ↔ WASM parity
- Completely **invisible** to users with silent rejection
- Excellent **privacy** — no third-party tracking or fingerprint databases
- Highly **tunable** mutation strength (`gene_size` + `mutation_rounds`)
- Full control and auditability
**Weaknesses:**
- Requires self-hosting and maintenance
- No global threat intelligence network like Cloudflare
---
### 2. Cloudflare Bot Management
**Strengths:**
- Extremely easy to deploy
- Excellent scale and global threat intelligence
- Good detection rates
**Weaknesses vs ChronoSeal:**
- Relies heavily on fingerprinting and JS challenges
- Sends data to Cloudflare (privacy impact)
- Less transparent and auditable
- Vendor lock-in
**Winner:** ChronoSeal for privacy-conscious teams
---
### 3. Enterprise Solutions (Akamai, HUMAN, DataDome, Kasada)
**Strengths:**
- Sophisticated ML + behavioral analysis
- Large threat intelligence databases
- Professional support
**Weaknesses vs ChronoSeal:**
- Extremely expensive
- Black-box systems (limited visibility)
- Heavy data collection (privacy concerns)
- Vendor dependency
**Winner:** ChronoSeal for teams wanting transparency and control
---
### 4. reCAPTCHA v3
**Strengths:**
- Free tier available
- Easy integration
**Weaknesses:**
- Heavy Google tracking
- Increasingly bypassed
- Poor privacy
**Winner:** ChronoSeal by a large margin
---
## When to Choose ChronoSeal
**Choose ChronoSeal if you want:**
- Maximum privacy
- Strong cryptographic guarantees
- Full control over your infrastructure
- Tunable defense strength
- No third-party data sharing
- Open source transparency
**Choose Commercial Solutions if you want:**
- Zero maintenance
- Massive global threat intelligence
- Enterprise support & SLAs
- Quick deployment at huge scale
## Technical Differentiation
ChronoSeal’s unique advantage is the **Synthetic Gene Mutation Engine** — a deterministic, server-controlled mutation sequence that both server and browser WASM must execute in sync. This creates a second synchronized state channel that is extremely difficult for automation to maintain at scale.
No commercial solution currently offers equivalent cryptographic + mutation-based attestation in a self-hosted package.
---
## Conclusion
**ChronoSeal** is currently one of the strongest **open-source/self-hosted** anti-bot solutions available. It trades ease-of-use and global scale for **privacy, transparency, cryptographic strength, and control**.
It is particularly well-suited for:
- Privacy-focused organizations
- High-value content platforms
- Teams that want to avoid vendor lock-in
- Developers who value auditability
---
+311 -17
View File
@@ -1,36 +1,330 @@
# Deployment # ChronoSeal Deployment Guide
## Native ChronoSeal is intended to run as a small Unix daemon behind TLS, with static browser assets served either by the daemon or by the same protected origin. This guide covers native, service, and container deployment.
## Deployment Model
Typical production topology:
```text
Internet
|
v
TLS reverse proxy
|
v
chronoseal daemon on 127.0.0.1:3000
|
v
sqlite-in-disk or valkey storage
```
For local evaluation, the daemon can bind directly to `0.0.0.0:3000` or `127.0.0.1:3000`.
## Requirements
| Tool | Minimum | Purpose |
|---|---:|---|
| Rust | 1.87 stable | Build server and shared crates |
| `wasm32-unknown-unknown` target | current stable | Compile WASM runtime |
| `wasm-pack` | 0.13 | Generate browser WASM package |
| systemd | 248+ | Native service management |
| Docker | 24.x | Optional container image |
| Docker Compose | 2.x | Optional local orchestration |
Install Rust from rustup, then install the WASM tooling:
```bash ```bash
cargo build -p server --release rustup target add wasm32-unknown-unknown
sudo cp target/release/server /usr/local/bin/chronoseal cargo install wasm-pack
``` ```
## Build
Use the repository build script:
```bash
bash scripts/build.sh
```
The script:
1. Builds `wasm/` with `wasm-pack build --target web --release`.
2. Replaces `frontend/pkg` with the generated package.
3. Builds the release daemon binary.
Manual equivalent:
```bash
wasm-pack build wasm --target web --release
rm -rf frontend/pkg
mv wasm/pkg frontend/pkg
cargo build -p chronoseal-server --bin chronoseal --release
```
Release binary:
```text
target/release/chronoseal
```
## Native Install
The installer builds, installs, enables, and starts the service:
```bash
sudo bash scripts/install.sh
```
Installer actions:
- create the `chronoseal` system user if missing
- build WASM and server artifacts
- install `target/release/chronoseal` to `/usr/local/bin/chronoseal`
- copy `frontend/` to `/opt/chronoseal/frontend`
- install `chronoseal.service` to `/etc/systemd/system/chronoseal.service`
- reload systemd
- enable and start the service
Verify:
```bash
sudo systemctl status chronoseal
chronoseal status --format json
chronoseal health
sudo journalctl -u chronoseal -f
```
## Running Without Install
For local development:
```bash
bash scripts/build.sh
cargo run -p chronoseal-server --bin chronoseal -- run \
--bind 127.0.0.1:3000 \
--frontend-dir frontend
```
Probe the daemon:
```bash
curl http://127.0.0.1:3000/health
curl http://127.0.0.1:3000/stats
curl http://127.0.0.1:3000/metrics
```
## Configuration
ChronoSeal resolves configuration in this order:
1. CLI flags
2. `CHRONOSEAL_*` environment variables
3. TOML config file
4. built-in defaults
Default config discovery:
1. `CHRONOSEAL_CONFIG`, if it points to an existing file
2. `/etc/chronoseal/config.toml`
3. `$XDG_CONFIG_HOME/chronoseal/config.toml`
4. `~/.config/chronoseal/config.toml`
Validate effective configuration:
```bash
chronoseal config check --format yaml
```
Example:
```toml
bind = "127.0.0.1:3000"
db_type = "sqlite-in-disk"
pid_file = "/run/chronoseal.pid"
db_path = "/var/lib/chronoseal/chronoseal.sqlite"
frontend_dir = "/usr/share/chronoseal/frontend"
log_file = "/var/log/chronoseal/chronoseal.jsonl"
heartbeat_min_interval_ms = 12000
heartbeat_max_interval_ms = 25000
expiration_minutes = 30
rate_limit_count = 5
rate_limit_window_secs = 10
max_timestamp_drift_ms = 30000
min_mouse_total_dist = 10.0
max_mouse_avg_speed = 2.0
min_pause_count = 1
require_mouse_activity = true
gene_size = 512
mutation_rounds = 4
```
## Storage Backends
| Backend | `db_type` | Use case |
|---|---|---|
| SQLite memory | `sqlite-in-memory` | ephemeral local or stateless deployment |
| SQLite disk | `sqlite-in-disk` | persisted session continuity across restarts |
| Valkey | `valkey` | external session storage |
For disk persistence:
```bash
sudo mkdir -p /var/lib/chronoseal
sudo chown -R chronoseal:chronoseal /var/lib/chronoseal
```
For Valkey:
```bash
export CHRONOSEAL_DB_TYPE=valkey
export CHRONOSEAL_VALKEY_ADDR=127.0.0.1:6666
```
If Valkey connection setup fails, the current implementation logs a warning and falls back to in-memory SQLite.
## systemd ## systemd
```bash The supplied service file is intended as the baseline unit. Keep the daemon under a dedicated user and restrict filesystem access to the paths it needs.
sudo cp chronoseal.service /etc/systemd/system/
Useful commands:
```bash
sudo systemctl daemon-reload sudo systemctl daemon-reload
sudo systemctl enable chronoseal sudo systemctl enable --now chronoseal
sudo systemctl start chronoseal sudo systemctl restart chronoseal
sudo systemctl status chronoseal
sudo journalctl -u chronoseal -f
``` ```
Recommended hardening properties include:
- `NoNewPrivileges=true`
- `PrivateTmp=true`
- `ProtectSystem=strict`
- `ProtectHome=true`
- `ProtectKernelTunables=true`
- `ProtectKernelModules=true`
- `ProtectControlGroups=true`
- `MemoryDenyWriteExecute=true`
- `RestrictRealtime=true`
- `RestrictSUIDSGID=true`
- `SystemCallArchitectures=native`
Any hardening must still allow access to:
- the binary
- frontend assets
- PID file directory
- optional log file directory
- SQLite database directory, if using `sqlite-in-disk`
## Reverse Proxy and TLS
ChronoSeal should be served over HTTPS in production. Terminate TLS at a reverse proxy or load balancer and proxy to the local daemon.
Minimal nginx example:
```nginx
server {
listen 443 ssl http2;
server_name example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
proxy_read_timeout 35s;
proxy_send_timeout 10s;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
server {
listen 80;
server_name example.com;
return 301 https://$host$request_uri;
}
```
Keep `/init`, `/hb`, and frontend assets on the same origin when possible. If you split origins, configure CORS and cookie/application policy deliberately.
## Docker ## Docker
Build and run:
```bash ```bash
bash scripts/build.sh
docker compose up -d --build docker compose up -d --build
``` ```
## Reverse Proxy The Compose file exposes port `3000`.
Recommended: ```bash
- nginx curl http://127.0.0.1:3000/health
- Nginx Proxy Manager ```
- HAProxy
Enable: The Dockerfile copies `frontend/` from the working tree. Build `frontend/pkg` before building the image when the browser WASM runtime is required inside the container.
- HTTP/2
- TLS 1.3 ## Observability
- aggressive timeout policies
CLI:
```bash
chronoseal status --format json
chronoseal health
chronoseal stats --format json
chronoseal metrics
```
HTTP:
```bash
curl http://127.0.0.1:3000/health
curl http://127.0.0.1:3000/stats
curl http://127.0.0.1:3000/metrics
```
Prometheus metrics:
- `chronoseal_sessions`
- `chronoseal_expired_sessions`
- `chronoseal_max_chain_length`
## Logging
Use info-level logs for production:
```bash
CHRONOSEAL_LOG=info chronoseal run
```
or with systemd:
```bash
sudo systemctl edit chronoseal
```
Avoid debug logging in production because internal identifiers may be written to logs.
## Production Checklist
- Build `frontend/pkg` before packaging.
- Serve ChronoSeal traffic over HTTPS.
- Bind the daemon to localhost behind a reverse proxy unless direct exposure is required.
- Use a dedicated service user.
- Keep debug logs disabled.
- Choose storage intentionally: `sqlite-in-memory`, `sqlite-in-disk`, or `valkey`.
- Protect SQLite and log directories with correct ownership.
- Monitor `/health`, `/stats`, and `/metrics`.
- Verify `chronoseal config check` after environment or config changes.
+125
View File
@@ -0,0 +1,125 @@
# ChronoSeal Design Philosophy
ChronoSeal is designed for operators who want a local, inspectable, Unix-native browser attestation layer rather than a hosted anti-bot black box.
## Core Position
ChronoSeal is infrastructure software. It should feel closer to `nginx`, `redis-server`, or a small system daemon than to a third-party analytics platform.
Design priorities:
- CLI-first operation
- explicit configuration
- deterministic protocol behavior
- small runtime surface
- privacy-preserving state
- observable health and metrics
- no hidden telemetry
- no persistent user profiling
## What ChronoSeal Optimizes For
### Operator Control
Operators should be able to build, run, inspect, configure, monitor, and stop the service with ordinary Unix tools.
This is why ChronoSeal provides:
- `chronoseal run`
- `chronoseal status`
- `chronoseal health`
- `chronoseal config check`
- `chronoseal metrics`
- `chronoseal stats`
- shell completions
- systemd integration
### Determinism
The protocol depends on deterministic agreement between server Rust and browser WASM.
Shared logic belongs in `shared/` when divergence would create security or correctness risk. This includes:
- protocol structs
- hash-chain semantics
- synthetic gene model
- mutation opcode behavior
- mutation order encoding
### Cost Escalation
ChronoSeal does not claim impossible security. It raises the cost of automation by making clients maintain:
- a browser-local signing key
- a signed canonical heartbeat payload
- a Blake3 hash chain
- VM execution output
- server-issued mutation progression
- plausible timing and interaction signals
The objective is to make cheap automation brittle and expensive automation more complex.
### Silent Rejection
Heartbeat rejection is intentionally ambiguous. Invalid heartbeats receive the same `status` value as accepted heartbeats, but accepted responses include next-state fields.
This avoids turning the API into a validation oracle. Integrators must check for `next_salt`, `next_mutation_step`, and `next_mutation_order_b64`.
### Privacy
ChronoSeal should not become a surveillance system.
It avoids:
- long-term user identifiers
- browser history
- cross-site identity graphs
- fingerprint databases
- behavioral profiling as a product feature
It stores only the session state required for continuity.
## Non-Goals
ChronoSeal is not:
- a CAPTCHA
- a fraud scoring engine
- an authentication provider
- a hosted SaaS product
- a persistent fingerprinting system
- a replacement for authorization checks
- a complete defense against real browser farms
## Operational Assumptions
ChronoSeal assumes:
- Linux or a Unix-like host
- systemd for production service management
- TLS in production
- browser clients can execute WASM
- operators can manage config files and service users
- application owners decide how attestation status gates protected resources
## Engineering Biases
When the project faces tradeoffs, prefer:
- explicit configuration over implicit magic
- server-side recomputation over browser trust
- bounded deterministic execution over unbounded heuristics
- clear CLI output over hidden dashboards
- local deployment over mandatory cloud dependencies
- privacy by data minimization over privacy by policy alone
## Success Criteria
ChronoSeal is succeeding when:
- legitimate browser sessions advance without user friction
- simple scrapers cannot pass the protocol
- automation requires a full stateful implementation
- operators can debug deployments with normal Unix tools
- stored data remains minimal and short-lived
- documentation reflects the implementation precisely
+283
View File
@@ -0,0 +1,283 @@
# ChronoSeal Performance Tuning Guide
ChronoSeal uses a deterministic Synthetic Gene Mutation Engine to strengthen browser session continuity validation. This guide explains how to tune the mutation engine for an appropriate balance between security strength, resource consumption, and user experience.
The primary tuning parameters are:
* `gene_size` — size of the synthetic gene buffer
* `mutation_rounds` — number of mutation iterations executed per heartbeat
---
## Understanding the Mutation Engine
For every accepted heartbeat, ChronoSeal executes a server-generated mutation program against a synthetic gene buffer.
Increasing mutation complexity raises the computational cost of reproducing valid session state while also increasing CPU utilization on both the server and browser runtime.
General effects:
* Larger `gene_size` increases mutation state complexity.
* Higher `mutation_rounds` increase computational work per heartbeat.
* Both increase memory access and CPU consumption.
* Excessive values may negatively impact lower-end mobile devices.
The optimal values depend on your threat model and expected client hardware.
---
## Recommended Configurations
| Profile | `gene_size` | `mutation_rounds` | Security Level | Recommended Usage |
| ----------------- | ----------- | ----------------- | ---------------- | -------------------------------- |
| Default | 512 | 4 | Moderate | Development and testing |
| Recommended | 2048 | 16 | Strong | Most production deployments |
| High Security | 4096 | 32 | Very Strong | Sensitive applications |
| Maximum Practical | 8192 | 64 | Extremely Strong | High-value targets |
| Experimental | 65536 | 65536 | Research Only | Benchmarking and experimentation |
### Recommended Production Configuration
```toml
gene_size = 2048
mutation_rounds = 16
```
This configuration provides a strong balance between security and runtime overhead for most deployments.
---
## Configuration
Edit your configuration file:
```toml
# Mutation Engine Settings
gene_size = 2048
mutation_rounds = 16
```
Common configuration locations:
```text
/etc/chronoseal/config.toml
~/.config/chronoseal/config.toml
```
Restart ChronoSeal:
```bash
sudo systemctl restart chronoseal
```
Validate the effective configuration:
```bash
chronoseal config check --format yaml
```
---
## Performance Monitoring
### Server-Side Monitoring
View service logs:
```bash
sudo journalctl -u chronoseal -f
```
Inspect runtime statistics:
```bash
chronoseal stats --format json
```
Enable additional diagnostics when required:
```bash
CHRONOSEAL_LOG=debug chronoseal run
```
Avoid debug logging in production environments.
---
### Browser-Side Monitoring
Measure mutation execution time:
```javascript
console.time("gene-mutation");
const commitment = preview_gene_commitment(
order_b64,
session_id,
mutation_step,
mutation_rounds
);
console.timeEnd("gene-mutation");
```
Browser developer tools can also be used to monitor:
* JavaScript execution time
* WASM execution time
* CPU utilization
* Memory consumption
---
## Tuning Strategy
### Step 1: Start with Recommended Values
```toml
gene_size = 2048
mutation_rounds = 16
```
Deploy and observe normal usage patterns.
### Step 2: Monitor Heartbeat Success Rates
Watch for:
* heartbeat failures
* increased browser CPU usage
* elevated mobile device latency
* increased battery consumption
### Step 3: Increase Gradually
Increase one parameter at a time.
Recommended progression:
```text
2048 / 16
4096 / 16
4096 / 32
8192 / 32
8192 / 64
```
This makes it easier to identify performance bottlenecks.
### Step 4: Test Mobile Devices
Always test on:
* Android devices
* iPhones
* older laptops
* low-power CPUs
Desktop-only validation can be misleading.
---
## Advanced Deployment Strategies
### Risk-Based Mutation Strength
Future deployments may choose to dynamically increase mutation strength based on:
* session age
* failed heartbeat history
* suspicious behavior signals
* protected resource sensitivity
Example policy:
```text
New session → 2048 / 16
Suspicious session → 4096 / 32
Elevated-risk action → 8192 / 64
```
---
## Performance Recommendations
### WASM Builds
Always use optimized builds:
```bash
wasm-pack build wasm --target web --release
```
### General Guidance
* Keep `mutation_rounds` below 64 for most deployments.
* Prefer increasing `gene_size` before dramatically increasing rounds.
* Benchmark on representative client hardware.
* Monitor browser CPU utilization during load testing.
* Re-evaluate settings after major algorithm changes.
### Storage Performance
For maximum throughput:
```toml
db_type = "sqlite-in-memory"
```
For persistence:
```toml
db_type = "sqlite-in-disk"
```
Choose based on operational requirements rather than mutation settings.
---
## Security Considerations
Higher values increase the cost of reproducing valid session state but do not provide absolute protection against determined attackers.
ChronoSeal remains a cost-raising attestation layer rather than a complete anti-abuse solution.
Mutation tuning should be considered alongside:
* heartbeat timing controls
* signature validation
* hash-chain continuity
* behavioral trust checks
* rate limiting
* session expiration
---
## Recommended Starting Point
For most production deployments:
```toml
gene_size = 2048
mutation_rounds = 16
```
This configuration provides a strong balance between security, performance, and compatibility across desktop and mobile devices.
---
## Related Documentation
* `docs/ARCHITECTURE.md`
* `docs/API.md`
* `docs/THREAT_MODEL.md`
* `docs/REFRACTORING-v0.6.0.md`
For diagnostics:
```bash
chronoseal config check
chronoseal stats
chronoseal health
```
+106
View File
@@ -0,0 +1,106 @@
# ChronoSeal Privacy Policy
ChronoSeal is a privacy-oriented browser attestation system. It is designed to validate short-lived session continuity without creating persistent user profiles.
This document describes what ChronoSeal itself collects and stores. Applications that integrate ChronoSeal may collect additional data under their own policies.
## Data ChronoSeal Processes
ChronoSeal processes the minimum protocol data needed to validate a live browser session.
| Data | Purpose |
|---|---|
| `session_id` | Opaque session lookup key |
| public key | Verify signed heartbeats for the session |
| `salt` | Hash-chain progression |
| `initial_hash` / `prev_hash` / `last_hash` | Replay-resistant continuity |
| `timestamp` | Drift and liveness validation |
| mouse event samples | Behavioral plausibility checks |
| VM stack state | Input to hash-chain progression |
| basic fingerprint fields | Sanity validation |
| gene bytes and environment records | Mutation continuity |
| pending mutation program and step | Next heartbeat verification |
| expiration and last-seen timestamps | Session lifecycle and cleanup |
Basic fingerprint fields currently include:
- aspect ratio
- device pixel ratio
- hardware concurrency
## Data ChronoSeal Does Not Intentionally Collect
ChronoSeal does not intentionally collect or build:
- browser history
- page content history
- account identity
- email addresses
- names
- payment data
- location history
- cross-site tracking identifiers
- persistent fingerprint databases
- long-term behavioral profiles
ChronoSeal is not intended for analytics, advertising, or identity graph construction.
## Session Lifetime
Sessions are short-lived and expire according to `expiration_minutes`, which defaults to 30 minutes.
Expired sessions are removed by cleanup behavior. In-memory storage is lost when the process exits.
## Storage Modes and Persistence
| Mode | Persistence |
|---|---|
| `sqlite-in-memory` | process lifetime only |
| `sqlite-in-disk` | persisted to the configured SQLite file |
| `valkey` | persisted according to the Valkey deployment configuration |
Persistent state is operator-selected. The default backend is `sqlite-in-memory`.
## Client-Side Key Handling
The browser WASM runtime generates an Ed25519 keypair for the session.
- The public key is sent to `/init`.
- The private key is not sent to the server.
- Heartbeat payloads are signed in the browser runtime.
This is a continuity mechanism, not a long-term identity mechanism.
## Silent Rejection
ChronoSeal returns the same basic heartbeat status for accepted and rejected heartbeat requests:
```json
{
"status": "ok"
}
```
Accepted responses additionally include next-state fields. Rejected responses omit them.
This reduces attacker feedback and avoids returning detailed failure classifications to clients.
## Logs
Operators control logging through `CHRONOSEAL_LOG`, `RUST_LOG`, and optional log-file configuration.
Production deployments should avoid debug logging because internal session identifiers or validation context may appear in logs.
## Operator Responsibilities
Operators should:
- serve traffic over HTTPS
- protect SQLite, Valkey, and log storage
- restrict access to metrics and stats endpoints
- choose persistence mode deliberately
- disclose any application-level data collection separately
## Summary
ChronoSeal validates live session continuity using short-lived cryptographic and deterministic state. It is designed to raise automation cost without becoming a persistent tracking or profiling system.
+191
View File
@@ -0,0 +1,191 @@
# ChronoSeal v0.6.0 Refactoring and System Upgrade
ChronoSeal v0.6.0 changed the project from a lightweight heartbeat prototype into a Unix-native attestation daemon with shared server/WASM protocol logic, deterministic mutation parity, operational CLI commands, and pluggable storage modes.
This document summarizes the architectural changes introduced in the v0.6.0 line.
## Summary
Major changes:
- introduced the Synthetic Gene Mutation Engine
- added server-side validation of `mutation_step` and `gene_commitment`
- moved protocol and deterministic mutation logic into `shared/`
- added `chronoseal-wasm` browser runtime support for mutation preview and commit
- expanded persisted session state with gene and pending mutation fields
- added storage modes: `sqlite-in-memory`, `sqlite-in-disk`, and `valkey`
- added health, metrics, stats, config, status, completion, and version CLI surfaces
- added PID file handling, structured logging, and graceful shutdown behavior
- preserved silent heartbeat rejection semantics
## Motivation
The earlier model relied mainly on:
- heartbeat timing
- behavioral entropy
- hash-chain continuity
- signature verification
v0.6.0 added a second deterministic state channel: a server-authored synthetic gene mutation sequence. This makes successful automation maintain both:
- the cryptographic hash/signature chain
- the synthetic mutation state expected by the server
## Shared Crate Refactor
`shared/` now owns the parts of the protocol that must remain identical across server and browser runtime:
- request and response structs
- hashing helpers
- synthetic gene state
- mutation environment encoding
- mutation order generation and encoding
- opcode execution semantics
- protocol constants
This reduces the risk of server/WASM drift.
## Mutation Handshake
New protocol fields:
- `gene_size`
- `mutation_step`
- `mutation_order_b64`
- `gene_commitment`
- `next_mutation_step`
- `next_mutation_order_b64`
Lifecycle:
1. `/init` returns mutation step 1 and a server-authored mutation order.
2. The browser previews the mutation in WASM.
3. The browser signs and submits the resulting `gene_commitment`.
4. The server applies the same pending mutation to its committed state.
5. The server compares commitments.
6. On success, server commits the candidate state and issues the next mutation.
7. The browser commits its preview only after receiving the accepted response.
## Session Schema Changes
The persisted session record now includes:
- committed gene bytes
- encoded environment records
- pending mutation program
- pending mutation step
State advances only after a heartbeat is accepted. Rejected heartbeats do not rotate salt, update hash state, commit gene state, or consume the pending mutation.
## WASM Runtime Changes
The WASM crate now supports:
- `generate_keypair()`
- `get_public_key()`
- `sign_message()`
- `compute_next_hash()`
- `run_program()`
- `init_gene_state()`
- `preview_gene_commitment(order_b64, session_id, mutation_step, rounds)`
- `commit_gene_preview()`
- `discard_gene_preview()`
- `current_gene_commitment(session_id, mutation_step)`
The generated package uses the `chronoseal_wasm` prefix.
## Storage Refactor
The storage layer is abstracted behind `DbPool`.
Supported modes:
| Mode | Behavior |
|---|---|
| `sqlite-in-memory` | default ephemeral in-process SQLite |
| `sqlite-in-disk` | persisted SQLite database at `db_path` |
| `valkey` | Valkey-compatible external store |
The storage interface supports insert, load, update, delete expired sessions, and stats.
## CLI and Runtime Changes
The `chronoseal` binary now provides:
- `run`
- `status`
- `health`
- `config check`
- `generate keypair`
- `version`
- `db-type`
- `metrics`
- `stats`
- `completion`
The daemon exposes:
- `POST /init`
- `POST /hb`
- `GET /health`
- `GET /metrics`
- `GET /stats`
- static frontend serving at `/`
## Validation Improvements
The heartbeat verifier now checks:
- session presence
- expiration
- signature
- hash-chain continuity
- mutation step
- mutation commitment parity
- timestamp drift
- behavioral mouse checks
- fingerprint ranges
- rate limiting at the route layer
Accepted heartbeats return next-state fields. Rejected heartbeats return only `{"status":"ok"}`.
## Testing Impact
The refactor added or strengthened tests for:
- gene environment encoding and validation
- mutation opcode behavior
- mutation order round-trips
- deterministic mutation generation with seeded RNG
- server/client mutation parity
- random program divergence resistance
- replay rejection
- mutation step mismatch rejection
- mutation commitment tamper rejection
- storage backend stats
- route-level silent rejection behavior
## Operational Impact
v0.6.0 makes ChronoSeal more suitable for deployment as a real service:
- explicit daemon lifecycle
- CLI-first operations
- systemd-oriented install path
- health and metrics endpoints
- configurable persistence
- shared protocol implementation
- clearer docs and threat model
## Compatibility Notes
Important names in the current implementation:
- binary: `chronoseal`
- server crate: `chronoseal-server`
- WASM crate: `chronoseal-wasm`
- generated WASM module prefix: `chronoseal_wasm`
- persistent SQLite mode: `sqlite-in-disk`
Older docs or integrations may refer to `sqlite-disk`, `server`, or `antibot_wasm`; those names are stale for the current codebase.
+240
View File
@@ -0,0 +1,240 @@
# ChronoSeal Threat Model
ChronoSeal is a cost-raising browser attestation layer. It makes replay, stale state reuse, and incomplete automation more expensive by requiring signed, continuous, deterministic browser-side state progression.
It is not a perfect bot blocker, CAPTCHA replacement, hardware attestation system, fraud engine, or identity provider.
## Security Objectives
ChronoSeal aims to:
- reject stale or replayed heartbeat payloads
- reject heartbeats that do not maintain the server-issued mutation sequence
- bind heartbeat payloads to a browser-local Ed25519 session key
- make basic HTTP clients insufficient
- make browser automation maintain multiple synchronized state channels
- avoid detailed rejection feedback
- preserve privacy by avoiding persistent user identity state
## Protected Assets
| Asset | Protection focus |
|---|---|
| Protected page/API access | Require live attestation before allowing continued access |
| Session continuity | Ensure each accepted heartbeat advances from the last accepted state |
| Server compute | Rate-limit and reject invalid clients without expensive application work |
| Protocol state | Protect hash-chain, salt, and mutation progression |
| User privacy | Avoid long-term tracking and detailed failure disclosure |
## Trust Assumptions
ChronoSeal assumes:
- the server host and daemon process are trusted
- storage is trusted for session continuity
- TLS protects traffic in production
- browser clients can run JavaScript and WASM
- operators configure reverse proxy, filesystem permissions, and logs appropriately
ChronoSeal does not assume:
- the browser is honest
- WASM is a secure enclave
- mouse data proves human presence
- fingerprint values are unforgeable
- attackers cannot run a full browser
## Attacker Levels
### Level 1: Commodity HTTP Client
Examples:
- `curl`
- `requests`
- scraper scripts without browser or WASM execution
Expected result:
- cannot produce valid signatures
- cannot maintain hash-chain state
- cannot execute mutation preview
- cannot produce accepted heartbeats
### Level 2: Basic Headless Browser
Examples:
- Playwright
- Puppeteer
- Selenium
Expected result:
- can load JavaScript and WASM
- must preserve keypair, hash chain, salt, VM, and mutation state
- must generate plausible timing and mouse event windows
- silent rejection complicates debugging and scaling
### Level 3: Stealth Automation
Examples:
- patched browser runtime
- synthetic event generation
- custom protocol client with WASM or Rust reimplementation
Expected result:
- can attempt full protocol implementation
- must still match canonical signing, hash progression, mutation parity, and timing
- must handle changing server-issued mutation programs
- receives limited failure feedback
### Level 4: Resourced Browser Farm
Examples:
- real browsers
- realistic input devices
- human-assisted workflows
- distributed session management
Expected result:
- ChronoSeal raises cost and complexity
- ChronoSeal does not claim complete prevention
- additional application-level controls are required
## Attack Vectors and Mitigations
### Replay
Attack: resend a previously accepted heartbeat.
Mitigations:
- stored `last_hash` must match request `prev_hash`
- accepted heartbeats rotate salt
- mutation step advances after acceptance
- timestamp drift is bounded
### Signature Forgery
Attack: submit a heartbeat without the browser session private key.
Mitigations:
- Ed25519 signature over canonical payload
- public key registered during `/init`
- signature verified on every heartbeat
- signature covers mutation step and gene commitment
### Hash-Chain Desynchronization
Attack: submit a heartbeat from stale client state.
Mitigations:
- server compares request `prev_hash` to stored `last_hash`
- server computes the next hash only after all validation passes
- rejected heartbeats do not advance server state
### Mutation Tampering
Attack: forge or skip synthetic gene mutations.
Mitigations:
- server stores the pending mutation program
- request must include the expected `mutation_step`
- server applies the mutation independently
- commitment includes candidate gene state, `session_id`, and step
- mismatch causes silent rejection
### Session Identifier Theft
Attack: reuse a stolen `session_id`.
Mitigations:
- `session_id` alone is insufficient
- attacker also needs current private key, hash state, salt, mutation step, and mutation state
- stale attempts fail after the real session advances
### Failure Oracle Probing
Attack: send malformed requests and inspect responses to infer validation rules.
Mitigations:
- heartbeat semantic failures return `200 OK` with `{"status":"ok"}`
- accepted heartbeats are distinguished only by next-state fields
- detailed validation errors are not returned to the client
### Storage Tampering
Attack: alter persisted session state.
Mitigations:
- run the daemon under a dedicated user
- restrict SQLite database permissions
- protect Valkey behind trusted network boundaries
- use normal host hardening and backups where persistence matters
Storage is trusted. If an attacker can modify storage, they can affect session continuity.
## Behavioral Checks
ChronoSeal validates:
- minimum event count
- minimum movement distance
- maximum average speed
- pause count
- timestamp drift
- basic fingerprint field ranges
These checks are cost signals. They are not proof of humanity and should not be the only security layer for high-risk actions.
## Privacy Constraints
ChronoSeal intentionally avoids:
- persistent user identifiers
- browser history collection
- device fingerprint databases
- cross-session identity graphs
- long-term behavioral profiles
Session data is short-lived by default. Persistent storage is operator-selected through `sqlite-in-disk` or `valkey`.
## Limitations
ChronoSeal does not protect against:
- real users intentionally automating or abusing access
- complete browser farms with realistic input
- compromised server hosts
- tampered storage
- server-side application vulnerabilities
- credential theft outside ChronoSeal
- policy decisions that require identity, risk scoring, or business context
## Operational Security
Recommended:
- serve all traffic over HTTPS
- keep `/init` and `/hb` same-origin with protected content when possible
- run behind a reverse proxy
- keep debug logs disabled in production
- protect storage and log directories
- monitor health and metrics
- use `sqlite-in-memory` for ephemeral sessions
- use `sqlite-in-disk` or `valkey` only when persistence is required
## Disclosure
See [../SECURITY.md](../SECURITY.md) for the vulnerability disclosure policy.
+163
View File
@@ -0,0 +1,163 @@
# ChronoSeal WASM Build Guide
ChronoSeal uses a Rust-generated WASM package for browser-side attestation. The package is built from `wasm/` and copied into `frontend/pkg`.
## Responsibilities
The WASM runtime:
- generates a browser-local Ed25519 keypair
- signs canonical heartbeat payloads
- computes Blake3 hash-chain progression
- executes server-issued VM opcode programs
- initializes synthetic gene state
- previews gene mutation commitments
- commits or discards preview state after heartbeat response
The WASM runtime is not treated as a secure enclave. The server independently recomputes deterministic state.
## Requirements
```bash
rustup target add wasm32-unknown-unknown
cargo install wasm-pack
```
Verify:
```bash
wasm-pack --version
```
## Build
From the repository root:
```bash
wasm-pack build wasm --target web --release
rm -rf frontend/pkg
mv wasm/pkg frontend/pkg
```
`--target web` emits native ES modules compatible with the static frontend.
Development build:
```bash
wasm-pack build wasm --target web
rm -rf frontend/pkg
mv wasm/pkg frontend/pkg
```
Full project build:
```bash
bash scripts/build.sh
```
## Output Files
The package name comes from the crate name `chronoseal-wasm`, so generated files use the `chronoseal_wasm` prefix.
Expected `frontend/pkg/` contents include:
- `chronoseal_wasm.js`
- `chronoseal_wasm_bg.wasm`
- `chronoseal_wasm.d.ts`
- `package.json`
Generated files in `wasm/pkg/` and `frontend/pkg/` are build artifacts and should be regenerated during release.
## Browser Import
```js
import init, {
generate_keypair,
get_public_key,
sign_message,
compute_next_hash,
run_program,
init_gene_state,
preview_gene_commitment,
commit_gene_preview,
discard_gene_preview,
current_gene_commitment
} from './pkg/chronoseal_wasm.js';
```
Call `await init()` before using any exported function.
## Exported Functions
| Function | Signature | Failure value |
|---|---|---|
| `generate_keypair()` | `() -> string` | `""` only on unexpected failure |
| `get_public_key()` | `() -> string` | `""` if no keypair exists |
| `sign_message(msg)` | `(string) -> string` | `""` if no keypair exists or signing fails |
| `compute_next_hash(prev, ts, entropy, stack, salt)` | `(string, u64, string, string, string) -> string` | panic/error path should be avoided by valid inputs |
| `run_program(b64)` | `(string) -> JsValue` | returns empty/default stack state on invalid execution path |
| `init_gene_state(gene_size)` | `(u32) -> bool` | `false` |
| `preview_gene_commitment(order_b64, session_id, mutation_step, rounds)` | `(string, string, u64, u8) -> string` | `""` |
| `commit_gene_preview()` | `() -> bool` | `false` |
| `discard_gene_preview()` | `() -> void` | none |
| `current_gene_commitment(session_id, mutation_step)` | `(string, u64) -> string` | `""` if no committed state exists |
`rounds = 0` in `preview_gene_commitment` selects the shared default mutation round count.
## Mutation State Lifecycle
The browser must keep two gene states:
- committed state: the last accepted state
- preview state: candidate state for the heartbeat currently being sent
Expected sequence:
1. Call `init_gene_state(gene_size)` after `/init`.
2. Call `preview_gene_commitment(order_b64, session_id, mutation_step, rounds)` before signing `/hb`.
3. Include the returned commitment and mutation step in the signed heartbeat.
4. If the response contains next-state fields, call `commit_gene_preview()`.
5. If the heartbeat is rejected or errors, call `discard_gene_preview()`.
Never commit preview state before the server accepts the heartbeat.
## Hash-Chain Ordering
After an accepted heartbeat, compute the next local hash with the salt that was active when the heartbeat was sent. Then replace the local salt with `next_salt`.
Correct order:
```js
const sentSalt = currentSalt;
currentSalt = resp.next_salt;
prevHash = compute_next_hash(prevHash, timestamp, entropyJson, stackStateJson, sentSalt);
```
This mirrors the server, which computes and stores the new hash before rotating to the next salt.
## Serving WASM
The `.wasm` file must be served with:
```text
Content-Type: application/wasm
```
ChronoSeal's built-in static file service handles this for normal deployments.
## Validation
Recommended checks after WASM changes:
```bash
cargo test --workspace
cargo clippy --workspace --all-targets -- -D warnings
wasm-pack build wasm --target web
```
Then refresh `frontend/pkg`:
```bash
rm -rf frontend/pkg
mv wasm/pkg frontend/pkg
```
+27
View File
@@ -0,0 +1,27 @@
bind = "0.0.0.0:3000"
# Storage backend: sqlite-in-memory (default), sqlite-in-disk, or valkey.
db_type = "sqlite-in-memory"
pid_file = "/run/chronoseal.pid"
db_path = "/var/lib/chronoseal/chronoseal.sqlite"
frontend_dir = "/usr/share/chronoseal/frontend"
log_file = "/var/log/chronoseal/chronoseal.jsonl"
heartbeat_min_interval_ms = 12000
heartbeat_max_interval_ms = 25000
expiration_minutes = 30
rate_limit_count = 5
rate_limit_window_secs = 10
max_timestamp_drift_ms = 30000
min_mouse_total_dist = 10.0
max_mouse_avg_speed = 2.0
min_pause_count = 1
require_mouse_activity = true
# Synthetic gene size. Current valid range: 1..=4096. Default: 512.
gene_size = 512
# Current valid range: 1..=10. Default: 4.
mutation_rounds = 4
+42 -5
View File
@@ -1,8 +1,21 @@
import init, { generate_keypair, sign_message, compute_next_hash, run_program } from './pkg/antibot_wasm.js'; import init, {
generate_keypair,
sign_message,
compute_next_hash,
run_program,
init_gene_state,
preview_gene_commitment,
commit_gene_preview,
discard_gene_preview
} from './pkg/chronoseal_wasm.js';
import { collectEntropy } from './entropy.js'; import { collectEntropy } from './entropy.js';
import { sendRequest } from './transport.js'; import { sendRequest } from './transport.js';
let session, prevHash, currentSalt, opcodesB64, lastTime; let session, prevHash, currentSalt, opcodesB64, lastTime;
let minInterval = 12000;
let maxInterval = 25000;
let pendingMutationStep = 0;
let pendingMutationOrderB64 = '';
export async function initHeartbeat() { export async function initHeartbeat() {
await init(); await init();
@@ -12,12 +25,19 @@ export async function initHeartbeat() {
prevHash = initResp.initial_hash; prevHash = initResp.initial_hash;
currentSalt = initResp.salt; currentSalt = initResp.salt;
opcodesB64 = initResp.opcodes_b64; 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(); lastTime = performance.now();
scheduleNext(); scheduleNext();
} }
function scheduleNext() { function scheduleNext() {
const delay = 12000 + Math.random() * 13000; const delay = minInterval + Math.random() * (maxInterval - minInterval);
setTimeout(sendHeartbeat, delay); setTimeout(sendHeartbeat, delay);
} }
@@ -36,6 +56,10 @@ async function sendHeartbeat() {
const timestamp = Date.now(); const timestamp = Date.now();
const entropyData = { events: events.map(e => ({ x: e.x, y: e.y, t: e.t })) }; const entropyData = { events: events.map(e => ({ x: e.x, y: e.y, t: e.t })) };
const entropyJson = JSON.stringify(entropyData); const entropyJson = JSON.stringify(entropyData);
const geneCommitment = preview_gene_commitment(pendingMutationOrderB64);
if (!geneCommitment) {
throw new Error('Unable to compute mutation commitment');
}
const signable = { const signable = {
sessionId: session, sessionId: session,
@@ -43,11 +67,14 @@ async function sendHeartbeat() {
timestamp: timestamp, timestamp: timestamp,
entropyData: entropyData, entropyData: entropyData,
stackState: JSON.parse(stackState), stackState: JSON.parse(stackState),
fingerprint: fingerprint fingerprint: fingerprint,
mutationStep: pendingMutationStep,
geneCommitment: geneCommitment
}; };
const msg = JSON.stringify(signable, Object.keys(signable).sort()); const msg = JSON.stringify(signable, Object.keys(signable).sort());
const sig = sign_message(msg); const sig = sign_message(msg);
if (!sig) { if (!sig) {
discard_gene_preview();
console.error('Keypair not initialised — skipping heartbeat'); console.error('Keypair not initialised — skipping heartbeat');
return; return;
} }
@@ -58,22 +85,32 @@ async function sendHeartbeat() {
entropy_data: entropyData, entropy_data: entropyData,
stack_state: JSON.parse(stackState), stack_state: JSON.parse(stackState),
fingerprint, fingerprint,
mutation_step: pendingMutationStep,
gene_commitment: geneCommitment,
signature: sig signature: sig
}); });
if (resp.next_salt) { if (resp.next_salt && resp.next_mutation_step && resp.next_mutation_order_b64) {
if (!commit_gene_preview()) {
discard_gene_preview();
throw new Error('Failed to commit local mutation preview');
}
// IMPORTANT: capture the salt that was active when this heartbeat was sent. // IMPORTANT: capture the salt that was active when this heartbeat was sent.
// The server computes new_hash = H(prev, ts, entropy, stack, OLD_salt) and stores it, // The server computes new_hash = H(prev, ts, entropy, stack, OLD_salt) and stores it,
// then rotates to next_salt. We must mirror that using the same old salt, then rotate. // then rotates to next_salt. We must mirror that using the same old salt, then rotate.
const sentSalt = currentSalt; const sentSalt = currentSalt;
currentSalt = resp.next_salt; currentSalt = resp.next_salt;
prevHash = compute_next_hash(prevHash, timestamp, entropyJson, stackState, sentSalt); prevHash = compute_next_hash(prevHash, timestamp, entropyJson, stackState, sentSalt);
pendingMutationStep = resp.next_mutation_step;
pendingMutationOrderB64 = resp.next_mutation_order_b64;
} else { } else {
discard_gene_preview();
console.warn('Heartbeat rejected'); console.warn('Heartbeat rejected');
} }
} catch (e) { } catch (e) {
discard_gene_preview();
console.error(e); console.error(e);
} finally { } finally {
scheduleNext(); scheduleNext();
} }
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

+127
View File
@@ -0,0 +1,127 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
version="1.1"
id="Layer_1"
x="0px"
y="0px"
width="296.99997mm"
viewBox="0 0 1122.5196 793.7008"
enable-background="new 0 0 1254 1254"
xml:space="preserve"
height="210mm"
sodipodi:docname="chronoseal.svg"
inkscape:export-filename="logo1.png"
inkscape:export-xdpi="96"
inkscape:export-ydpi="96"
inkscape:version="1.4.4 (dcaf3e7d9e, 2026-05-05)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
inkscape:zoom="1.1654266"
inkscape:cx="561.16791"
inkscape:cy="396.85039"
inkscape:window-width="2048"
inkscape:window-height="1205"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="Layer_1" /><defs
id="defs44" />
<path
fill="none"
opacity="0"
stroke="none"
d="m 791.89377,632.01366 c 1.04068,-1.03091 1.90903,-2.38956 3.14896,-3.04166 11.48425,-6.03955 22.98721,-12.04745 34.57742,-17.88034 8.52002,-4.28775 14.37558,-10.49713 14.23218,-20.46771 -0.14127,-9.82202 -6.25309,-15.51495 -14.66742,-19.5827 -61.76651,-29.85993 -123.47361,-59.84277 -185.21183,-89.76129 -10.48242,-5.07981 -20.81335,-10.5555 -31.58477,-14.93764 -19.71186,-8.01936 -39.87799,-8.40881 -59.18305,0.84148 -71.77527,34.39209 -143.33667,69.23047 -215.00567,103.84463 -8.40815,4.06091 -14.32166,9.84101 -14.39978,19.74008 -0.079,10.00498 5.52282,16.22797 14.08978,20.54743 12.3277,6.21567 24.51239,12.71484 37.34228,19.41369 7.51429,3.93381 14.42273,7.5896 21.37854,11.15274 31.68775,16.23208 63.40558,32.40548 95.07782,48.66778 14.943,7.6726 29.74964,15.6123 44.73053,23.2091 8.44464,4.2823 16.79547,9.1534 25.75525,11.9324 18.81989,5.8372 37.10712,3.9335 54.83185,-5.5578 25.51599,-13.6634 51.41541,-26.6141 77.2135,-39.7465 30.82071,-15.6894 61.7009,-31.26196 92.58359,-46.82907 1.56006,-0.78641 3.38776,-1.0419 5.09082,-1.54462 z"
id="path2" /><path
fill="#4b4d51"
opacity="1"
stroke="none"
d="m 791.89376,550.60089 c 1.04068,-1.03091 1.90903,-2.38956 3.14896,-3.04166 11.48425,-6.03955 22.98721,-12.04745 34.57742,-17.88034 8.52002,-4.28775 14.37558,-10.49713 14.23218,-20.46771 -0.14127,-9.82202 -6.25309,-15.51495 -14.66742,-19.5827 -61.76651,-29.85993 -123.47361,-59.84277 -185.21183,-89.76129 -10.48242,-5.07981 -20.81335,-10.5555 -31.58477,-14.93764 -19.71186,-8.01936 -39.87799,-8.40881 -59.18305,0.84148 -71.77522,34.39209 -143.33662,69.23047 -215.00562,103.84463 -8.4082,4.06091 -14.3217,9.84101 -14.3998,19.74008 -0.079,10.00498 5.5228,16.22797 14.0898,20.54743 12.3277,6.21567 24.5123,12.71484 37.3422,19.41369 7.5143,3.93381 14.4228,7.5896 21.3786,11.15274 31.6877,16.23212 63.4056,32.40552 95.0778,48.66782 14.943,7.67261 29.7496,15.61227 44.73052,23.20911 8.44464,4.28232 16.79547,9.15341 25.75525,11.93237 18.81989,5.83722 37.10712,3.93353 54.83185,-5.55777 25.51599,-13.66336 51.41541,-26.61407 77.2135,-39.74655 30.82071,-15.68933 61.7009,-31.26196 92.58359,-46.82907 1.56006,-0.78641 3.38776,-1.0419 5.09082,-1.54462 z"
id="path45"
style="fill:#e1e1e4;fill-opacity:1" /><path
fill="#4b4d51"
opacity="1"
stroke="none"
d="m 791.89377,469.18814 c 1.04068,-1.03091 1.90903,-2.38956 3.14896,-3.04166 11.48425,-6.03955 22.98721,-12.04745 34.57742,-17.88034 8.52002,-4.28775 14.37558,-10.49713 14.23218,-20.46771 -0.14127,-9.82202 -6.25309,-15.51495 -14.66742,-19.5827 C 767.4184,378.3558 705.7113,348.37296 643.97308,318.45444 c -10.48242,-5.07981 -20.81335,-10.5555 -31.58477,-14.93764 -19.71186,-8.01936 -39.87799,-8.40881 -59.18305,0.84148 -71.77527,34.39209 -143.33667,69.23047 -215.00567,103.84463 -8.40815,4.06091 -14.32166,9.84101 -14.39978,19.74008 -0.079,10.00498 5.52282,16.22797 14.08978,20.54743 12.3277,6.21567 24.51239,12.71484 37.34228,19.41369 7.51429,3.93381 14.42273,7.5896 21.37854,11.15274 31.68775,16.23212 63.40558,32.40552 95.07782,48.66782 14.943,7.67261 29.74964,15.61227 44.73053,23.20911 8.44464,4.28232 16.79547,9.15341 25.75525,11.93237 18.81989,5.83722 37.10712,3.93353 54.83185,-5.55777 25.51599,-13.66336 51.41541,-26.61407 77.2135,-39.74655 30.82071,-15.68933 61.7009,-31.26196 92.58359,-46.82907 1.56006,-0.78641 3.38776,-1.0419 5.09082,-1.54462 z"
id="path46"
style="fill:#b0b2b8;fill-opacity:1" /><path
fill="#4b4d51"
opacity="1"
stroke="none"
d="m 791.89377,387.77539 c 1.04068,-1.03091 1.90903,-2.38956 3.14896,-3.04166 11.48425,-6.03955 22.98721,-12.04745 34.57742,-17.88034 8.52002,-4.28775 14.37558,-10.49713 14.23218,-20.46771 -0.14127,-9.82202 -6.25309,-15.51495 -14.66742,-19.5827 -61.76651,-29.85993 -123.47361,-59.84277 -185.21183,-89.76129 -10.48242,-5.07981 -20.81335,-10.5555 -31.58477,-14.93764 -19.71186,-8.01936 -39.87799,-8.40881 -59.18305,0.84148 -71.77527,34.39209 -143.33667,69.23047 -215.00567,103.84463 -8.40815,4.06091 -14.32166,9.84101 -14.39978,19.74008 -0.079,10.00498 5.52282,16.22797 14.08978,20.54743 12.3277,6.21567 24.51239,12.71484 37.34228,19.41369 7.51429,3.93381 14.42273,7.5896 21.37854,11.15274 31.68775,16.23212 63.40558,32.40552 95.07782,48.66782 14.943,7.67261 29.74964,15.61227 44.73053,23.20911 8.44464,4.28232 16.79547,9.15341 25.75525,11.93237 18.81989,5.83722 37.10712,3.93353 54.83185,-5.55777 25.51599,-13.66336 51.41541,-26.61407 77.2135,-39.74655 30.82071,-15.68933 61.7009,-31.26196 92.58359,-46.82907 1.56006,-0.78641 3.38776,-1.0419 5.09082,-1.54462 z"
id="path47"
style="fill:#7b7e85;fill-opacity:1" /><text
xml:space="preserve"
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:160px;font-family:'URW Gothic';-inkscape-font-specification:'URW Gothic, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;writing-mode:lr-tb;direction:ltr;fill:#e1e1e4;fill-opacity:1;stroke-width:39.1"
x="154.54701"
y="550.13623"
id="text47"><tspan
id="tspan47"
x="154.54701"
y="550.13623" /><tspan
id="tspan48"
x="154.54701"
y="750.13623"
style="font-style:normal;font-variant:normal;font-weight:600;font-stretch:normal;font-size:160px;font-family:'URW Gothic';-inkscape-font-specification:'URW Gothic, Semi-Bold';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal"><tspan
style="font-style:normal;font-variant:normal;font-weight:600;font-stretch:normal;font-size:160px;font-family:'URW Gothic';-inkscape-font-specification:'URW Gothic, Semi-Bold';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;fill:#1f2024;fill-opacity:1"
id="tspan49">chrono</tspan>seal</tspan></text><path
fill="#4b4d51"
opacity="1"
stroke="none"
d="m 791.89376,306.36264 c 1.04068,-1.03091 1.90903,-2.38956 3.14896,-3.04166 11.48425,-6.03955 22.98721,-12.04745 34.57742,-17.88034 8.52002,-4.28775 14.37558,-10.49713 14.23218,-20.46771 -0.14127,-9.82202 -6.25309,-15.51495 -14.66742,-19.5827 -61.76651,-29.85993 -123.47361,-59.84277 -185.21183,-89.76129 -10.48242,-5.07981 -20.81335,-10.5555 -31.58477,-14.93764 -19.71186,-8.01936 -39.87799,-8.40881 -59.18305,0.84148 -71.77522,34.39209 -143.33662,69.23047 -215.00562,103.84463 -8.4082,4.06091 -14.3217,9.84101 -14.3998,19.74008 -0.079,10.00498 5.5228,16.22797 14.0898,20.54743 12.3277,6.21567 24.5123,12.71484 37.3422,19.41369 7.5143,3.93381 14.4228,7.5896 21.3786,11.15274 31.6877,16.23212 63.4056,32.40552 95.0778,48.66782 14.943,7.67261 29.7496,15.61227 44.73052,23.20911 8.44464,4.28232 16.79547,9.15341 25.75525,11.93237 18.81989,5.83722 37.10712,3.93353 54.83185,-5.55777 25.51599,-13.66336 51.41541,-26.61407 77.2135,-39.74655 30.82071,-15.68933 61.7009,-31.26196 92.58359,-46.82907 1.56006,-0.78641 3.38776,-1.0419 5.09082,-1.54462 z"
id="path1"
style="fill:#4a4d53;fill-opacity:1" /></svg>

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.
+127
View File
@@ -0,0 +1,127 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
version="1.1"
id="Layer_1"
x="0px"
y="0px"
width="296.99997mm"
viewBox="0 0 1122.5196 793.7008"
enable-background="new 0 0 1254 1254"
xml:space="preserve"
height="210mm"
sodipodi:docname="logo1.svg"
inkscape:export-filename="logo1.png"
inkscape:export-xdpi="96"
inkscape:export-ydpi="96"
inkscape:version="1.4.4 (dcaf3e7d9e, 2026-05-05)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
inkscape:zoom="1.1654266"
inkscape:cx="561.16791"
inkscape:cy="396.85039"
inkscape:window-width="2048"
inkscape:window-height="1205"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="Layer_1" /><defs
id="defs44" />
<path
fill="none"
opacity="1"
stroke="none"
d="m 791.89377,632.01366 c 1.04068,-1.03091 1.90903,-2.38956 3.14896,-3.04166 11.48425,-6.03955 22.98721,-12.04745 34.57742,-17.88034 8.52002,-4.28775 14.37558,-10.49713 14.23218,-20.46771 -0.14127,-9.82202 -6.25309,-15.51495 -14.66742,-19.5827 -61.76651,-29.85993 -123.47361,-59.84277 -185.21183,-89.76129 -10.48242,-5.07981 -20.81335,-10.5555 -31.58477,-14.93764 -19.71186,-8.01936 -39.87799,-8.40881 -59.18305,0.84148 -71.77527,34.39209 -143.33667,69.23047 -215.00567,103.84463 -8.40815,4.06091 -14.32166,9.84101 -14.39978,19.74008 -0.079,10.00498 5.52282,16.22797 14.08978,20.54743 12.3277,6.21567 24.51239,12.71484 37.34228,19.41369 7.51429,3.93381 14.42273,7.5896 21.37854,11.15274 31.68775,16.23208 63.40558,32.40548 95.07782,48.66778 14.943,7.6726 29.74964,15.6123 44.73053,23.2091 8.44464,4.2823 16.79547,9.1534 25.75525,11.9324 18.81989,5.8372 37.10712,3.9335 54.83185,-5.5578 25.51599,-13.6634 51.41541,-26.6141 77.2135,-39.7465 30.82071,-15.6894 61.7009,-31.26196 92.58359,-46.82907 1.56006,-0.78641 3.38776,-1.0419 5.09082,-1.54462 z"
id="path2" /><path
fill="#4b4d51"
opacity="1"
stroke="none"
d="m 791.89376,550.60089 c 1.04068,-1.03091 1.90903,-2.38956 3.14896,-3.04166 11.48425,-6.03955 22.98721,-12.04745 34.57742,-17.88034 8.52002,-4.28775 14.37558,-10.49713 14.23218,-20.46771 -0.14127,-9.82202 -6.25309,-15.51495 -14.66742,-19.5827 -61.76651,-29.85993 -123.47361,-59.84277 -185.21183,-89.76129 -10.48242,-5.07981 -20.81335,-10.5555 -31.58477,-14.93764 -19.71186,-8.01936 -39.87799,-8.40881 -59.18305,0.84148 -71.77522,34.39209 -143.33662,69.23047 -215.00562,103.84463 -8.4082,4.06091 -14.3217,9.84101 -14.3998,19.74008 -0.079,10.00498 5.5228,16.22797 14.0898,20.54743 12.3277,6.21567 24.5123,12.71484 37.3422,19.41369 7.5143,3.93381 14.4228,7.5896 21.3786,11.15274 31.6877,16.23212 63.4056,32.40552 95.0778,48.66782 14.943,7.67261 29.7496,15.61227 44.73052,23.20911 8.44464,4.28232 16.79547,9.15341 25.75525,11.93237 18.81989,5.83722 37.10712,3.93353 54.83185,-5.55777 25.51599,-13.66336 51.41541,-26.61407 77.2135,-39.74655 30.82071,-15.68933 61.7009,-31.26196 92.58359,-46.82907 1.56006,-0.78641 3.38776,-1.0419 5.09082,-1.54462 z"
id="path45"
style="fill:#e1e1e4;fill-opacity:1" /><path
fill="#4b4d51"
opacity="1"
stroke="none"
d="m 791.89377,469.18814 c 1.04068,-1.03091 1.90903,-2.38956 3.14896,-3.04166 11.48425,-6.03955 22.98721,-12.04745 34.57742,-17.88034 8.52002,-4.28775 14.37558,-10.49713 14.23218,-20.46771 -0.14127,-9.82202 -6.25309,-15.51495 -14.66742,-19.5827 C 767.4184,378.3558 705.7113,348.37296 643.97308,318.45444 c -10.48242,-5.07981 -20.81335,-10.5555 -31.58477,-14.93764 -19.71186,-8.01936 -39.87799,-8.40881 -59.18305,0.84148 -71.77527,34.39209 -143.33667,69.23047 -215.00567,103.84463 -8.40815,4.06091 -14.32166,9.84101 -14.39978,19.74008 -0.079,10.00498 5.52282,16.22797 14.08978,20.54743 12.3277,6.21567 24.51239,12.71484 37.34228,19.41369 7.51429,3.93381 14.42273,7.5896 21.37854,11.15274 31.68775,16.23212 63.40558,32.40552 95.07782,48.66782 14.943,7.67261 29.74964,15.61227 44.73053,23.20911 8.44464,4.28232 16.79547,9.15341 25.75525,11.93237 18.81989,5.83722 37.10712,3.93353 54.83185,-5.55777 25.51599,-13.66336 51.41541,-26.61407 77.2135,-39.74655 30.82071,-15.68933 61.7009,-31.26196 92.58359,-46.82907 1.56006,-0.78641 3.38776,-1.0419 5.09082,-1.54462 z"
id="path46"
style="fill:#b0b2b8;fill-opacity:1" /><path
fill="#4b4d51"
opacity="1"
stroke="none"
d="m 791.89377,387.77539 c 1.04068,-1.03091 1.90903,-2.38956 3.14896,-3.04166 11.48425,-6.03955 22.98721,-12.04745 34.57742,-17.88034 8.52002,-4.28775 14.37558,-10.49713 14.23218,-20.46771 -0.14127,-9.82202 -6.25309,-15.51495 -14.66742,-19.5827 -61.76651,-29.85993 -123.47361,-59.84277 -185.21183,-89.76129 -10.48242,-5.07981 -20.81335,-10.5555 -31.58477,-14.93764 -19.71186,-8.01936 -39.87799,-8.40881 -59.18305,0.84148 -71.77527,34.39209 -143.33667,69.23047 -215.00567,103.84463 -8.40815,4.06091 -14.32166,9.84101 -14.39978,19.74008 -0.079,10.00498 5.52282,16.22797 14.08978,20.54743 12.3277,6.21567 24.51239,12.71484 37.34228,19.41369 7.51429,3.93381 14.42273,7.5896 21.37854,11.15274 31.68775,16.23212 63.40558,32.40552 95.07782,48.66782 14.943,7.67261 29.74964,15.61227 44.73053,23.20911 8.44464,4.28232 16.79547,9.15341 25.75525,11.93237 18.81989,5.83722 37.10712,3.93353 54.83185,-5.55777 25.51599,-13.66336 51.41541,-26.61407 77.2135,-39.74655 30.82071,-15.68933 61.7009,-31.26196 92.58359,-46.82907 1.56006,-0.78641 3.38776,-1.0419 5.09082,-1.54462 z"
id="path47"
style="fill:#7b7e85;fill-opacity:1" /><text
xml:space="preserve"
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:160px;font-family:'URW Gothic';-inkscape-font-specification:'URW Gothic, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;writing-mode:lr-tb;direction:ltr;fill:#e1e1e4;fill-opacity:1;stroke-width:39.1"
x="154.54701"
y="550.13623"
id="text47"><tspan
id="tspan47"
x="154.54701"
y="550.13623" /><tspan
id="tspan48"
x="154.54701"
y="750.13623"
style="font-style:normal;font-variant:normal;font-weight:600;font-stretch:normal;font-size:160px;font-family:'URW Gothic';-inkscape-font-specification:'URW Gothic, Semi-Bold';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal"><tspan
style="font-style:normal;font-variant:normal;font-weight:600;font-stretch:normal;font-size:160px;font-family:'URW Gothic';-inkscape-font-specification:'URW Gothic, Semi-Bold';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;fill:#1f2024;fill-opacity:1"
id="tspan49">chrono</tspan>seal</tspan></text><path
fill="#4b4d51"
opacity="1"
stroke="none"
d="m 791.89376,306.36264 c 1.04068,-1.03091 1.90903,-2.38956 3.14896,-3.04166 11.48425,-6.03955 22.98721,-12.04745 34.57742,-17.88034 8.52002,-4.28775 14.37558,-10.49713 14.23218,-20.46771 -0.14127,-9.82202 -6.25309,-15.51495 -14.66742,-19.5827 -61.76651,-29.85993 -123.47361,-59.84277 -185.21183,-89.76129 -10.48242,-5.07981 -20.81335,-10.5555 -31.58477,-14.93764 -19.71186,-8.01936 -39.87799,-8.40881 -59.18305,0.84148 -71.77522,34.39209 -143.33662,69.23047 -215.00562,103.84463 -8.4082,4.06091 -14.3217,9.84101 -14.3998,19.74008 -0.079,10.00498 5.5228,16.22797 14.0898,20.54743 12.3277,6.21567 24.5123,12.71484 37.3422,19.41369 7.5143,3.93381 14.4228,7.5896 21.3786,11.15274 31.6877,16.23212 63.4056,32.40552 95.0778,48.66782 14.943,7.67261 29.7496,15.61227 44.73052,23.20911 8.44464,4.28232 16.79547,9.15341 25.75525,11.93237 18.81989,5.83722 37.10712,3.93353 54.83185,-5.55777 25.51599,-13.66336 51.41541,-26.61407 77.2135,-39.74655 30.82071,-15.68933 61.7009,-31.26196 92.58359,-46.82907 1.56006,-0.78641 3.38776,-1.0419 5.09082,-1.54462 z"
id="path1"
style="fill:#4a4d53;fill-opacity:1" /></svg>

After

Width:  |  Height:  |  Size: 8.1 KiB

Regular → Executable
+13 -7
View File
@@ -1,10 +1,16 @@
#!/bin/bash #!/bin/bash
set -e set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
echo "Building WASM..." echo "Building WASM..."
cd ../wasm cd "$ROOT/wasm"
wasm-pack build --target web wasm-pack build --target web --release
mv pkg ../frontend/pkg rm -rf "$ROOT/frontend/pkg"
mv pkg "$ROOT/frontend/pkg"
echo "Building server..." echo "Building server..."
cd ../server cd "$ROOT"
cargo build --release cargo build -p chronoseal-server --bin chronoseal --release
echo "Done."
echo "Done."
+46
View File
@@ -0,0 +1,46 @@
#!/bin/bash
set -euo pipefail
echo "🚀 ChronoSeal Installer"
echo "======================"
# Create system user
if ! id -u chronoseal &>/dev/null; then
sudo useradd --system --no-create-home --shell /usr/sbin/nologin chronoseal
echo "✓ Created chronoseal system user"
fi
# Build
echo "→ Building ChronoSeal..."
cd "$(dirname "$0")/.."
bash scripts/build.sh
# Install binary
sudo install -Dm755 target/release/chronoseal /usr/local/bin/chronoseal
echo "✓ Installed binary to /usr/local/bin/chronoseal"
# Install frontend assets
sudo mkdir -p /opt/chronoseal
sudo cp -r frontend /opt/chronoseal/
sudo chown -R chronoseal:chronoseal /opt/chronoseal
echo "✓ Installed frontend assets"
# Install systemd service
sudo cp chronoseal.service /etc/systemd/system/chronoseal.service
sudo systemctl daemon-reload
echo "✓ Installed systemd service"
# Enable and start
sudo systemctl enable --now chronoseal
echo "✓ ChronoSeal service started"
echo ""
echo "✅ ChronoSeal installed successfully!"
echo ""
echo "Useful commands:"
echo " chronoseal status # Check service status"
echo " chronoseal health # Health probe"
echo " sudo systemctl status chronoseal"
echo " sudo journalctl -u chronoseal -f"
echo ""
echo "To uninstall: sudo systemctl disable --now chronoseal && sudo rm /usr/local/bin/chronoseal"
Regular → Executable
+10 -3
View File
@@ -1,5 +1,12 @@
#!/bin/bash #!/bin/bash
bash build.sh set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
"$ROOT/scripts/build.sh"
echo "Release artifacts:" echo "Release artifacts:"
echo " - server/target/release/antibot-server" echo " - target/release/chronoseal"
echo " - frontend/ (including pkg/)" echo " - frontend/ (including pkg/)"
echo " - chronoseal.service"
echo " - scripts/install.sh"
+15 -2
View File
@@ -1,20 +1,33 @@
[package] [package]
name = "chronoseal-server" name = "chronoseal-server"
version = "0.2.0" version = "0.6.0"
edition = "2021" edition = "2021"
[[bin]]
name = "chronoseal"
path = "src/main.rs"
[dependencies] [dependencies]
shared = { path = "../shared" } shared = { path = "../shared" }
axum = "0.7" axum = "0.7"
clap = { version = "4", features = ["derive", "env", "wrap_help"] }
clap_complete = "4"
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
serde_yaml = "0.9"
toml = "0.8"
rusqlite = { version = "0.31", features = ["bundled"] } rusqlite = { version = "0.31", features = ["bundled"] }
r2d2 = "0.8"
r2d2_sqlite = "0.24"
thiserror = "2"
tracing = "0.1" tracing = "0.1"
tracing-subscriber = "0.3" tracing-appender = "0.2"
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
tower = "0.4" tower = "0.4"
tower-http = { version = "0.5", features = ["cors", "fs"] } tower-http = { version = "0.5", features = ["cors", "fs"] }
hex = "0.4" hex = "0.4"
base64 = "0.22" base64 = "0.22"
rand = "0.8" rand = "0.8"
ed25519-dalek = "2" ed25519-dalek = "2"
valkey = "0.0.0-alpha5"
+8 -10
View File
@@ -1,24 +1,22 @@
use std::sync::Arc;
use crate::session::AppState; use crate::session::AppState;
use std::sync::Arc;
pub async fn cleanup_loop(state: Arc<AppState>) { pub async fn cleanup_loop(state: Arc<AppState>) {
loop { loop {
tokio::time::sleep(std::time::Duration::from_secs(60)).await; tokio::time::sleep(std::time::Duration::from_secs(60)).await;
// Evict expired sessions from SQLite. // Evict expired sessions from the configured storage backend.
{ {
let db = state.db.lock().await; if let Err(err) = state.db_pool.delete_expired_sessions() {
let now = crate::storage::current_time_ms(); tracing::error!("Failed to evict expired sessions: {}", err);
let _ = db.execute( }
"DELETE FROM sessions WHERE expires_at < ?1",
rusqlite::params![now],
);
} }
// Evict stale rate-limiter entries to prevent unbounded HashMap growth. // Evict stale rate-limiter entries to prevent unbounded HashMap growth.
{ {
let window_secs = state.get_config().rate_limit_window_secs;
let mut rl = state.rate_limiter.lock().await; let mut rl = state.rate_limiter.lock().await;
rl.evict_stale(); rl.evict_stale(window_secs);
} }
} }
} }
+159
View File
@@ -0,0 +1,159 @@
use clap::{Args, Parser, Subcommand, ValueEnum};
use std::path::PathBuf;
#[derive(Debug, Clone, Copy, ValueEnum)]
pub enum OutputFormat {
Text,
Json,
Yaml,
}
#[derive(Debug, Parser)]
#[command(
name = "chronoseal",
version,
about = "Linux-native cryptographic browser attestation service",
long_about = "ChronoSeal runs as a composable Unix service. The CLI is the source of truth for daemon operation, health checks, configuration validation, metrics, and shell integration.",
after_help = "Examples:\n chronoseal\n chronoseal run --bind 127.0.0.1:3000\n chronoseal status --format json\n chronoseal health --config /etc/chronoseal/config.toml\n chronoseal config check --output yaml\n chronoseal generate keypair\n chronoseal completion bash > /etc/bash_completion.d/chronoseal\n\nConfiguration precedence:\n CLI flags > CHRONOSEAL_* environment variables > config file > built-in defaults\n\nDefault config discovery:\n /etc/chronoseal/config.toml, then $XDG_CONFIG_HOME/chronoseal/config.toml, then ~/.config/chronoseal/config.toml"
)]
pub struct Cli {
#[command(flatten)]
pub globals: GlobalArgs,
#[command(subcommand)]
pub command: Option<Command>,
}
#[derive(Debug, Clone, Args)]
pub struct GlobalArgs {
/// Path to config file.
#[arg(long, env = "CHRONOSEAL_CONFIG", global = true)]
pub config: Option<PathBuf>,
/// Output format for machine-readable commands.
#[arg(long, short = 'f', value_enum, default_value = "text", global = true)]
pub format: OutputFormat,
/// Alias for --format, provided for Unix tool compatibility.
#[arg(long, value_enum, global = true)]
pub output: Option<OutputFormat>,
/// Override the logging filter, for example info, chronoseal=debug.
#[arg(long, env = "CHRONOSEAL_LOG", global = true)]
pub log: Option<String>,
}
impl GlobalArgs {
pub fn output_format(&self) -> OutputFormat {
self.output.unwrap_or(self.format)
}
}
#[derive(Debug, Subcommand)]
pub enum Command {
/// Run the ChronoSeal daemon.
#[command(
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),
/// Report whether the configured daemon is reachable and which PID file is present.
#[command(
after_help = "Examples:\n chronoseal status\n chronoseal status --format json\n chronoseal status --pid-file /run/chronoseal.pid"
)]
Status(RuntimeArgs),
/// Perform a daemon health probe.
#[command(
after_help = "Examples:\n chronoseal health\n chronoseal health --format json\n chronoseal health --bind 127.0.0.1:3000"
)]
Health(RuntimeArgs),
/// Validate and print effective configuration.
#[command(subcommand)]
Config(ConfigCommand),
/// Generate operational material.
#[command(subcommand)]
Generate(GenerateCommand),
/// Print version and build information.
#[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"
)]
Metrics(RuntimeArgs),
/// Print service statistics from the running daemon.
#[command(after_help = "Examples:\n chronoseal stats\n chronoseal stats --format json")]
Stats(RuntimeArgs),
/// Generate shell completions.
#[command(
after_help = "Examples:\n chronoseal completion bash\n chronoseal completion zsh > ~/.zfunc/_chronoseal"
)]
Completion { shell: clap_complete::Shell },
}
#[derive(Debug, Clone, Args)]
pub struct RunArgs {
#[command(flatten)]
pub runtime: RuntimeArgs,
/// Database backend selection.
/// sqlite-in-memory is active. sqlite-in-disk and valkey are planned (TODO).
#[arg(long, env = "CHRONOSEAL_DB_TYPE", value_enum)]
pub db_type: Option<crate::config::DbType>,
/// SQLite database path. Use ':memory:' for ephemeral state.
#[arg(long, env = "CHRONOSEAL_DB_PATH")]
pub db_path: Option<PathBuf>,
/// Static frontend directory served at /.
#[arg(long, env = "CHRONOSEAL_FRONTEND_DIR")]
pub frontend_dir: Option<PathBuf>,
/// Optional structured JSON log file.
#[arg(long, env = "CHRONOSEAL_LOG_FILE")]
pub log_file: Option<PathBuf>,
/// Number of mutation rounds to execute per program.
#[arg(long, env = "CHRONOSEAL_MUTATION_ROUNDS")]
pub mutation_rounds: Option<u8>,
}
#[derive(Debug, Clone, Args)]
pub struct RuntimeArgs {
/// Socket address the daemon binds to, or that CLI probes connect to.
#[arg(long, env = "CHRONOSEAL_BIND")]
pub bind: Option<String>,
/// PID file path.
#[arg(long, env = "CHRONOSEAL_PID_FILE")]
pub pid_file: Option<PathBuf>,
}
#[derive(Debug, Subcommand)]
pub enum ConfigCommand {
/// Validate configuration and print the effective values.
#[command(
after_help = "Examples:\n chronoseal config check\n chronoseal config check --config /etc/chronoseal/config.toml\n chronoseal config check --output json"
)]
Check(RuntimeArgs),
}
#[derive(Debug, Subcommand)]
pub enum GenerateCommand {
/// Generate an Ed25519 keypair as hex-encoded JSON/YAML/text.
#[command(
after_help = "Examples:\n chronoseal generate keypair\n chronoseal generate keypair --format json"
)]
Keypair,
}
+373
View File
@@ -0,0 +1,373 @@
use crate::cli::{RunArgs, RuntimeArgs};
use clap::ValueEnum;
use serde::{Deserialize, Serialize};
use std::{
env, fs, io,
net::SocketAddr,
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,
pub log_file: Option<PathBuf>,
pub heartbeat_min_interval_ms: u64,
pub heartbeat_max_interval_ms: u64,
pub expiration_minutes: i64,
pub rate_limit_count: u32,
pub rate_limit_window_secs: u64,
pub max_timestamp_drift_ms: i64,
pub min_mouse_total_dist: f64,
pub max_mouse_avg_speed: f64,
pub min_pause_count: u32,
pub require_mouse_activity: bool,
pub gene_size: usize,
pub mutation_rounds: u8,
}
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"),
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: 10.0,
max_mouse_avg_speed: 2.0,
min_pause_count: 1,
require_mouse_activity: true,
gene_size: shared::constants::DEFAULT_GENE_SIZE,
mutation_rounds: shared::constants::DEFAULT_MUTATION_ROUNDS,
}
}
}
impl Config {
pub fn load(config_path: Option<&Path>) -> Result<Self, ConfigError> {
let mut config = Self::default();
if let Some(path) = config_path
.map(Path::to_path_buf)
.or_else(discover_config_path)
{
let raw = fs::read_to_string(&path).map_err(|source| ConfigError::Read {
path: path.clone(),
source,
})?;
config = toml::from_str(&raw).map_err(|source| ConfigError::Parse {
path: path.clone(),
source,
})?;
}
config.apply_env();
config.validate()?;
Ok(config)
}
pub fn apply_runtime_args(&mut self, args: &RuntimeArgs) {
if let Some(bind) = &args.bind {
self.bind.clone_from(bind);
}
if let Some(pid_file) = &args.pid_file {
self.pid_file = pid_file.clone();
}
}
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();
}
if let Some(frontend_dir) = &args.frontend_dir {
self.frontend_dir = frontend_dir.clone();
}
if let Some(log_file) = &args.log_file {
self.log_file = Some(log_file.clone());
}
if let Some(mutation_rounds) = args.mutation_rounds {
self.mutation_rounds = mutation_rounds;
}
}
pub fn validate(&self) -> Result<(), ConfigError> {
self.bind
.parse::<SocketAddr>()
.map_err(|source| ConfigError::InvalidBind {
bind: self.bind.clone(),
source,
})?;
if !(1..=shared::constants::MAX_GENE_SIZE).contains(&self.gene_size) {
return Err(ConfigError::InvalidGeneSize {
size: self.gene_size,
});
}
if !(1..=shared::constants::MAX_MUTATION_ROUNDS).contains(&self.mutation_rounds) {
return Err(ConfigError::InvalidMutationRounds {
rounds: self.mutation_rounds,
});
}
Ok(())
}
fn apply_env(&mut self) {
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);
}
if let Ok(value) = env::var("CHRONOSEAL_DB_PATH") {
self.db_path = PathBuf::from(value);
}
if let Ok(value) = env::var("CHRONOSEAL_FRONTEND_DIR") {
self.frontend_dir = PathBuf::from(value);
}
if let Ok(value) = env::var("CHRONOSEAL_LOG_FILE") {
self.log_file = Some(PathBuf::from(value));
}
if let Ok(value) = env::var("CHRONOSEAL_HEARTBEAT_MIN_INTERVAL_MS") {
if let Ok(val) = value.parse() {
self.heartbeat_min_interval_ms = val;
}
}
if let Ok(value) = env::var("CHRONOSEAL_HEARTBEAT_MAX_INTERVAL_MS") {
if let Ok(val) = value.parse() {
self.heartbeat_max_interval_ms = val;
}
}
if let Ok(value) = env::var("CHRONOSEAL_EXPIRATION_MINUTES") {
if let Ok(val) = value.parse() {
self.expiration_minutes = val;
}
}
if let Ok(value) = env::var("CHRONOSEAL_RATE_LIMIT_COUNT") {
if let Ok(val) = value.parse() {
self.rate_limit_count = val;
}
}
if let Ok(value) = env::var("CHRONOSEAL_RATE_LIMIT_WINDOW_SECS") {
if let Ok(val) = value.parse() {
self.rate_limit_window_secs = val;
}
}
if let Ok(value) = env::var("CHRONOSEAL_MAX_TIMESTAMP_DRIFT_MS") {
if let Ok(val) = value.parse() {
self.max_timestamp_drift_ms = val;
}
}
if let Ok(value) = env::var("CHRONOSEAL_MIN_MOUSE_TOTAL_DIST") {
if let Ok(val) = value.parse() {
self.min_mouse_total_dist = val;
}
}
if let Ok(value) = env::var("CHRONOSEAL_MAX_MOUSE_AVG_SPEED") {
if let Ok(val) = value.parse() {
self.max_mouse_avg_speed = val;
}
}
if let Ok(value) = env::var("CHRONOSEAL_MIN_PAUSE_COUNT") {
if let Ok(val) = value.parse() {
self.min_pause_count = val;
}
}
if let Ok(value) = env::var("CHRONOSEAL_REQUIRE_MOUSE_ACTIVITY") {
if let Ok(val) = value.parse() {
self.require_mouse_activity = val;
}
}
if let Ok(value) = env::var("CHRONOSEAL_GENE_SIZE") {
if let Ok(val) = value.parse() {
self.gene_size = val;
}
}
if let Ok(value) = env::var("CHRONOSEAL_MUTATION_ROUNDS") {
if let Ok(val) = value.parse() {
self.mutation_rounds = val;
}
}
}
}
#[derive(Debug)]
pub enum ConfigError {
Read {
path: PathBuf,
source: io::Error,
},
Parse {
path: PathBuf,
source: toml::de::Error,
},
InvalidBind {
bind: String,
source: std::net::AddrParseError,
},
InvalidGeneSize {
size: usize,
},
InvalidMutationRounds {
rounds: u8,
},
}
impl std::fmt::Display for ConfigError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Read { path, source } => write!(f, "failed to read {}: {source}", path.display()),
Self::Parse { path, source } => {
write!(f, "failed to parse {} as TOML: {source}", path.display())
}
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
)
}
Self::InvalidMutationRounds { rounds } => {
write!(
f,
"invalid mutation rounds {rounds}; expected 1..={}",
shared::constants::MAX_MUTATION_ROUNDS
)
}
}
}
}
impl std::error::Error for ConfigError {}
fn discover_config_path() -> Option<PathBuf> {
if let Ok(path) = env::var("CHRONOSEAL_CONFIG") {
let p = PathBuf::from(path);
if p.is_file() {
return Some(p);
}
}
user_config_candidates()
.into_iter()
.find(|candidate| candidate.is_file())
}
pub fn user_config_candidates() -> Vec<PathBuf> {
let mut candidates = vec![PathBuf::from("/etc/chronoseal/config.toml")];
if let Ok(xdg) = env::var("XDG_CONFIG_HOME") {
candidates.push(PathBuf::from(xdg).join("chronoseal/config.toml"));
} else if let Ok(home) = env::var("HOME") {
candidates.push(PathBuf::from(home).join(".config/chronoseal/config.toml"));
}
candidates
}
fn default_state_dir() -> PathBuf {
if let Ok(value) = env::var("CHRONOSEAL_STATE_DIR") {
return PathBuf::from(value);
}
if let Ok(value) = env::var("XDG_STATE_HOME") {
return PathBuf::from(value).join("chronoseal");
}
if let Ok(home) = env::var("HOME") {
return PathBuf::from(home).join(".local/state/chronoseal");
}
PathBuf::from("/var/lib/chronoseal")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_db_type_is_sqlite_in_memory() {
let cfg = Config::default();
assert_eq!(cfg.db_type, DbType::SqliteInMemory);
}
#[test]
fn test_apply_run_args_overrides_db_type() {
let mut cfg = Config::default();
let args = crate::cli::RunArgs {
runtime: crate::cli::RuntimeArgs {
bind: None,
pid_file: None,
},
db_type: Some(DbType::SqliteInDisk),
db_path: None,
frontend_dir: None,
log_file: None,
mutation_rounds: 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);
}
}
+20 -16
View File
@@ -2,28 +2,32 @@ use ed25519_dalek::{Signature, VerifyingKey};
use shared::protocol::HeartbeatRequest; use shared::protocol::HeartbeatRequest;
use std::collections::BTreeMap; use std::collections::BTreeMap;
pub fn canonical_signing_message(
req: &HeartbeatRequest,
) -> Result<String, Box<dyn std::error::Error>> {
// Build canonical JSON with BTreeMap so keys are sorted alphabetically,
// matching the JS client's JSON.stringify(obj, Object.keys(obj).sort()).
let mut payload: BTreeMap<&str, serde_json::Value> = BTreeMap::new();
payload.insert("entropyData", serde_json::to_value(&req.entropy_data)?);
payload.insert("fingerprint", serde_json::to_value(&req.fingerprint)?);
payload.insert("geneCommitment", serde_json::json!(req.gene_commitment));
payload.insert("mutationStep", serde_json::json!(req.mutation_step));
payload.insert("prevHash", serde_json::json!(req.prev_hash));
payload.insert("sessionId", serde_json::json!(req.session_id));
payload.insert("stackState", serde_json::to_value(&req.stack_state)?);
payload.insert("timestamp", serde_json::json!(req.timestamp));
Ok(serde_json::to_string(&payload)?)
}
pub fn verify_signature( pub fn verify_signature(
pub_key_bytes: &[u8], pub_key_bytes: &[u8],
req: &HeartbeatRequest, req: &HeartbeatRequest,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
let pk = VerifyingKey::from_bytes( let pk = VerifyingKey::from_bytes(&pub_key_bytes.try_into().map_err(|_| "invalid pubkey")?)?;
&pub_key_bytes.try_into().map_err(|_| "invalid pubkey")?,
)?;
let sig_bytes = hex::decode(&req.signature)?; let sig_bytes = hex::decode(&req.signature)?;
let sig = Signature::from_slice(&sig_bytes)?; let sig = Signature::from_slice(&sig_bytes)?;
let message = canonical_signing_message(req)?;
// Build canonical JSON with BTreeMap so keys are sorted alphabetically,
// matching the JS client's JSON.stringify(obj, Object.keys(obj).sort()).
// Sorted order: entropyData, fingerprint, prevHash, sessionId, stackState, timestamp
let mut payload: BTreeMap<&str, serde_json::Value> = BTreeMap::new();
payload.insert("entropyData", serde_json::to_value(&req.entropy_data)?);
payload.insert("fingerprint", serde_json::to_value(&req.fingerprint)?);
payload.insert("prevHash", serde_json::json!(req.prev_hash));
payload.insert("sessionId", serde_json::json!(req.session_id));
payload.insert("stackState", serde_json::to_value(&req.stack_state)?);
payload.insert("timestamp", serde_json::json!(req.timestamp));
let message = serde_json::to_string(&payload)?;
pk.verify_strict(message.as_bytes(), &sig)?; pk.verify_strict(message.as_bytes(), &sig)?;
Ok(()) Ok(())
} }
+89
View File
@@ -0,0 +1,89 @@
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use serde_json::json;
use thiserror::Error;
#[derive(Error, Debug)]
pub enum SessionError {
#[error("Hex decoding error: {0}")]
Hex(#[from] hex::FromHexError),
#[error("Database error: {0}")]
Database(#[from] rusqlite::Error),
#[error("Storage error: {0}")]
Storage(String),
#[error("R2D2 pool error: {0}")]
Pool(#[from] r2d2::Error),
#[error("Invalid public key length")]
InvalidPublicKeyLength,
#[error("Invalid gene configuration: {0}")]
InvalidGeneConfiguration(String),
}
impl IntoResponse for SessionError {
fn into_response(self) -> Response {
let (status, error_message) = match self {
SessionError::InvalidPublicKeyLength => (StatusCode::BAD_REQUEST, self.to_string()),
_ => (
StatusCode::INTERNAL_SERVER_ERROR,
"Internal server error".to_string(),
),
};
let body = Json(json!({
"error": error_message
}));
(status, body).into_response()
}
}
#[derive(Error, Debug)]
pub enum VerificationError {
#[error("Session not found")]
SessionNotFound,
#[error("Database error: {0}")]
Database(#[from] rusqlite::Error),
#[error("Storage error: {0}")]
Storage(String),
#[error("Hex decoding error: {0}")]
Hex(#[from] hex::FromHexError),
#[error("Signature verification error: {0}")]
Signature(String),
#[error("Session has expired")]
Expired,
#[error("Chain is broken")]
ChainBroken,
#[error("Timestamp drift exceeded threshold")]
TimestampDrift,
#[error("Trust criteria failed: {0}")]
TrustFailed(String),
#[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),
}
+10 -4
View File
@@ -2,9 +2,15 @@ use shared::protocol::Fingerprint;
pub fn validate(fp: &Fingerprint) -> Result<(), Box<dyn std::error::Error>> { pub fn validate(fp: &Fingerprint) -> Result<(), Box<dyn std::error::Error>> {
let ar: f64 = fp.aspect_ratio.parse().map_err(|_| "ar")?; let ar: f64 = fp.aspect_ratio.parse().map_err(|_| "ar")?;
if ar < 0.5 || ar > 3.0 { return Err("aspect ratio".into()); } if !(0.5..=3.0).contains(&ar) {
return Err("aspect ratio".into());
}
let dpr: f64 = fp.device_pixel_ratio.parse().map_err(|_| "dpr")?; let dpr: f64 = fp.device_pixel_ratio.parse().map_err(|_| "dpr")?;
if dpr <= 0.0 || dpr > 5.0 { return Err("dpr".into()); } if dpr <= 0.0 || dpr > 5.0 {
if fp.hardware_concurrency == 0 { return Err("hw".into()); } return Err("dpr".into());
}
if fp.hardware_concurrency == 0 {
return Err("hw".into());
}
Ok(()) Ok(())
} }
+132 -29
View File
@@ -1,47 +1,150 @@
mod cleanup; mod cleanup;
mod cli;
mod config;
mod crypto; mod crypto;
mod errors;
mod fingerprint; mod fingerprint;
mod middleware; mod middleware;
mod output;
mod ratelimit; mod ratelimit;
mod routes; mod routes;
mod runtime;
mod session; mod session;
mod storage; mod storage;
mod trust; mod trust;
mod vm; mod vm;
use axum::Router; use clap::{CommandFactory, Parser};
use std::sync::Arc; use cli::{Cli, Command, ConfigCommand, GenerateCommand};
use tokio::sync::Mutex; use config::Config;
use tracing::info; use std::path::PathBuf;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
use session::AppState;
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
tracing_subscriber::fmt::init(); if let Err(err) = try_main().await {
eprintln!("chronoseal: {err}");
std::process::exit(1);
}
}
let conn = storage::init_db().expect("DB init"); async fn try_main() -> Result<(), Box<dyn std::error::Error>> {
let state = Arc::new(AppState { let cli = Cli::parse();
db: Mutex::new(conn), if let Some(config_path) = cli.globals.config.as_deref() {
rate_limiter: Mutex::new(ratelimit::RateLimiter::new( std::env::set_var("CHRONOSEAL_CONFIG", config_path);
shared::constants::RATE_LIMIT_COUNT, }
shared::constants::RATE_LIMIT_WINDOW_SECS, let log_filter = cli.globals.log.as_deref().unwrap_or("info");
)), let log_file = log_file_for_command(&cli);
}); let _log_guard = init_logging(log_filter, log_file)?;
// Periodic cleanup match &cli.command {
let bg_state = state.clone(); None | Some(Command::Run(_)) => {
tokio::spawn(async move { cleanup::cleanup_loop(bg_state).await }); let mut config = Config::load(cli.globals.config.as_deref())?;
if let Some(Command::Run(args)) = &cli.command {
config.apply_run_args(args);
config.validate()?;
}
runtime::run_daemon(config).await?;
}
Some(Command::Status(args)) => {
let mut config = Config::load(cli.globals.config.as_deref())?;
config.apply_runtime_args(args);
config.validate()?;
output::print(cli.globals.output_format(), &runtime::probe_status(&config))?;
}
Some(Command::Health(args)) => {
let mut config = Config::load(cli.globals.config.as_deref())?;
config.apply_runtime_args(args);
config.validate()?;
let report = runtime::probe_health(&config);
let healthy = report.status == "healthy";
output::print(cli.globals.output_format(), &report)?;
if !healthy {
std::process::exit(2);
}
}
Some(Command::Config(ConfigCommand::Check(args))) => {
let mut config = Config::load(cli.globals.config.as_deref())?;
config.apply_runtime_args(args);
config.validate()?;
output::print(cli.globals.output_format(), &config)?;
}
Some(Command::Generate(GenerateCommand::Keypair)) => {
output::print(cli.globals.output_format(), &runtime::generate_keypair())?;
}
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);
config.validate()?;
print!("{}", runtime::fetch_metrics(&config)?);
}
Some(Command::Stats(args)) => {
let mut config = Config::load(cli.globals.config.as_deref())?;
config.apply_runtime_args(args);
config.validate()?;
output::print(cli.globals.output_format(), &runtime::fetch_stats(&config)?)?;
}
Some(Command::Completion { shell }) => {
let mut command = Cli::command();
let name = command.get_name().to_string();
clap_complete::generate(*shell, &mut command, name, &mut std::io::stdout());
}
}
Ok(())
}
let app = Router::new() fn log_file_for_command(cli: &Cli) -> Option<PathBuf> {
.route("/init", axum::routing::post(routes::init::handler)) match &cli.command {
.route("/hb", axum::routing::post(routes::heartbeat::handler)) None => Config::load(cli.globals.config.as_deref())
.nest_service("/", tower_http::services::ServeDir::new("../frontend")) .ok()
.layer(tower_http::cors::CorsLayer::permissive()) .and_then(|config| config.log_file),
.layer(axum::middleware::from_fn(middleware::log_request)) Some(Command::Run(args)) => {
.with_state(state); let mut config = Config::load(cli.globals.config.as_deref()).ok()?;
config.apply_run_args(args);
config.log_file
}
_ => None,
}
}
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap(); fn init_logging(
info!("Server running on :3000"); filter: &str,
axum::serve(listener, app).await.unwrap(); log_file: Option<PathBuf>,
} ) -> Result<Option<tracing_appender::non_blocking::WorkerGuard>, Box<dyn std::error::Error>> {
let env_filter = EnvFilter::try_new(filter)?;
if let Some(path) = log_file {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let directory = path.parent().unwrap_or_else(|| std::path::Path::new("."));
let file_name = path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("chronoseal.jsonl");
let appender = tracing_appender::rolling::never(directory, file_name);
let (writer, guard) = tracing_appender::non_blocking(appender);
tracing_subscriber::registry()
.with(env_filter)
.with(tracing_subscriber::fmt::layer().with_target(false))
.with(
tracing_subscriber::fmt::layer()
.json()
.with_target(false)
.with_writer(writer),
)
.init();
Ok(Some(guard))
} else {
tracing_subscriber::fmt()
.with_env_filter(env_filter)
.with_target(false)
.init();
Ok(None)
}
}
+1 -1
View File
@@ -8,4 +8,4 @@ pub async fn log_request(req: Request, next: Next) -> Response {
let response = next.run(req).await; let response = next.run(req).await;
tracing::info!("{} {} -> {}", method, uri, response.status()); tracing::info!("{} {} -> {}", method, uri, response.status());
response response
} }
+18
View File
@@ -0,0 +1,18 @@
use crate::cli::OutputFormat;
use serde::Serialize;
pub fn print<T>(format: OutputFormat, value: &T) -> Result<(), Box<dyn std::error::Error>>
where
T: Serialize + TextOutput,
{
match format {
OutputFormat::Text => println!("{}", value.to_text()),
OutputFormat::Json => println!("{}", serde_json::to_string_pretty(value)?),
OutputFormat::Yaml => print!("{}", serde_yaml::to_string(value)?),
}
Ok(())
}
pub trait TextOutput {
fn to_text(&self) -> String;
}
+45 -11
View File
@@ -3,22 +3,22 @@ use std::time::Instant;
pub struct RateLimiter { pub struct RateLimiter {
buckets: HashMap<String, (u32, Instant)>, buckets: HashMap<String, (u32, Instant)>,
limit: u32,
window_secs: u64,
} }
impl RateLimiter { impl RateLimiter {
pub fn new(limit: u32, window_secs: u64) -> Self { pub fn new() -> Self {
Self { buckets: HashMap::new(), limit, window_secs } Self {
buckets: HashMap::new(),
}
} }
pub fn check(&mut self, key: &str) -> bool { pub fn check(&mut self, key: &str, limit: u32, window_secs: u64) -> bool {
let now = Instant::now(); let now = Instant::now();
let entry = self.buckets.entry(key.to_string()).or_insert((0, now)); let entry = self.buckets.entry(key.to_string()).or_insert((0, now));
if now.duration_since(entry.1).as_secs() >= self.window_secs { if now.duration_since(entry.1).as_secs() >= window_secs {
*entry = (1, now); *entry = (1, now);
true true
} else if entry.0 >= self.limit { } else if entry.0 >= limit {
false false
} else { } else {
entry.0 += 1; entry.0 += 1;
@@ -28,10 +28,44 @@ impl RateLimiter {
/// Remove entries whose rate-limit window has fully elapsed. /// Remove entries whose rate-limit window has fully elapsed.
/// Call this periodically (e.g. from the cleanup loop) to bound memory usage. /// Call this periodically (e.g. from the cleanup loop) to bound memory usage.
pub fn evict_stale(&mut self) { pub fn evict_stale(&mut self, window_secs: u64) {
let window = self.window_secs;
let now = Instant::now(); let now = Instant::now();
self.buckets self.buckets
.retain(|_, (_, ts)| now.duration_since(*ts).as_secs() < window); .retain(|_, (_, ts)| now.duration_since(*ts).as_secs() < window_secs);
} }
} }
#[cfg(test)]
mod tests {
use super::*;
use std::thread;
use std::time::Duration;
#[test]
fn test_rate_limiter() {
let mut rl = RateLimiter::new();
// Limit of 2 requests per 1 second window
assert!(rl.check("user1", 2, 1));
assert!(rl.check("user1", 2, 1));
assert!(!rl.check("user1", 2, 1)); // 3rd fails
assert!(rl.check("user2", 2, 1)); // different key succeeds
thread::sleep(Duration::from_millis(1100));
assert!(rl.check("user1", 2, 1)); // succeeds after time window
}
#[test]
fn test_rate_limiter_eviction() {
let mut rl = RateLimiter::new();
assert!(rl.check("user1", 1, 1));
assert_eq!(rl.buckets.len(), 1);
rl.evict_stale(1);
assert_eq!(rl.buckets.len(), 1); // not stale yet
thread::sleep(Duration::from_millis(1100));
rl.evict_stale(1);
assert_eq!(rl.buckets.len(), 0); // evicted
}
}
+186 -11
View File
@@ -1,7 +1,7 @@
use axum::{extract::State, http::StatusCode, Json};
use std::sync::Arc;
use shared::protocol::{HeartbeatRequest, HeartbeatResponse};
use crate::session::AppState; use crate::session::AppState;
use axum::{extract::State, http::StatusCode, Json};
use shared::protocol::{HeartbeatRequest, HeartbeatResponse};
use std::sync::Arc;
pub async fn handler( pub async fn handler(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
@@ -9,22 +9,197 @@ pub async fn handler(
) -> (StatusCode, Json<HeartbeatResponse>) { ) -> (StatusCode, Json<HeartbeatResponse>) {
// Rate limiting // Rate limiting
{ {
let (limit, window_secs) = {
let cfg = state.get_config();
(cfg.rate_limit_count, cfg.rate_limit_window_secs)
};
let mut rl = state.rate_limiter.lock().await; let mut rl = state.rate_limiter.lock().await;
if !rl.check(&payload.session_id) { if !rl.check(&payload.session_id, limit, window_secs) {
tracing::debug!("Rate limit hit: {}", payload.session_id); tracing::debug!("Rate limit hit: {}", payload.session_id);
return (StatusCode::OK, Json(HeartbeatResponse { status: "ok".into(), next_salt: None })); return (
StatusCode::OK,
Json(HeartbeatResponse {
status: "ok".into(),
next_salt: None,
next_mutation_step: None,
next_mutation_order_b64: None,
}),
);
} }
} }
let db = state.db.lock().await; let config = state.get_config();
match crate::session::verify_heartbeat(&db, &payload) { match crate::session::verify_heartbeat(&state.db_pool, &config, &payload) {
Ok(next_salt) => ( Ok(result) => (
StatusCode::OK, StatusCode::OK,
Json(HeartbeatResponse { status: "ok".into(), next_salt: Some(next_salt) }), Json(HeartbeatResponse {
status: "ok".into(),
next_salt: Some(result.next_salt_hex),
next_mutation_step: Some(result.next_mutation_step),
next_mutation_order_b64: Some(result.next_mutation_order_b64),
}),
), ),
Err(e) => { Err(e) => {
tracing::warn!("Heartbeat failed for {}: {}", payload.session_id, e); tracing::warn!("Heartbeat failed for {}: {}", payload.session_id, e);
(StatusCode::OK, Json(HeartbeatResponse { status: "ok".into(), next_salt: None })) (
StatusCode::OK,
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_with_context(
&candidate,
&init.session_id,
mutation_step,
),
signature: String::new(),
};
sign_request(sk, &mut req);
req
}
async fn setup_state_and_session(
config: crate::config::Config,
) -> (Arc<AppState>, InitResponse, SigningKey) {
let pool = crate::storage::init_pool(Path::new(":memory:")).unwrap();
let state = Arc::new(AppState {
db_pool: pool.clone(),
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 init = crate::session::create_session(&pool, &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());
}
}
+9 -12
View File
@@ -1,17 +1,14 @@
use axum::{extract::State, http::StatusCode, Json}; use crate::errors::SessionError;
use std::sync::Arc;
use shared::protocol::{InitRequest, InitResponse};
use crate::session::AppState; use crate::session::AppState;
use axum::{extract::State, Json};
use shared::protocol::{InitRequest, InitResponse};
use std::sync::Arc;
pub async fn handler( pub async fn handler(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
Json(payload): Json<InitRequest>, Json(payload): Json<InitRequest>,
) -> Result<Json<InitResponse>, (StatusCode, String)> { ) -> Result<Json<InitResponse>, SessionError> {
let db = state.db.lock().await; let config = state.get_config();
crate::session::create_session(&db, &payload.public_key) let resp = crate::session::create_session(&state.db_pool, &config, &payload.public_key)?;
.map(Json) Ok(Json(resp))
.map_err(|e| { }
tracing::error!("Init error: {}", e);
(StatusCode::INTERNAL_SERVER_ERROR, "Internal".into())
})
}
+1 -1
View File
@@ -1,2 +1,2 @@
pub mod init;
pub mod heartbeat; pub mod heartbeat;
pub mod init;
+467
View File
@@ -0,0 +1,467 @@
use crate::{
config::Config,
output::TextOutput,
ratelimit::RateLimiter,
routes, session,
storage::{self, StoreStats},
};
use axum::{http::StatusCode, response::IntoResponse, routing::get, Json, Router};
use serde::Serialize;
use std::{
fs,
io::{Read, Write},
net::{SocketAddr, TcpStream},
path::Path,
sync::Arc,
time::Duration,
};
use tokio::sync::{Mutex, Notify};
use tracing::{error, info, warn};
#[derive(Debug, Serialize)]
pub struct HealthReport {
pub status: &'static str,
pub bind: String,
}
impl TextOutput for HealthReport {
fn to_text(&self) -> String {
format!("{}\nbind={}", self.status, self.bind)
}
}
#[derive(Debug, Serialize)]
pub struct StatusReport {
pub running: bool,
pub healthy: bool,
pub bind: String,
pub pid_file: String,
pub pid: Option<u32>,
}
impl TextOutput for StatusReport {
fn to_text(&self) -> String {
let pid = self
.pid
.map_or_else(|| "unknown".to_string(), |pid| pid.to_string());
format!(
"running={}\nhealthy={}\nbind={}\npid_file={}\npid={}",
self.running, self.healthy, self.bind, self.pid_file, pid
)
}
}
#[derive(Debug, Serialize)]
pub struct VersionReport {
pub name: &'static str,
pub version: &'static str,
pub target: &'static str,
}
impl TextOutput for VersionReport {
fn to_text(&self) -> String {
format!("{} {}", self.name, self.version)
}
}
#[derive(Debug, Serialize)]
pub struct KeypairReport {
pub algorithm: &'static str,
pub public_key_hex: String,
pub private_key_hex: String,
}
impl TextOutput for KeypairReport {
fn to_text(&self) -> String {
format!(
"algorithm={}\npublic_key_hex={}\nprivate_key_hex={}",
self.algorithm, self.public_key_hex, self.private_key_hex
)
}
}
#[derive(Debug, Serialize)]
pub struct DbTypeEntry {
pub name: &'static str,
pub implemented: bool,
pub notes: &'static str,
}
#[derive(Debug, Serialize)]
pub struct DbTypeReport {
pub default: &'static str,
pub backends: Vec<DbTypeEntry>,
}
impl TextOutput for DbTypeReport {
fn to_text(&self) -> String {
let mut out = format!("default={}\n", self.default);
for backend in &self.backends {
let status = if backend.implemented {
"implemented"
} else {
"todo"
};
out.push_str(&format!(
"db_type={} status={} notes={}\n",
backend.name, status, backend.notes
));
}
out
}
}
impl TextOutput for Config {
fn to_text(&self) -> String {
format!(
"bind={}\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()),
self.gene_size
)
}
}
impl TextOutput for StoreStats {
fn to_text(&self) -> String {
format!(
"sessions={}\nexpired_sessions={}\nmax_chain_length={}",
self.sessions, self.expired_sessions, self.max_chain_length
)
}
}
pub async fn run_daemon(config: Config) -> Result<(), Box<dyn std::error::Error>> {
install_pid_file(&config.pid_file)?;
let db_pool = init_db_pool(&config)?;
let state = Arc::new(session::AppState {
db_pool,
rate_limiter: Mutex::new(RateLimiter::new()),
config: std::sync::RwLock::new(config.clone()),
});
let bg_state = state.clone();
tokio::spawn(async move { crate::cleanup::cleanup_loop(bg_state).await });
let app = Router::new()
.route("/init", axum::routing::post(routes::init::handler))
.route("/hb", axum::routing::post(routes::heartbeat::handler))
.route("/health", get(health_handler))
.route("/metrics", get(metrics_handler))
.route("/stats", get(stats_handler))
.nest_service(
"/",
tower_http::services::ServeDir::new(&config.frontend_dir),
)
.layer(tower_http::cors::CorsLayer::permissive())
.layer(axum::middleware::from_fn(crate::middleware::log_request))
.with_state(state.clone());
let addr: SocketAddr = config.bind.parse()?;
let listener = tokio::net::TcpListener::bind(addr).await?;
info!(bind = %config.bind, "chronoseal daemon started");
let shutdown = signal_task(state.clone());
let result = axum::serve(listener, app)
.with_graceful_shutdown(shutdown)
.await;
remove_pid_file(&config.pid_file);
result?;
info!("chronoseal daemon stopped");
Ok(())
}
pub fn db_type_report() -> DbTypeReport {
DbTypeReport {
default: crate::config::DbType::SqliteInMemory.as_str(),
backends: vec![
DbTypeEntry {
name: crate::config::DbType::SqliteInMemory.as_str(),
implemented: true,
notes: "default runtime backend",
},
DbTypeEntry {
name: crate::config::DbType::SqliteInDisk.as_str(),
implemented: true,
notes: "persistent SQLite backend (uses --db-path)",
},
DbTypeEntry {
name: crate::config::DbType::Valkey.as_str(),
implemented: true,
notes: "compatibility mode: falls back to sqlite-in-memory",
},
],
}
}
fn init_db_pool(config: &Config) -> Result<storage::DbPool, Box<dyn std::error::Error>> {
storage::DbPool::init(config)
}
pub fn probe_health(config: &Config) -> HealthReport {
if http_get(&config.bind, "/health").is_ok() {
HealthReport {
status: "healthy",
bind: config.bind.clone(),
}
} else {
HealthReport {
status: "unreachable",
bind: config.bind.clone(),
}
}
}
pub fn probe_status(config: &Config) -> StatusReport {
let pid = read_pid(&config.pid_file);
let healthy = http_get(&config.bind, "/health").is_ok();
StatusReport {
running: pid.is_some() || healthy,
healthy,
bind: config.bind.clone(),
pid_file: config.pid_file.display().to_string(),
pid,
}
}
pub fn fetch_metrics(config: &Config) -> Result<String, Box<dyn std::error::Error>> {
http_get(&config.bind, "/metrics")
}
pub fn fetch_stats(config: &Config) -> Result<StoreStats, Box<dyn std::error::Error>> {
let body = http_get(&config.bind, "/stats")?;
Ok(serde_json::from_str(&body)?)
}
pub fn generate_keypair() -> KeypairReport {
let private_key = rand::random::<[u8; 32]>();
let signing_key = ed25519_dalek::SigningKey::from_bytes(&private_key);
let verifying_key = signing_key.verifying_key();
KeypairReport {
algorithm: "ed25519",
public_key_hex: hex::encode(verifying_key.to_bytes()),
private_key_hex: hex::encode(private_key),
}
}
pub fn version() -> VersionReport {
VersionReport {
name: "chronoseal",
version: env!("CARGO_PKG_VERSION"),
target: std::env::consts::ARCH,
}
}
async fn health_handler() -> impl IntoResponse {
(
StatusCode::OK,
Json(serde_json::json!({ "status": "healthy" })),
)
}
async fn stats_handler(
axum::extract::State(state): axum::extract::State<Arc<session::AppState>>,
) -> Result<Json<StoreStats>, (StatusCode, String)> {
state
.db_pool
.stats()
.map(Json)
.map_err(|err| (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()))
}
async fn metrics_handler(
axum::extract::State(state): axum::extract::State<Arc<session::AppState>>,
) -> Result<String, (StatusCode, String)> {
state
.db_pool
.stats()
.map(|stats| {
format!(
"# HELP chronoseal_sessions Active ChronoSeal sessions\n# TYPE chronoseal_sessions gauge\nchronoseal_sessions {}\n# HELP chronoseal_expired_sessions Expired sessions not yet removed\n# TYPE chronoseal_expired_sessions gauge\nchronoseal_expired_sessions {}\n# HELP chronoseal_max_chain_length Maximum heartbeat chain length\n# TYPE chronoseal_max_chain_length gauge\nchronoseal_max_chain_length {}\n",
stats.sessions, stats.expired_sessions, stats.max_chain_length
)
})
.map_err(|err| (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()))
}
async fn signal_task(state: Arc<session::AppState>) {
let shutdown = Arc::new(Notify::new());
#[cfg(unix)]
{
use tokio::signal::unix::{signal, SignalKind};
let shutdown_term = shutdown.clone();
tokio::spawn(async move {
let mut sigterm = signal(SignalKind::terminate()).expect("install SIGTERM handler");
sigterm.recv().await;
info!("received SIGTERM; shutting down gracefully");
shutdown_term.notify_one();
});
let shutdown_int = shutdown.clone();
tokio::spawn(async move {
if tokio::signal::ctrl_c().await.is_ok() {
info!("received interrupt; shutting down gracefully");
shutdown_int.notify_one();
}
});
let state_for_hup = state.clone();
tokio::spawn(async move {
let mut sighup = signal(SignalKind::hangup()).expect("install SIGHUP handler");
while sighup.recv().await.is_some() {
match Config::load(None) {
Ok(reloaded) => {
info!(
bind = %reloaded.bind,
db_path = %reloaded.db_path.display(),
"received SIGHUP; configuration reloaded"
);
if let Ok(mut config_write) = state_for_hup.config.write() {
*config_write = reloaded;
}
}
Err(err) => warn!(error = %err, "received SIGHUP; configuration reload failed"),
}
}
});
tokio::spawn(async move {
let mut sigusr1 = signal(SignalKind::user_defined1()).expect("install SIGUSR1 handler");
while sigusr1.recv().await.is_some() {
info!("received SIGUSR1; stats are available via chronoseal stats or /stats");
}
});
}
#[cfg(not(unix))]
{
if tokio::signal::ctrl_c().await.is_ok() {
shutdown.notify_one();
}
}
shutdown.notified().await;
}
fn install_pid_file(path: &Path) -> Result<(), Box<dyn std::error::Error>> {
if let Some(parent) = path.parent() {
if let Err(err) = fs::create_dir_all(parent) {
warn!(path = %parent.display(), error = %err, "could not create PID directory");
}
}
match fs::write(path, std::process::id().to_string()) {
Ok(()) => Ok(()),
Err(err) => {
warn!(path = %path.display(), error = %err, "could not write PID file");
Ok(())
}
}
}
fn remove_pid_file(path: &Path) {
if let Err(err) = fs::remove_file(path) {
if err.kind() != std::io::ErrorKind::NotFound {
error!(path = %path.display(), error = %err, "could not remove PID file");
}
}
}
fn read_pid(path: &Path) -> Option<u32> {
fs::read_to_string(path).ok()?.trim().parse().ok()
}
fn http_get(bind: &str, path: &str) -> Result<String, Box<dyn std::error::Error>> {
let mut stream = TcpStream::connect_timeout(&bind.parse()?, Duration::from_secs(2))?;
stream.set_read_timeout(Some(Duration::from_secs(2)))?;
stream.write_all(
format!("GET {path} HTTP/1.1\r\nHost: chronoseal\r\nConnection: close\r\n\r\n").as_bytes(),
)?;
let mut response = String::new();
stream.read_to_string(&mut response)?;
let (_, body) = response
.split_once("\r\n\r\n")
.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,
mutation_rounds: shared::constants::DEFAULT_MUTATION_ROUNDS,
}
}
#[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 stats = pool.stats().unwrap();
assert_eq!(stats.sessions, 0);
assert_eq!(stats.expired_sessions, 0);
assert_eq!(stats.max_chain_length, 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 stats = pool.stats().unwrap();
assert_eq!(stats.sessions, 0);
assert_eq!(stats.expired_sessions, 0);
assert_eq!(stats.max_chain_length, 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 stats = pool.stats().unwrap();
assert_eq!(stats.sessions, 0);
assert_eq!(stats.expired_sessions, 0);
assert_eq!(stats.max_chain_length, 0);
}
}
+525 -50
View File
@@ -1,98 +1,573 @@
pub struct AppState { pub struct AppState {
pub db: tokio::sync::Mutex<rusqlite::Connection>, pub db_pool: crate::storage::DbPool,
pub rate_limiter: tokio::sync::Mutex<crate::ratelimit::RateLimiter>, pub rate_limiter: tokio::sync::Mutex<crate::ratelimit::RateLimiter>,
pub config: std::sync::RwLock<crate::config::Config>,
} }
use rusqlite::params; impl AppState {
use shared::protocol::{HeartbeatRequest, InitResponse}; pub fn get_config(&self) -> crate::config::Config {
use crate::{crypto, trust, fingerprint, vm, storage}; if let Ok(cfg) = self.config.read() {
cfg.clone()
} else {
crate::config::Config::default()
}
}
}
use crate::{crypto, fingerprint, storage, trust, vm};
use shared::{
gene::{self, GeneState},
protocol::{HeartbeatRequest, InitResponse},
vm_extensions,
};
#[derive(Debug, Clone)]
pub struct HeartbeatVerificationResult {
pub next_salt_hex: String,
pub next_mutation_step: u64,
pub next_mutation_order_b64: String,
}
pub fn create_session( pub fn create_session(
conn: &rusqlite::Connection, db: &storage::DbPool,
config: &crate::config::Config,
pub_key_hex: &str, pub_key_hex: &str,
) -> Result<InitResponse, Box<dyn std::error::Error>> { ) -> Result<InitResponse, crate::errors::SessionError> {
let pub_key = hex::decode(pub_key_hex)?; let pub_key = hex::decode(pub_key_hex)?;
if pub_key.len() != shared::constants::SESSION_ID_LEN { if pub_key.len() != shared::constants::SESSION_ID_LEN {
return Err("invalid pubkey len".into()); return Err(crate::errors::SessionError::InvalidPublicKeyLength);
} }
let gene_state = gene::new_state(config.gene_size)
.map_err(|err| crate::errors::SessionError::InvalidGeneConfiguration(err.to_string()))?;
let environment_blob = gene::encode_environment(&gene_state.environment)
.map_err(|err| crate::errors::SessionError::InvalidGeneConfiguration(err.to_string()))?;
let session_id = hex::encode(rand::random::<[u8; shared::constants::SESSION_ID_LEN]>()); let session_id = hex::encode(rand::random::<[u8; shared::constants::SESSION_ID_LEN]>());
let salt = rand::random::<[u8; shared::constants::SALT_LEN]>(); let salt = rand::random::<[u8; shared::constants::SALT_LEN]>();
let now = storage::current_time_ms(); let now = storage::current_time_ms();
let expires_at = now + (shared::constants::EXPIRATION_MINUTES as u64) * 60 * 1000; let expires_at = now + (config.expiration_minutes as u64) * 60 * 1000;
let initial_hash = shared::hashing::initial_hash(&session_id, &pub_key, &salt); let initial_hash = shared::hashing::initial_hash(&session_id, &pub_key, &salt);
conn.execute(
"INSERT INTO sessions (session_id, public_key, salt, last_hash, created_at, last_seen, expires_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
params![session_id, pub_key, salt.to_vec(), initial_hash, now, now, expires_at],
)?;
let opcodes = vm::generate_random_program(8..=16); let opcodes = vm::generate_random_program(8..=16);
let opcodes_b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &opcodes); let opcodes_b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &opcodes);
let initial_mutation = vm_extensions::generate_order(1, config.gene_size);
let initial_mutation_b64 = vm_extensions::encode_order_b64(&initial_mutation);
let record = storage::SessionRecord {
session_id: session_id.clone(),
public_key: pub_key,
salt: salt.to_vec(),
last_hash: initial_hash.clone(),
chain_length: 1,
created_at: now,
last_seen: now,
expires_at,
gene: gene_state.gene,
environment: environment_blob,
pending_mutation: initial_mutation.program,
pending_mutation_step: initial_mutation.step,
};
db.insert_session(&record)
.map_err(|err| crate::errors::SessionError::Storage(err.to_string()))?;
Ok(InitResponse { Ok(InitResponse {
session_id, session_id,
salt: hex::encode(salt), salt: hex::encode(salt),
opcodes_b64, opcodes_b64,
initial_hash: hex::encode(&initial_hash), initial_hash: hex::encode(&initial_hash),
expires_at, 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,
}) })
} }
pub fn verify_heartbeat( pub fn verify_heartbeat(
conn: &rusqlite::Connection, db: &storage::DbPool,
config: &crate::config::Config,
req: &HeartbeatRequest, req: &HeartbeatRequest,
) -> Result<String, Box<dyn std::error::Error>> { ) -> Result<HeartbeatVerificationResult, crate::errors::VerificationError> {
let mut stmt = conn.prepare( let session = db
"SELECT public_key, salt, last_hash, expires_at FROM sessions WHERE session_id = ?1", .load_session(&req.session_id)
)?; .map_err(|e| crate::errors::VerificationError::Storage(e.to_string()))?;
let (pub_key, salt, stored_last_hash, expires_at): (Vec<u8>, Vec<u8>, Vec<u8>, u64) = let session = session.ok_or(crate::errors::VerificationError::SessionNotFound)?;
stmt.query_row(params![req.session_id], |row| {
Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
})?;
let now = storage::current_time_ms(); let now = storage::current_time_ms();
if now > expires_at { if now > session.expires_at {
return Err("expired".into()); return Err(crate::errors::VerificationError::Expired);
} }
// 1. Verify signature // 1. Verify signature
crypto::verify_signature(&pub_key, req)?; crypto::verify_signature(&session.public_key, req)
.map_err(|e| crate::errors::VerificationError::Signature(e.to_string()))?;
// 2. Check chain continuity // 2. Check chain continuity
if stored_last_hash != hex::decode(&req.prev_hash)? {
return Err("chain broken".into());
}
// 3. Time window
let diff = (now as i64) - (req.timestamp as i64);
if diff.abs() > shared::constants::MAX_TIMESTAMP_DRIFT_MS {
return Err("timestamp drift".into());
}
// 4. Trusted mouse & fingerprint
trust::validate_mouse(&req.entropy_data)?;
fingerprint::validate(&req.fingerprint)?;
// 5. Compute new hash
let prev_hash_bytes = hex::decode(&req.prev_hash)?; let prev_hash_bytes = hex::decode(&req.prev_hash)?;
if session.last_hash != prev_hash_bytes {
return Err(crate::errors::VerificationError::ChainBroken);
}
// 3. Mutation step and deterministic mutation parity
if req.mutation_step != session.pending_mutation_step {
return Err(crate::errors::VerificationError::MutationStepMismatch {
expected: session.pending_mutation_step,
got: req.mutation_step,
});
}
let environment = gene::decode_environment(&session.environment)
.map_err(|e| crate::errors::VerificationError::GeneState(e.to_string()))?;
let server_state = GeneState {
gene: session.gene.clone(),
environment,
};
let candidate_state = vm_extensions::apply_program_clone_with_rounds(
&server_state,
&session.pending_mutation,
config.mutation_rounds,
)
.map_err(|e| crate::errors::VerificationError::MutationProgram(e.to_string()))?;
let expected_gene_commitment =
gene::commitment_hex_with_context(&candidate_state, &req.session_id, req.mutation_step);
if req.gene_commitment != expected_gene_commitment {
return Err(crate::errors::VerificationError::MutationCommitmentMismatch);
}
// 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);
}
// 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()))?;
// 6. Compute new hash
let new_hash = shared::hashing::next_chain_hash( let new_hash = shared::hashing::next_chain_hash(
&prev_hash_bytes, &prev_hash_bytes,
req.timestamp, req.timestamp,
&req.entropy_data, &req.entropy_data,
&req.stack_state, &req.stack_state,
&salt, &session.salt,
); );
// 6. New salt for client // 7. Prepare next mutation order and salt
let next_step = session.pending_mutation_step + 1;
let next_mutation = vm_extensions::generate_order(next_step, candidate_state.gene.len());
let next_mutation_b64 = vm_extensions::encode_order_b64(&next_mutation);
let next_salt = rand::random::<[u8; shared::constants::SALT_LEN]>(); let next_salt = rand::random::<[u8; shared::constants::SALT_LEN]>();
let next_salt_hex = hex::encode(next_salt); let next_salt_hex = hex::encode(next_salt);
let next_environment_blob = gene::encode_environment(&candidate_state.environment)
.map_err(|e| crate::errors::VerificationError::GeneState(e.to_string()))?;
conn.execute( let update_record = storage::SessionRecord {
"UPDATE sessions SET last_hash=?1, salt=?2, chain_length=chain_length+1, last_seen=?3 WHERE session_id=?4", session_id: req.session_id.clone(),
params![new_hash, next_salt.to_vec(), now, req.session_id], public_key: session.public_key,
)?; salt: next_salt.to_vec(),
last_hash: new_hash.clone(),
chain_length: session.chain_length + 1,
created_at: session.created_at,
last_seen: now,
expires_at: session.expires_at,
gene: candidate_state.gene,
environment: next_environment_blob,
pending_mutation: next_mutation.program,
pending_mutation_step: next_step,
};
db.update_session(&update_record)
.map_err(|e| crate::errors::VerificationError::Storage(e.to_string()))?;
Ok(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 rusqlite::params;
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 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(
db: &storage::DbPool,
config: &crate::config::Config,
) -> (InitResponse, SigningKey) {
let mut rng = rand::thread_rng();
let sk = SigningKey::generate(&mut rng);
let pk_hex = hex::encode(sk.verifying_key().to_bytes());
let init = create_session(db, 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_with_context(
&candidate_state,
&client.session_id,
client.pending_mutation_step,
),
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(db: &storage::DbPool, session_id: &str) -> GeneState {
let session = db.load_session(session_id).unwrap().unwrap();
GeneState {
gene: session.gene,
environment: gene::decode_environment(&session.environment).unwrap(),
}
}
fn run_successful_heartbeat(
db: &storage::DbPool,
config: &crate::config::Config,
client: &mut SimulatedClient,
) -> HeartbeatRequest {
let timestamp = storage::current_time_ms();
let (req, candidate_state, entropy, stack) = build_request(client, timestamp);
let result = verify_heartbeat(db, 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 config = test_config();
let (init, signing_key) = create_test_session(&pool, &config);
assert_eq!(init.gene_size, config.gene_size as u32);
assert!(!init.mutation_order_b64.is_empty());
assert_eq!(init.mutation_step, 1);
let mut client = client_from_init(&init, signing_key);
for _ in 0..5 {
run_successful_heartbeat(&pool, &config, &mut client);
}
let stats = pool.stats().unwrap();
assert_eq!(stats.sessions, 1);
assert_eq!(stats.max_chain_length, 6);
}
#[test]
fn test_deterministic_server_client_parity_across_many_heartbeats() {
let pool = storage::init_pool(Path::new(":memory:")).unwrap();
let config = test_config();
let (init, signing_key) = create_test_session(&pool, &config);
let mut client = client_from_init(&init, signing_key);
for _ in 0..12 {
run_successful_heartbeat(&pool, &config, &mut client);
let server_state = load_server_gene_state(&pool, &client.session_id);
assert_eq!(server_state, client.committed_gene_state);
}
}
#[test]
fn test_replay_attack_is_rejected() {
let pool = storage::init_pool(Path::new(":memory:")).unwrap();
let config = test_config();
let (init, signing_key) = create_test_session(&pool, &config);
let mut client = client_from_init(&init, signing_key);
let timestamp = storage::current_time_ms();
let (req, candidate_state, entropy, stack) = build_request(&client, timestamp);
let result = verify_heartbeat(&pool, &config, &req).unwrap();
apply_successful_response(
&mut client,
&req,
candidate_state,
&entropy,
&stack,
&result,
);
let replay = verify_heartbeat(&pool, &config, &req);
assert!(matches!(
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 config = test_config();
let (init, signing_key) = create_test_session(&pool, &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(&pool, &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 config = test_config();
let (init, signing_key) = create_test_session(&pool, &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(&pool, &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 = match &pool {
storage::DbPool::Sqlite(pool) => pool.get().unwrap(),
_ => panic!("expected sqlite pool for test"),
};
let config = test_config();
let (init, signing_key) = create_test_session(&pool, &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 updated: Vec<u8> = conn
.query_row(
"SELECT pending_mutation FROM sessions WHERE session_id=?1",
params![client.session_id.clone()],
|row| row.get(0),
)
.unwrap();
assert_eq!(updated, vec![0xFFu8]);
let timestamp = storage::current_time_ms();
let (req, _, _, _) = build_request(&client, timestamp);
let err = verify_heartbeat(&pool, &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 = match &pool {
storage::DbPool::Sqlite(pool) => pool.get().unwrap(),
_ => panic!("expected sqlite pool for test"),
};
let config = test_config();
let (init, signing_key) = create_test_session(&pool, &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(&pool, &config, &req).unwrap_err();
assert!(matches!(err, crate::errors::VerificationError::Expired));
}
#[test]
fn test_create_session_rejects_invalid_public_key_length() {
let pool = storage::init_pool(Path::new(":memory:")).unwrap();
let config = test_config();
let err = create_session(&pool, &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 config = test_config();
let (init, signing_key) = create_test_session(&pool, &config);
let mut client = client_from_init(&init, signing_key);
run_successful_heartbeat(&pool, &config, &mut client);
let timestamp = storage::current_time_ms();
let (mut req, _, _, _) = build_request(&client, timestamp);
req.mutation_step -= 1;
sign_request(&client.signing_key, &mut req);
let err = verify_heartbeat(&pool, &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 mut config = test_config();
config.gene_size = 128;
let (init, signing_key) = create_test_session(&pool, &config);
let mut client = client_from_init(&init, signing_key);
for _ in 0..10 {
run_successful_heartbeat(&pool, &config, &mut client);
let server_state = load_server_gene_state(&pool, &client.session_id);
assert_eq!(
gene::commitment(&server_state),
gene::commitment(&client.committed_gene_state)
);
}
}
}
+374 -7
View File
@@ -1,8 +1,249 @@
use rusqlite::Connection; use crate::config::Config;
use serde::{Deserialize, Serialize};
use std::path::Path;
use std::sync::{Arc, Mutex};
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
use valkey::Client as ValkeyClient;
pub fn init_db() -> Result<Connection, rusqlite::Error> { #[derive(Debug, Clone, Serialize, Deserialize)]
let conn = Connection::open_in_memory()?; pub struct StoreStats {
pub sessions: u64,
pub expired_sessions: u64,
pub max_chain_length: u64,
}
#[derive(Debug, Clone)]
pub enum DbPool {
Sqlite(r2d2::Pool<r2d2_sqlite::SqliteConnectionManager>),
Valkey(ValkeyStore),
}
#[derive(Debug, Clone)]
pub struct ValkeyStore {
client: Arc<Mutex<ValkeyClient>>,
index_key: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionRecord {
pub session_id: String,
pub public_key: Vec<u8>,
pub salt: Vec<u8>,
pub last_hash: Vec<u8>,
pub chain_length: u64,
pub created_at: u64,
pub last_seen: u64,
pub expires_at: u64,
pub gene: Vec<u8>,
pub environment: Vec<u8>,
pub pending_mutation: Vec<u8>,
pub pending_mutation_step: u64,
}
impl DbPool {
pub fn init(config: &Config) -> Result<Self, Box<dyn std::error::Error>> {
match config.db_type {
crate::config::DbType::SqliteInMemory => {
let pool = init_sqlite_pool(Path::new(":memory:"))?;
Ok(DbPool::Sqlite(pool))
}
crate::config::DbType::SqliteInDisk => {
let pool = init_sqlite_pool(&config.db_path)?;
Ok(DbPool::Sqlite(pool))
}
crate::config::DbType::Valkey => {
let addr = std::env::var("CHRONOSEAL_VALKEY_ADDR")
.unwrap_or_else(|_| "127.0.0.1:6666".to_string());
match ValkeyClient::connect(addr) {
Ok(client) => Ok(DbPool::Valkey(ValkeyStore {
client: Arc::new(Mutex::new(client)),
index_key: "sessions:ids".to_string(),
})),
Err(err) => {
tracing::warn!(
"valkey connection failed, falling back to sqlite-in-memory: {err}"
);
let pool = init_sqlite_pool(Path::new(":memory:"))?;
Ok(DbPool::Sqlite(pool))
}
}
}
}
}
pub fn insert_session(&self, record: &SessionRecord) -> Result<(), Box<dyn std::error::Error>> {
match self {
DbPool::Sqlite(pool) => {
let conn = pool.get()?;
let mut stmt = conn.prepare(
"INSERT INTO sessions (
session_id, public_key, salt, last_hash, chain_length,
created_at, last_seen, expires_at, gene, environment,
pending_mutation, pending_mutation_step
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
)?;
stmt.execute(rusqlite::params![
record.session_id,
&record.public_key,
&record.salt,
&record.last_hash,
record.chain_length,
record.created_at,
record.last_seen,
record.expires_at,
&record.gene,
&record.environment,
&record.pending_mutation,
record.pending_mutation_step,
])?;
Ok(())
}
DbPool::Valkey(store) => store.insert_session(record),
}
}
pub fn load_session(
&self,
session_id: &str,
) -> Result<Option<SessionRecord>, Box<dyn std::error::Error>> {
match self {
DbPool::Sqlite(pool) => {
let conn = pool.get()?;
let mut stmt = conn.prepare(
"SELECT session_id, public_key, salt, last_hash, chain_length, created_at, last_seen, expires_at, gene, environment, pending_mutation, pending_mutation_step
FROM sessions WHERE session_id = ?1",
)?;
let row = stmt.query_row([session_id], |row| {
Ok(SessionRecord {
session_id: row.get(0)?,
public_key: row.get(1)?,
salt: row.get(2)?,
last_hash: row.get(3)?,
chain_length: row.get(4)?,
created_at: row.get(5)?,
last_seen: row.get(6)?,
expires_at: row.get(7)?,
gene: row.get(8)?,
environment: row.get(9)?,
pending_mutation: row.get(10)?,
pending_mutation_step: row.get(11)?,
})
});
match row {
Ok(rec) => Ok(Some(rec)),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(err) => Err(Box::new(err)),
}
}
DbPool::Valkey(store) => store.load_session(session_id),
}
}
pub fn update_session(&self, record: &SessionRecord) -> Result<(), Box<dyn std::error::Error>> {
match self {
DbPool::Sqlite(pool) => {
let conn = pool.get()?;
conn.execute(
"UPDATE sessions SET
public_key=?1,
salt=?2,
last_hash=?3,
chain_length=?4,
created_at=?5,
last_seen=?6,
expires_at=?7,
gene=?8,
environment=?9,
pending_mutation=?10,
pending_mutation_step=?11
WHERE session_id=?12",
rusqlite::params![
&record.public_key,
&record.salt,
&record.last_hash,
record.chain_length,
record.created_at,
record.last_seen,
record.expires_at,
&record.gene,
&record.environment,
&record.pending_mutation,
record.pending_mutation_step,
&record.session_id,
],
)?;
Ok(())
}
DbPool::Valkey(store) => store.insert_session(record),
}
}
pub fn delete_expired_sessions(&self) -> Result<(), Box<dyn std::error::Error>> {
match self {
DbPool::Sqlite(pool) => {
let conn = pool.get()?;
conn.execute(
"DELETE FROM sessions WHERE expires_at < ?1",
rusqlite::params![current_time_ms()],
)?;
Ok(())
}
DbPool::Valkey(store) => store.purge_expired_sessions(),
}
}
pub fn stats(&self) -> Result<StoreStats, Box<dyn std::error::Error>> {
match self {
DbPool::Sqlite(pool) => {
let conn = pool.get()?;
let now = current_time_ms();
let sessions =
conn.query_row("SELECT COUNT(*) FROM sessions", [], |row| row.get(0))?;
let expired_sessions = conn.query_row(
"SELECT COUNT(*) FROM sessions WHERE expires_at < ?1",
[now],
|row| row.get(0),
)?;
let max_chain_length = conn.query_row(
"SELECT COALESCE(MAX(chain_length), 0) FROM sessions",
[],
|row| row.get(0),
)?;
Ok(StoreStats {
sessions,
expired_sessions,
max_chain_length,
})
}
DbPool::Valkey(store) => store.stats(),
}
}
}
#[cfg(test)]
pub fn init_pool(path: &Path) -> Result<DbPool, Box<dyn std::error::Error>> {
let pool = init_sqlite_pool(path)?;
Ok(DbPool::Sqlite(pool))
}
fn init_sqlite_pool(
path: &Path,
) -> Result<r2d2::Pool<r2d2_sqlite::SqliteConnectionManager>, Box<dyn std::error::Error>> {
let manager = if path == Path::new(":memory:") {
r2d2_sqlite::SqliteConnectionManager::memory()
} else {
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
r2d2_sqlite::SqliteConnectionManager::file(path)
};
let pool = r2d2::Pool::new(manager)?;
let conn = pool.get()?;
init_schema(&conn)?;
Ok(pool)
}
fn init_schema(conn: &rusqlite::Connection) -> Result<(), rusqlite::Error> {
conn.execute_batch( conn.execute_batch(
"CREATE TABLE IF NOT EXISTS sessions ( "CREATE TABLE IF NOT EXISTS sessions (
session_id TEXT PRIMARY KEY, session_id TEXT PRIMARY KEY,
@@ -12,12 +253,138 @@ pub fn init_db() -> Result<Connection, rusqlite::Error> {
chain_length INTEGER NOT NULL DEFAULT 1, chain_length INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL, created_at INTEGER NOT NULL,
last_seen INTEGER NOT NULL, last_seen INTEGER NOT NULL,
expires_at INTEGER NOT NULL expires_at INTEGER NOT NULL,
gene BLOB NOT NULL DEFAULT X'',
environment BLOB NOT NULL DEFAULT X'',
pending_mutation BLOB NOT NULL DEFAULT X'',
pending_mutation_step INTEGER NOT NULL DEFAULT 0
);", );",
)?; )?;
Ok(conn) 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(())
}
impl ValkeyStore {
fn session_key(&self, session_id: &str) -> String {
format!("session:{}", session_id)
}
fn load_session(
&self,
session_id: &str,
) -> Result<Option<SessionRecord>, Box<dyn std::error::Error>> {
let mut client = self.client.lock().unwrap();
if let Some(payload) = client.get(&self.session_key(session_id))? {
let record = serde_json::from_str(&payload)?;
Ok(Some(record))
} else {
Ok(None)
}
}
fn insert_session(&self, record: &SessionRecord) -> Result<(), Box<dyn std::error::Error>> {
let mut client = self.client.lock().unwrap();
let value = serde_json::to_string(record)?;
client.set(&self.session_key(&record.session_id), &value)?;
let existing = client.get(&self.index_key)?;
let mut ids = existing.unwrap_or_default();
if !ids.split('\n').any(|id| id == record.session_id) {
if !ids.is_empty() {
ids.push('\n');
}
ids.push_str(&record.session_id);
client.set(&self.index_key, &ids)?;
}
Ok(())
}
fn purge_expired_sessions(&self) -> Result<(), Box<dyn std::error::Error>> {
let mut client = self.client.lock().unwrap();
let ids = client.get(&self.index_key)?.unwrap_or_default();
let now = current_time_ms();
let mut remaining: Vec<String> = Vec::new();
for id in ids.split('\n').filter(|id| !id.is_empty()) {
if let Some(payload) = client.get(&self.session_key(id))? {
if let Ok(record) = serde_json::from_str::<SessionRecord>(&payload) {
if record.expires_at > now {
remaining.push(id.to_string());
}
}
}
}
client.set(&self.index_key, &remaining.join("\n"))?;
Ok(())
}
fn stats(&self) -> Result<StoreStats, Box<dyn std::error::Error>> {
let mut client = self.client.lock().unwrap();
let ids = client.get(&self.index_key)?.unwrap_or_default();
let now = current_time_ms();
let mut sessions = 0;
let mut expired_sessions = 0;
let mut max_chain_length = 0;
for id in ids.split('\n').filter(|id| !id.is_empty()) {
if let Some(payload) = client.get(&self.session_key(id))? {
if let Ok(record) = serde_json::from_str::<SessionRecord>(&payload) {
sessions += 1;
if record.expires_at < now {
expired_sessions += 1;
}
max_chain_length = max_chain_length.max(record.chain_length);
}
}
}
Ok(StoreStats {
sessions,
expired_sessions,
max_chain_length,
})
}
} }
pub fn current_time_ms() -> u64 { pub fn current_time_ms() -> u64 {
SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_millis() as u64 SystemTime::now()
} .duration_since(UNIX_EPOCH)
.unwrap()
.as_millis() as u64
}
+189 -7
View File
@@ -1,7 +1,14 @@
use crate::config::Config;
use shared::protocol::EntropyData; use shared::protocol::EntropyData;
pub fn validate_mouse(data: &EntropyData) -> Result<(), Box<dyn std::error::Error>> { pub fn validate_mouse(
data: &EntropyData,
config: &Config,
) -> Result<(), Box<dyn std::error::Error>> {
let events = &data.events; let events = &data.events;
if !config.require_mouse_activity && events.is_empty() {
return Ok(());
}
if events.len() < 3 { if events.len() < 3 {
return Err("few events".into()); return Err("few events".into());
} }
@@ -19,18 +26,193 @@ pub fn validate_mouse(data: &EntropyData) -> Result<(), Box<dyn std::error::Erro
pauses += 1; pauses += 1;
} }
} }
if total_dist < shared::constants::MIN_MOUSE_TOTAL_DIST { if total_dist < config.min_mouse_total_dist {
return Err("insufficient distance".into()); return Err("insufficient distance".into());
} }
// Speed in px/ms: total distance over elapsed wall-clock time of the event window. // Speed in px/ms: total distance over elapsed wall-clock time of the event window.
let total_time_ms = let total_time_ms = (events.last().unwrap().timestamp_ms - events[0].timestamp_ms).max(1.0);
(events.last().unwrap().timestamp_ms - events[0].timestamp_ms).max(1.0);
let avg_speed = total_dist / total_time_ms; let avg_speed = total_dist / total_time_ms;
if avg_speed > shared::constants::MAX_MOUSE_AVG_SPEED { if avg_speed > config.max_mouse_avg_speed {
return Err("speed too high".into()); return Err("speed too high".into());
} }
if pauses < shared::constants::MIN_PAUSE_COUNT { if pauses < config.min_pause_count {
return Err("no pause".into()); return Err("no pause".into());
} }
Ok(()) Ok(())
} }
#[cfg(test)]
mod tests {
use super::*;
use shared::protocol::MouseEvent;
fn get_default_config() -> Config {
Config {
min_mouse_total_dist: 10.0,
max_mouse_avg_speed: 2.0,
min_pause_count: 1,
require_mouse_activity: true,
..Config::default()
}
}
#[test]
fn test_validate_mouse_success() {
let config = get_default_config();
// Mouse moves from (0,0) to (5,0) then (15,0) with a pause
let events = vec![
MouseEvent {
x: 0.0,
y: 0.0,
timestamp_ms: 100.0,
},
MouseEvent {
x: 5.0,
y: 0.0,
timestamp_ms: 200.0,
},
// Pause here (dist = 0, time diff = 100ms > 50ms)
MouseEvent {
x: 5.0,
y: 0.0,
timestamp_ms: 300.0,
},
MouseEvent {
x: 15.0,
y: 0.0,
timestamp_ms: 400.0,
},
];
let data = EntropyData { events };
assert!(validate_mouse(&data, &config).is_ok());
}
#[test]
fn test_validate_mouse_insufficient_events() {
let config = get_default_config();
let events = vec![
MouseEvent {
x: 0.0,
y: 0.0,
timestamp_ms: 100.0,
},
MouseEvent {
x: 5.0,
y: 0.0,
timestamp_ms: 200.0,
},
];
let data = EntropyData { events };
let res = validate_mouse(&data, &config);
assert!(res.is_err());
assert_eq!(res.unwrap_err().to_string(), "few events");
}
#[test]
fn test_validate_mouse_insufficient_distance() {
let config = get_default_config();
// Total distance is only 5.0 < 10.0
let events = vec![
MouseEvent {
x: 0.0,
y: 0.0,
timestamp_ms: 100.0,
},
MouseEvent {
x: 2.0,
y: 0.0,
timestamp_ms: 200.0,
},
MouseEvent {
x: 2.0,
y: 0.0,
timestamp_ms: 300.0,
},
MouseEvent {
x: 5.0,
y: 0.0,
timestamp_ms: 400.0,
},
];
let data = EntropyData { events };
let res = validate_mouse(&data, &config);
assert!(res.is_err());
assert_eq!(res.unwrap_err().to_string(), "insufficient distance");
}
#[test]
fn test_validate_mouse_too_fast() {
let config = get_default_config();
// Distance is 200.0, time difference is 70ms -> speed 2.85 > 2.0
let events = vec![
MouseEvent {
x: 0.0,
y: 0.0,
timestamp_ms: 100.0,
},
MouseEvent {
x: 100.0,
y: 0.0,
timestamp_ms: 105.0,
},
MouseEvent {
x: 100.0,
y: 0.0,
timestamp_ms: 165.0,
}, // pause
MouseEvent {
x: 200.0,
y: 0.0,
timestamp_ms: 170.0,
},
];
let data = EntropyData { events };
let res = validate_mouse(&data, &config);
assert!(res.is_err());
assert_eq!(res.unwrap_err().to_string(), "speed too high");
}
#[test]
fn test_validate_mouse_no_pauses() {
let config = get_default_config();
// Constant movement without any pause
let events = vec![
MouseEvent {
x: 0.0,
y: 0.0,
timestamp_ms: 100.0,
},
MouseEvent {
x: 5.0,
y: 0.0,
timestamp_ms: 200.0,
},
MouseEvent {
x: 10.0,
y: 0.0,
timestamp_ms: 300.0,
},
MouseEvent {
x: 15.0,
y: 0.0,
timestamp_ms: 400.0,
},
];
let data = EntropyData { events };
let res = validate_mouse(&data, &config);
assert!(res.is_err());
assert_eq!(res.unwrap_err().to_string(), "no pause");
}
#[test]
fn test_validate_mouse_require_activity_toggle() {
let mut config = get_default_config();
config.require_mouse_activity = false;
let data = EntropyData { events: vec![] };
assert!(validate_mouse(&data, &config).is_ok());
config.require_mouse_activity = true;
assert!(validate_mouse(&data, &config).is_err());
}
}
+55 -3
View File
@@ -1,4 +1,8 @@
use rand::Rng; use rand::Rng;
use shared::{
gene::GeneState,
vm_extensions::{self, ExecutionTrace, MutationError, MutationOrder},
};
pub fn generate_random_program(len_range: std::ops::RangeInclusive<usize>) -> Vec<u8> { pub fn generate_random_program(len_range: std::ops::RangeInclusive<usize>) -> Vec<u8> {
let mut rng = rand::thread_rng(); let mut rng = rand::thread_rng();
@@ -9,7 +13,7 @@ pub fn generate_random_program(len_range: std::ops::RangeInclusive<usize>) -> Ve
if depth < 2 { if depth < 2 {
// Not enough operands for any binary op — push a literal. // Not enough operands for any binary op — push a literal.
ops.push(0x00); ops.push(0x00);
let val = rng.gen::<u32>(); let val = rng.r#gen::<u32>();
ops.extend_from_slice(&val.to_le_bytes()); ops.extend_from_slice(&val.to_le_bytes());
depth += 1; depth += 1;
} else { } else {
@@ -18,7 +22,7 @@ pub fn generate_random_program(len_range: std::ops::RangeInclusive<usize>) -> Ve
0x00 => { 0x00 => {
// PUSH literal // PUSH literal
ops.push(0x00); ops.push(0x00);
let val = rng.gen::<u32>(); let val = rng.r#gen::<u32>();
ops.extend_from_slice(&val.to_le_bytes()); ops.extend_from_slice(&val.to_le_bytes());
depth += 1; depth += 1;
} }
@@ -43,4 +47,52 @@ pub fn generate_random_program(len_range: std::ops::RangeInclusive<usize>) -> Ve
ops ops
} }
// Server does not need to execute the program; client does. #[allow(dead_code)]
pub fn execute_mutation_program(
state: &mut GeneState,
program: &[u8],
) -> Result<ExecutionTrace, MutationError> {
vm_extensions::execute_program(state, program)
}
#[allow(dead_code)]
pub fn execute_mutation_order(
state: &mut GeneState,
order: &MutationOrder,
) -> Result<ExecutionTrace, MutationError> {
vm_extensions::execute_program(state, &order.program)
}
#[cfg(test)]
mod tests {
use super::*;
use rand::SeedableRng;
use shared::gene::{commitment, new_state};
#[test]
fn test_execute_mutation_program_wraps_shared_engine() {
let mut state = new_state(8).unwrap();
let program = vec![vm_extensions::OP_MUTATE_POINT, 0, 0, 1];
let trace = execute_mutation_program(&mut state, &program).unwrap();
assert_eq!(state.gene[0], 1);
assert_eq!(trace.final_ip, program.len());
}
#[test]
fn test_execute_mutation_order_determinism() {
let mut rng_a = rand::rngs::StdRng::seed_from_u64(101);
let mut rng_b = rand::rngs::StdRng::seed_from_u64(101);
let order_a = vm_extensions::generate_order_with_rng(&mut rng_a, 9, 64);
let order_b = vm_extensions::generate_order_with_rng(&mut rng_b, 9, 64);
assert_eq!(order_a, order_b);
let mut state_a = new_state(64).unwrap();
let mut state_b = new_state(64).unwrap();
let trace_a = execute_mutation_order(&mut state_a, &order_a).unwrap();
let trace_b = execute_mutation_order(&mut state_b, &order_b).unwrap();
assert_eq!(state_a, state_b);
assert_eq!(trace_a.final_stack, trace_b.final_stack);
assert_eq!(commitment(&state_a), commitment(&state_b));
}
}
+3 -2
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "shared" name = "shared"
version = "0.2.0" version = "0.6.0"
edition = "2021" edition = "2021"
[dependencies] [dependencies]
@@ -10,4 +10,5 @@ blake3 = "1"
hex = "0.4" hex = "0.4"
base64 = "0.22" base64 = "0.22"
rand = "0.8" rand = "0.8"
ed25519-dalek = { version = "2", features = ["rand_core"] } ed25519-dalek = { version = "2", features = ["rand_core"] }
tracing = "0.1"
+10 -9
View File
@@ -1,11 +1,12 @@
pub const SESSION_ID_LEN: usize = 32; pub const SESSION_ID_LEN: usize = 32;
pub const SALT_LEN: usize = 16; pub const SALT_LEN: usize = 16;
pub const HEARTBEAT_MIN_INTERVAL_MS: u64 = 12_000; pub const DEFAULT_GENE_SIZE: usize = 512;
pub const HEARTBEAT_MAX_INTERVAL_MS: u64 = 25_000; pub const MAX_GENE_SIZE: usize = 4096;
pub const EXPIRATION_MINUTES: i64 = 30; pub const MAX_ENV_RECORDS: usize = 48;
pub const RATE_LIMIT_COUNT: u32 = 5; pub const MAX_MUTATION_PROGRAM_BYTES: usize = 256;
pub const RATE_LIMIT_WINDOW_SECS: u64 = 10; pub const DEFAULT_MUTATION_ROUNDS: u8 = 4;
pub const MAX_TIMESTAMP_DRIFT_MS: i64 = 30_000; pub const MIN_MUTATION_ROUNDS: u8 = 3;
pub const MIN_MOUSE_TOTAL_DIST: f64 = 10.0; pub const MAX_MUTATION_ROUNDS: u8 = 10;
pub const MAX_MOUSE_AVG_SPEED: f64 = 2.0; // px/ms pub const MAX_MUTATION_INSTRUCTION_BUDGET: usize = 2048;
pub const MIN_PAUSE_COUNT: u32 = 1; pub const HASH_OPCODE_INSTRUCTION_COST: usize = 16;
pub const SOFT_CAP_DURATION_MS: u128 = 50;
+394
View File
@@ -0,0 +1,394 @@
use crate::constants::{DEFAULT_GENE_SIZE, MAX_ENV_RECORDS, MAX_GENE_SIZE};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct EnvironmentRecord {
pub symbol: u16,
pub quantity: u32,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GeneState {
pub gene: Vec<u8>,
pub environment: Vec<EnvironmentRecord>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GeneError {
InvalidGeneSize { size: usize },
TooManyEnvironmentRecords { len: usize },
EnvironmentNotSorted,
DuplicateEnvironmentSymbol(u16),
ZeroQuantitySymbol(u16),
EnvironmentFull,
EnvironmentBlobLengthInvalid { len: usize },
EnvironmentBlobTooLarge { records: usize },
}
impl std::fmt::Display for GeneError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::InvalidGeneSize { size } => write!(f, "invalid gene size: {size}"),
Self::TooManyEnvironmentRecords { len } => {
write!(f, "too many environment records: {len}")
}
Self::EnvironmentNotSorted => write!(f, "environment records are not sorted"),
Self::DuplicateEnvironmentSymbol(symbol) => {
write!(f, "duplicate environment symbol: {symbol}")
}
Self::ZeroQuantitySymbol(symbol) => {
write!(f, "environment quantity cannot be zero for symbol {symbol}")
}
Self::EnvironmentFull => write!(f, "environment is at maximum capacity"),
Self::EnvironmentBlobLengthInvalid { len } => {
write!(
f,
"environment blob length must be a multiple of 6, got {len}"
)
}
Self::EnvironmentBlobTooLarge { records } => {
write!(f, "environment blob contains too many records: {records}")
}
}
}
}
impl std::error::Error for GeneError {}
pub fn new_state(gene_size: usize) -> Result<GeneState, GeneError> {
if !(1..=MAX_GENE_SIZE).contains(&gene_size) {
return Err(GeneError::InvalidGeneSize { size: gene_size });
}
Ok(GeneState {
gene: vec![0; gene_size],
environment: Vec::new(),
})
}
pub fn default_state() -> GeneState {
GeneState {
gene: vec![0; DEFAULT_GENE_SIZE],
environment: Vec::new(),
}
}
pub fn validate_state(state: &GeneState) -> Result<(), GeneError> {
if !(1..=MAX_GENE_SIZE).contains(&state.gene.len()) {
return Err(GeneError::InvalidGeneSize {
size: state.gene.len(),
});
}
validate_environment(&state.environment)
}
pub fn get_env_quantity(state: &GeneState, symbol: u16) -> u32 {
match state
.environment
.binary_search_by_key(&symbol, |record| record.symbol)
{
Ok(i) => state.environment[i].quantity,
Err(_) => 0,
}
}
pub fn set_env_quantity(
state: &mut GeneState,
symbol: u16,
quantity: u32,
) -> Result<(), GeneError> {
let idx = state
.environment
.binary_search_by_key(&symbol, |record| record.symbol);
match (idx, quantity) {
(Ok(i), 0) => {
state.environment.remove(i);
Ok(())
}
(Ok(i), qty) => {
state.environment[i].quantity = qty;
Ok(())
}
(Err(_), 0) => Ok(()),
(Err(i), qty) => {
if state.environment.len() >= MAX_ENV_RECORDS {
return Err(GeneError::EnvironmentFull);
}
state.environment.insert(
i,
EnvironmentRecord {
symbol,
quantity: qty,
},
);
Ok(())
}
}
}
pub fn add_env_quantity(
state: &mut GeneState,
symbol: u16,
quantity: u32,
) -> Result<u32, GeneError> {
let current = get_env_quantity(state, symbol);
let next = current.saturating_add(quantity);
set_env_quantity(state, symbol, next)?;
Ok(next)
}
pub fn sub_env_quantity(
state: &mut GeneState,
symbol: u16,
quantity: u32,
) -> Result<u32, GeneError> {
let current = get_env_quantity(state, symbol);
let next = current.saturating_sub(quantity);
set_env_quantity(state, symbol, next)?;
Ok(next)
}
pub fn encode_environment(records: &[EnvironmentRecord]) -> Result<Vec<u8>, GeneError> {
validate_environment(records)?;
let mut out = Vec::with_capacity(records.len() * 6);
for record in records {
out.extend_from_slice(&record.symbol.to_le_bytes());
out.extend_from_slice(&record.quantity.to_le_bytes());
}
Ok(out)
}
pub fn decode_environment(blob: &[u8]) -> Result<Vec<EnvironmentRecord>, GeneError> {
if !blob.len().is_multiple_of(6) {
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))
}
pub fn commitment_with_context(state: &GeneState, session_id: &str, step: u64) -> [u8; 32] {
let mut h = blake3::Hasher::new();
h.update(b"chronoseal/gene/v1");
h.update(session_id.as_bytes());
h.update(&step.to_le_bytes());
h.update(&commitment(state));
*h.finalize().as_bytes()
}
pub fn commitment_hex_with_context(state: &GeneState, session_id: &str, step: u64) -> String {
hex::encode(commitment_with_context(state, session_id, step))
}
fn validate_environment(records: &[EnvironmentRecord]) -> Result<(), GeneError> {
if records.len() > MAX_ENV_RECORDS {
return Err(GeneError::TooManyEnvironmentRecords { len: records.len() });
}
let mut prev_symbol: Option<u16> = None;
for record in records {
if record.quantity == 0 {
return Err(GeneError::ZeroQuantitySymbol(record.symbol));
}
if let Some(prev) = prev_symbol {
if record.symbol < prev {
return Err(GeneError::EnvironmentNotSorted);
}
if record.symbol == prev {
return Err(GeneError::DuplicateEnvironmentSymbol(record.symbol));
}
}
prev_symbol = Some(record.symbol);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use rand::{Rng, SeedableRng};
#[test]
fn test_new_state_with_default_size() {
let state = new_state(DEFAULT_GENE_SIZE).unwrap();
assert_eq!(state.gene.len(), DEFAULT_GENE_SIZE);
assert!(state.environment.is_empty());
}
#[test]
fn test_new_state_rejects_invalid_sizes() {
assert!(matches!(
new_state(0).unwrap_err(),
GeneError::InvalidGeneSize { .. }
));
assert!(matches!(
new_state(MAX_GENE_SIZE + 1).unwrap_err(),
GeneError::InvalidGeneSize { .. }
));
}
#[test]
fn test_set_and_get_env_quantity() {
let mut state = new_state(8).unwrap();
set_env_quantity(&mut state, 42, 7).unwrap();
assert_eq!(get_env_quantity(&state, 42), 7);
set_env_quantity(&mut state, 42, 0).unwrap();
assert_eq!(get_env_quantity(&state, 42), 0);
}
#[test]
fn test_add_env_quantity_saturates() {
let mut state = new_state(8).unwrap();
set_env_quantity(&mut state, 1, u32::MAX - 3).unwrap();
let next = add_env_quantity(&mut state, 1, 99).unwrap();
assert_eq!(next, u32::MAX);
}
#[test]
fn test_sub_env_quantity_removes_symbol() {
let mut state = new_state(8).unwrap();
set_env_quantity(&mut state, 7, 10).unwrap();
let next = sub_env_quantity(&mut state, 7, 100).unwrap();
assert_eq!(next, 0);
assert!(state.environment.is_empty());
}
#[test]
fn test_environment_capacity_limit_is_enforced() {
let mut state = new_state(8).unwrap();
for symbol in 0..(MAX_ENV_RECORDS as u16) {
set_env_quantity(&mut state, symbol, 1).unwrap();
}
let err = set_env_quantity(&mut state, 500, 1).unwrap_err();
assert_eq!(err, GeneError::EnvironmentFull);
}
#[test]
fn test_encode_decode_environment_roundtrip() {
let records = vec![
EnvironmentRecord {
symbol: 3,
quantity: 9,
},
EnvironmentRecord {
symbol: 11,
quantity: 999,
},
];
let blob = encode_environment(&records).unwrap();
let decoded = decode_environment(&blob).unwrap();
assert_eq!(decoded, records);
}
#[test]
fn test_decode_environment_rejects_unsorted_records() {
let mut blob = Vec::new();
blob.extend_from_slice(&7u16.to_le_bytes());
blob.extend_from_slice(&1u32.to_le_bytes());
blob.extend_from_slice(&2u16.to_le_bytes());
blob.extend_from_slice(&1u32.to_le_bytes());
let err = decode_environment(&blob).unwrap_err();
assert_eq!(err, GeneError::EnvironmentNotSorted);
}
#[test]
fn test_decode_environment_rejects_zero_quantity() {
let mut blob = Vec::new();
blob.extend_from_slice(&9u16.to_le_bytes());
blob.extend_from_slice(&0u32.to_le_bytes());
let err = decode_environment(&blob).unwrap_err();
assert_eq!(err, GeneError::ZeroQuantitySymbol(9));
}
#[test]
fn test_commitment_changes_when_gene_or_environment_changes() {
let mut state_a = new_state(16).unwrap();
let mut state_b = state_a.clone();
assert_eq!(commitment_hex(&state_a), commitment_hex(&state_b));
state_b.gene[0] = 1;
assert_ne!(commitment_hex(&state_a), commitment_hex(&state_b));
set_env_quantity(&mut state_a, 7, 3).unwrap();
assert_ne!(commitment_hex(&state_a), commitment_hex(&state_b));
}
#[test]
fn test_validate_state_rejects_duplicate_environment_symbols() {
let state = GeneState {
gene: vec![0; 10],
environment: vec![
EnvironmentRecord {
symbol: 1,
quantity: 1,
},
EnvironmentRecord {
symbol: 1,
quantity: 2,
},
],
};
assert_eq!(
validate_state(&state).unwrap_err(),
GeneError::DuplicateEnvironmentSymbol(1)
);
}
#[test]
fn test_table_driven_randomized_environment_roundtrip() {
for seed in 0..32u64 {
let mut rng = rand::rngs::StdRng::seed_from_u64(seed);
let mut state = new_state(32).unwrap();
for _ in 0..128 {
let symbol = rng.gen_range(0u16..200u16);
let qty = if rng.gen_bool(0.15) {
0
} else {
rng.gen_range(1u32..100_000u32)
};
if let Err(err) = set_env_quantity(&mut state, symbol, qty) {
assert_eq!(err, GeneError::EnvironmentFull);
}
validate_state(&state).unwrap();
}
let blob = encode_environment(&state.environment).unwrap();
let decoded = decode_environment(&blob).unwrap();
assert_eq!(decoded, state.environment);
let commitment_a = commitment(&state);
let commitment_b = commitment(&state.clone());
assert_eq!(commitment_a, commitment_b);
}
}
}
+2 -2
View File
@@ -1,5 +1,5 @@
use blake3::Hasher;
use crate::protocol::{EntropyData, StackState}; use crate::protocol::{EntropyData, StackState};
use blake3::Hasher;
/// Initial hash for a brand-new session: Blake3(session_id || pub_key || salt) /// Initial hash for a brand-new session: Blake3(session_id || pub_key || salt)
pub fn initial_hash(session_id: &str, pub_key: &[u8], salt: &[u8]) -> Vec<u8> { pub fn initial_hash(session_id: &str, pub_key: &[u8], salt: &[u8]) -> Vec<u8> {
@@ -39,4 +39,4 @@ pub fn hash_stack(stack: &[u32]) -> u32 {
let data: Vec<u8> = stack.iter().flat_map(|x| x.to_le_bytes()).collect(); let data: Vec<u8> = stack.iter().flat_map(|x| x.to_le_bytes()).collect();
let hash = blake3::hash(&data); let hash = blake3::hash(&data);
u32::from_le_bytes(hash.as_bytes()[..4].try_into().unwrap()) u32::from_le_bytes(hash.as_bytes()[..4].try_into().unwrap())
} }
+3 -1
View File
@@ -1,3 +1,5 @@
pub mod constants; pub mod constants;
pub mod gene;
pub mod hashing; pub mod hashing;
pub mod protocol; pub mod protocol;
pub mod vm_extensions;
+18 -7
View File
@@ -1,20 +1,25 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
#[derive(Deserialize, Serialize)] #[derive(Debug, Clone, Deserialize, Serialize)]
pub struct InitRequest { pub struct InitRequest {
pub public_key: String, pub public_key: String,
} }
#[derive(Serialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InitResponse { pub struct InitResponse {
pub session_id: String, pub session_id: String,
pub salt: String, pub salt: String,
pub opcodes_b64: String, pub opcodes_b64: String,
pub initial_hash: String, pub initial_hash: String,
pub expires_at: u64, 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 struct HeartbeatRequest {
pub session_id: String, pub session_id: String,
pub prev_hash: String, pub prev_hash: String,
@@ -22,17 +27,23 @@ pub struct HeartbeatRequest {
pub entropy_data: EntropyData, pub entropy_data: EntropyData,
pub stack_state: StackState, pub stack_state: StackState,
pub fingerprint: Fingerprint, pub fingerprint: Fingerprint,
pub mutation_step: u64,
pub gene_commitment: String,
pub signature: String, pub signature: String,
} }
#[derive(Serialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HeartbeatResponse { pub struct HeartbeatResponse {
pub status: String, pub status: String,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub next_salt: Option<String>, pub next_salt: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub next_mutation_step: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub next_mutation_order_b64: Option<String>,
} }
#[derive(Deserialize, Serialize)] #[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Fingerprint { pub struct Fingerprint {
#[serde(rename = "aspectRatio")] #[serde(rename = "aspectRatio")]
pub aspect_ratio: String, pub aspect_ratio: String,
@@ -42,7 +53,7 @@ pub struct Fingerprint {
pub hardware_concurrency: u32, pub hardware_concurrency: u32,
} }
#[derive(Deserialize, Serialize)] #[derive(Debug, Clone, Deserialize, Serialize)]
pub struct EntropyData { pub struct EntropyData {
pub events: Vec<MouseEvent>, pub events: Vec<MouseEvent>,
} }
@@ -59,4 +70,4 @@ pub struct MouseEvent {
pub struct StackState { pub struct StackState {
pub stack: Vec<u32>, pub stack: Vec<u32>,
pub ip: u16, pub ip: u16,
} }
+838
View File
@@ -0,0 +1,838 @@
use crate::{
constants::{
DEFAULT_MUTATION_ROUNDS, HASH_OPCODE_INSTRUCTION_COST, MAX_GENE_SIZE,
MAX_MUTATION_INSTRUCTION_BUDGET, MAX_MUTATION_PROGRAM_BYTES, SOFT_CAP_DURATION_MS,
},
gene::{
add_env_quantity, get_env_quantity, sub_env_quantity, validate_state, GeneError, GeneState,
},
};
use rand::Rng;
use serde::{Deserialize, Serialize};
use std::time::Instant;
// Stack-machine mutation opcodes (v0.6.0).
//
// NOTE: stack effect notation:
// +1 => pushes one u32
// -1 => pops one u32
// 0 => net-zero (or no stack interaction)
//
// Security/performance notes:
// - All index operands are normalized with modulo to avoid panics.
// - Program size is bounded by MAX_MUTATION_PROGRAM_BYTES.
// - Environment arithmetic is saturating and deterministic.
// - Hashing uses fixed BLAKE3 commitment and fixed transcription algorithm.
pub const OP_GENE_LOAD: u8 = 0x23; // +1
pub const OP_GENE_STORE: u8 = 0x24; // -1
pub const OP_MUTATE_POINT: u8 = 0x25; // 0
pub const OP_INSERT: u8 = 0x26; // -1
pub const OP_DELETE: u8 = 0x27; // +1
pub const OP_TRANSCRIBE: u8 = 0x28; // +1
pub const OP_APPLY_MUTAGEN: u8 = 0x29; // -1
pub const OP_FINALIZE_GENE_HASH: u8 = 0x2A; // +1
pub const OP_CONSUME: u8 = 0x2B; // 0 (pop amount, push remaining)
pub const OP_PRODUCE: u8 = 0x2C; // 0 (pop amount, push resulting quantity)
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MutationOrder {
pub step: u64,
pub program: Vec<u8>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExecutionTrace {
pub final_ip: usize,
pub final_stack: Vec<u32>,
pub final_gene_commitment_hex: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MutationError {
ProgramTooLong { len: usize },
TruncatedInstruction { opcode: u8, ip: usize },
UnknownOpcode(u8),
EmptyGene,
StackUnderflow { opcode: u8, ip: usize },
GeneFull { current_len: usize },
Base64(base64::DecodeError),
Gene(GeneError),
}
impl std::fmt::Display for MutationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::ProgramTooLong { len } => write!(f, "mutation program too long: {len} bytes"),
Self::TruncatedInstruction { opcode, ip } => {
write!(f, "truncated instruction {opcode:#04x} at ip={ip}")
}
Self::UnknownOpcode(opcode) => write!(f, "unknown mutation opcode: {opcode:#04x}"),
Self::EmptyGene => write!(f, "cannot mutate an empty gene"),
Self::StackUnderflow { opcode, ip } => {
write!(f, "stack underflow in opcode {opcode:#04x} at ip={ip}")
}
Self::GeneFull { current_len } => {
write!(f, "cannot insert; gene already at max size ({current_len})")
}
Self::Base64(err) => write!(f, "invalid base64 mutation order: {err}"),
Self::Gene(err) => write!(f, "{err}"),
}
}
}
impl std::error::Error for MutationError {}
impl From<GeneError> for MutationError {
fn from(value: GeneError) -> Self {
Self::Gene(value)
}
}
pub fn encode_order_b64(order: &MutationOrder) -> String {
base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &order.program)
}
pub fn decode_order_b64(step: u64, b64: &str) -> Result<MutationOrder, MutationError> {
let program = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, b64)
.map_err(MutationError::Base64)?;
if program.len() > MAX_MUTATION_PROGRAM_BYTES {
return Err(MutationError::ProgramTooLong { len: program.len() });
}
Ok(MutationOrder { step, program })
}
pub fn generate_order(step: u64, gene_size: usize) -> MutationOrder {
let mut rng = rand::thread_rng();
generate_order_with_rng(&mut rng, step, gene_size)
}
pub fn generate_order_with_rng<R: Rng + ?Sized>(
rng: &mut R,
step: u64,
gene_size: usize,
) -> MutationOrder {
let mut program = Vec::with_capacity(128);
let mut stack_depth: i32 = 0;
let mut estimated_gene_len = gene_size.clamp(1, MAX_GENE_SIZE);
let ops = rng.gen_range(20usize..=36usize);
let mut hash_ops_needed = rng.gen_range(2..=3);
for idx in 0..ops {
let remaining = ops - idx;
let op = if hash_ops_needed > 0 && remaining <= hash_ops_needed {
OP_FINALIZE_GENE_HASH
} else if stack_depth <= 0 {
rng.gen_range(0u8..3u8)
} else {
match rng.gen_range(0u8..12u8) {
0..=1 => OP_GENE_LOAD,
2..=3 => OP_TRANSCRIBE,
4 => OP_FINALIZE_GENE_HASH,
5 => OP_GENE_STORE,
6 => OP_MUTATE_POINT,
7 => OP_INSERT,
8 => OP_DELETE,
9 => OP_APPLY_MUTAGEN,
10 => OP_CONSUME,
_ => OP_PRODUCE,
}
};
match op {
OP_GENE_LOAD => {
program.push(OP_GENE_LOAD);
push_u16(&mut program, rng.r#gen::<u16>());
stack_depth += 1;
}
OP_TRANSCRIBE => {
program.push(OP_TRANSCRIBE);
push_u16(&mut program, rng.r#gen::<u16>());
program.push(rng.gen_range(1u8..=16u8));
stack_depth += 1;
}
OP_FINALIZE_GENE_HASH => {
program.push(OP_FINALIZE_GENE_HASH);
stack_depth += 1;
hash_ops_needed = hash_ops_needed.saturating_sub(1);
}
OP_GENE_STORE => {
if stack_depth > 0 {
program.push(OP_GENE_STORE);
push_u16(&mut program, rng.r#gen::<u16>());
stack_depth -= 1;
}
}
OP_MUTATE_POINT => {
program.push(OP_MUTATE_POINT);
push_u16(&mut program, rng.r#gen::<u16>());
program.push(rng.r#gen::<u8>());
}
OP_INSERT => {
if stack_depth > 0 && estimated_gene_len < MAX_GENE_SIZE {
program.push(OP_INSERT);
push_u16(&mut program, rng.r#gen::<u16>());
stack_depth -= 1;
estimated_gene_len += 1;
}
}
OP_DELETE => {
program.push(OP_DELETE);
push_u16(&mut program, rng.r#gen::<u16>());
stack_depth += 1;
if estimated_gene_len > 1 {
estimated_gene_len -= 1;
}
}
OP_APPLY_MUTAGEN => {
if stack_depth > 0 {
program.push(OP_APPLY_MUTAGEN);
push_u16(&mut program, rng.r#gen::<u16>());
push_u16(&mut program, rng.r#gen::<u16>());
stack_depth -= 1;
}
}
OP_CONSUME => {
if stack_depth > 0 {
program.push(OP_CONSUME);
push_u16(&mut program, rng.r#gen::<u16>());
}
}
OP_PRODUCE if stack_depth > 0 => {
program.push(OP_PRODUCE);
push_u16(&mut program, rng.r#gen::<u16>());
}
_ => {}
}
}
while hash_ops_needed > 0 && program.len() < MAX_MUTATION_PROGRAM_BYTES {
program.push(OP_FINALIZE_GENE_HASH);
hash_ops_needed -= 1;
}
MutationOrder { step, program }
}
pub fn apply_program_clone(state: &GeneState, program: &[u8]) -> Result<GeneState, MutationError> {
apply_program_clone_with_rounds(state, program, DEFAULT_MUTATION_ROUNDS)
}
pub fn apply_program_clone_with_rounds(
state: &GeneState,
program: &[u8],
rounds: u8,
) -> Result<GeneState, MutationError> {
let mut next = state.clone();
execute_program_with_rounds(&mut next, program, rounds)?;
Ok(next)
}
pub fn apply_program_with_rounds(
state: &mut GeneState,
program: &[u8],
rounds: u8,
) -> Result<(), MutationError> {
let _ = execute_program_with_rounds(state, program, rounds)?;
Ok(())
}
pub fn apply_program(state: &mut GeneState, program: &[u8]) -> Result<(), MutationError> {
let _ = execute_program_with_rounds(state, program, DEFAULT_MUTATION_ROUNDS)?;
Ok(())
}
pub fn execute_program_with_rounds(
state: &mut GeneState,
program: &[u8],
rounds: u8,
) -> Result<ExecutionTrace, MutationError> {
if state.gene.is_empty() {
return Err(MutationError::EmptyGene);
}
validate_state(state)?;
if program.len() > MAX_MUTATION_PROGRAM_BYTES {
return Err(MutationError::ProgramTooLong { len: program.len() });
}
let program_cost = estimate_program_cost(program);
let max_rounds = std::cmp::max(1, MAX_MUTATION_INSTRUCTION_BUDGET / program_cost);
let actual_rounds = std::cmp::min(rounds as usize, max_rounds) as u8;
let start = Instant::now();
let mut trace = None;
for _round in 0..actual_rounds {
trace = Some(execute_program(state, program)?);
}
let elapsed = start.elapsed();
tracing::debug!(
rounds = actual_rounds,
requested_rounds = rounds,
elapsed_ms = elapsed.as_millis(),
program_len = program.len(),
"mutation execution"
);
if actual_rounds < rounds {
tracing::debug!(
requested_rounds = rounds,
executed_rounds = actual_rounds,
"mutation soft cap reduced mutation rounds to preserve host responsiveness"
);
}
if elapsed.as_millis() > SOFT_CAP_DURATION_MS {
tracing::debug!(
elapsed_ms = elapsed.as_millis(),
"mutation execution exceeded soft cap duration"
);
}
Ok(trace.unwrap_or_else(|| ExecutionTrace {
final_ip: 0,
final_stack: Vec::new(),
final_gene_commitment_hex: crate::gene::commitment_hex(state),
}))
}
fn estimate_program_cost(program: &[u8]) -> usize {
let mut ip = 0;
let mut cost = 0;
while ip < program.len() {
let opcode = program[ip];
ip += 1;
cost += if opcode == OP_FINALIZE_GENE_HASH {
HASH_OPCODE_INSTRUCTION_COST
} else {
1
};
ip += match opcode {
OP_GENE_LOAD | OP_GENE_STORE | OP_INSERT | OP_DELETE | OP_CONSUME | OP_PRODUCE => 2,
OP_MUTATE_POINT | OP_TRANSCRIBE => 3,
OP_APPLY_MUTAGEN => 4,
OP_FINALIZE_GENE_HASH => 0,
_ => 0,
}
}
cost.max(1)
}
pub fn execute_program(
state: &mut GeneState,
program: &[u8],
) -> Result<ExecutionTrace, MutationError> {
if state.gene.is_empty() {
return Err(MutationError::EmptyGene);
}
validate_state(state)?;
if program.len() > MAX_MUTATION_PROGRAM_BYTES {
return Err(MutationError::ProgramTooLong { len: program.len() });
}
let mut ip = 0usize;
let mut stack: Vec<u32> = Vec::with_capacity(16);
while ip < program.len() {
let opcode_ip = ip;
let opcode = take_u8(program, &mut ip, 0x00)?;
match opcode {
OP_GENE_LOAD => {
let idx = take_u16(program, &mut ip, opcode)?;
let normalized = normalize_index(idx as usize, state.gene.len());
stack.push(state.gene[normalized] as u32);
}
OP_GENE_STORE => {
let idx = take_u16(program, &mut ip, opcode)?;
let value = pop_stack(&mut stack, opcode, opcode_ip)? as u8;
let normalized = normalize_index(idx as usize, state.gene.len());
state.gene[normalized] = value;
}
OP_MUTATE_POINT => {
let idx = take_u16(program, &mut ip, opcode)?;
let delta = take_u8(program, &mut ip, opcode)? as i8;
let normalized = normalize_index(idx as usize, state.gene.len());
state.gene[normalized] = state.gene[normalized].wrapping_add(delta as u8);
}
OP_INSERT => {
let idx = take_u16(program, &mut ip, opcode)?;
let value = pop_stack(&mut stack, opcode, opcode_ip)? as u8;
if state.gene.len() >= MAX_GENE_SIZE {
return Err(MutationError::GeneFull {
current_len: state.gene.len(),
});
}
let insert_at = (idx as usize).min(state.gene.len());
state.gene.insert(insert_at, value);
}
OP_DELETE => {
let idx = take_u16(program, &mut ip, opcode)?;
let normalized = normalize_index(idx as usize, state.gene.len());
let removed = if state.gene.len() > 1 {
state.gene.remove(normalized)
} else {
let prev = state.gene[0];
state.gene[0] = 0;
prev
};
stack.push(removed as u32);
}
OP_TRANSCRIBE => {
let start = take_u16(program, &mut ip, opcode)?;
let span = take_u8(program, &mut ip, opcode)?;
let transcription = transcribe_window(&state.gene, start as usize, span);
stack.push(transcription);
}
OP_APPLY_MUTAGEN => {
let symbol = take_u16(program, &mut ip, opcode)?;
let idx = take_u16(program, &mut ip, opcode)?;
let stack_mask = pop_stack(&mut stack, opcode, opcode_ip)? as u8;
let quantity = get_env_quantity(state, symbol);
let mix = ((quantity as u8)
^ ((quantity >> 8) as u8)
^ ((quantity >> 16) as u8)
^ ((quantity >> 24) as u8))
^ ((symbol & 0x00ff) as u8)
^ ((symbol >> 8) as u8)
^ stack_mask;
let normalized = normalize_index(idx as usize, state.gene.len());
state.gene[normalized] ^= mix;
}
OP_FINALIZE_GENE_HASH => {
let commit = crate::gene::commitment(state);
let hash32 = u32::from_le_bytes([commit[0], commit[1], commit[2], commit[3]]);
stack.push(hash32);
}
OP_CONSUME => {
let symbol = take_u16(program, &mut ip, opcode)?;
let amount = pop_stack(&mut stack, opcode, opcode_ip)?;
let left = sub_env_quantity(state, symbol, amount)?;
stack.push(left);
}
OP_PRODUCE => {
let symbol = take_u16(program, &mut ip, opcode)?;
let amount = pop_stack(&mut stack, opcode, opcode_ip)?;
let next = add_env_quantity(state, symbol, amount)?;
stack.push(next);
}
_ => return Err(MutationError::UnknownOpcode(opcode)),
}
}
Ok(ExecutionTrace {
final_ip: ip,
final_stack: stack,
final_gene_commitment_hex: crate::gene::commitment_hex(state),
})
}
fn transcribe_window(gene: &[u8], start: usize, span: u8) -> u32 {
let count = usize::from(span.max(1));
let mut acc = 2_166_136_261u32; // FNV offset basis
for i in 0..count {
let idx = (start + i) % gene.len();
acc ^= gene[idx] as u32;
acc = acc.wrapping_mul(16_777_619); // FNV prime
}
acc
}
fn push_u16(buf: &mut Vec<u8>, value: u16) {
buf.extend_from_slice(&value.to_le_bytes());
}
fn take_u8(bytes: &[u8], ip: &mut usize, opcode: u8) -> Result<u8, MutationError> {
if *ip >= bytes.len() {
return Err(MutationError::TruncatedInstruction { opcode, ip: *ip });
}
let value = bytes[*ip];
*ip += 1;
Ok(value)
}
fn take_u16(bytes: &[u8], ip: &mut usize, opcode: u8) -> Result<u16, MutationError> {
if *ip + 2 > bytes.len() {
return Err(MutationError::TruncatedInstruction { opcode, ip: *ip });
}
let value = u16::from_le_bytes([bytes[*ip], bytes[*ip + 1]]);
*ip += 2;
Ok(value)
}
fn pop_stack(stack: &mut Vec<u32>, opcode: u8, ip: usize) -> Result<u32, MutationError> {
stack
.pop()
.ok_or(MutationError::StackUnderflow { opcode, ip })
}
fn normalize_index(idx: usize, len: usize) -> usize {
idx % len
}
#[cfg(test)]
mod tests {
use super::*;
use crate::gene::{commitment, new_state, set_env_quantity};
use rand::{Rng, SeedableRng};
use std::time::Instant;
fn u16_bytes(v: u16) -> [u8; 2] {
v.to_le_bytes()
}
#[test]
fn test_opcode_gene_load() {
let mut state = new_state(4).unwrap();
state.gene = vec![10, 20, 30, 40];
let trace = execute_program(&mut state, &[OP_GENE_LOAD, 1, 0]).unwrap();
assert_eq!(trace.final_stack, vec![20]);
}
#[test]
fn test_opcode_gene_store() {
let mut state = new_state(4).unwrap();
state.gene = vec![1, 2, 3, 4];
let program = vec![
OP_GENE_LOAD,
0,
0, // stack: [1]
OP_GENE_STORE,
2,
0, // gene[2] <- 1
];
execute_program(&mut state, &program).unwrap();
assert_eq!(state.gene, vec![1, 2, 1, 4]);
}
#[test]
fn test_opcode_mutate_point() {
let mut state = new_state(4).unwrap();
state.gene[0] = 200;
let program = vec![OP_MUTATE_POINT, 0, 0, 100u8];
execute_program(&mut state, &program).unwrap();
assert_eq!(state.gene[0], 44);
}
#[test]
fn test_opcode_insert() {
let mut state = new_state(3).unwrap();
state.gene = vec![10, 20, 30];
let program = vec![
OP_GENE_LOAD,
1,
0, // stack: [20]
OP_INSERT,
0,
0, // insert 20 at position 0
];
execute_program(&mut state, &program).unwrap();
assert_eq!(state.gene, vec![20, 10, 20, 30]);
}
#[test]
fn test_opcode_delete() {
let mut state = new_state(4).unwrap();
state.gene = vec![9, 8, 7, 6];
let trace = execute_program(&mut state, &[OP_DELETE, 2, 0]).unwrap();
assert_eq!(state.gene, vec![9, 8, 6]);
assert_eq!(trace.final_stack, vec![7]);
}
#[test]
fn test_opcode_transcribe() {
let mut state = new_state(5).unwrap();
state.gene = vec![1, 2, 3, 4, 5];
let trace = execute_program(&mut state, &[OP_TRANSCRIBE, 1, 0, 3]).unwrap();
assert_eq!(trace.final_stack.len(), 1);
assert_ne!(trace.final_stack[0], 0);
}
#[test]
fn test_opcode_apply_mutagen() {
let mut state = new_state(4).unwrap();
set_env_quantity(&mut state, 7, 0x1234_5678).unwrap();
state.gene[1] = 0xAA;
let program = vec![
OP_GENE_LOAD,
0,
0, // stack mask source
OP_APPLY_MUTAGEN,
7,
0,
1,
0,
];
execute_program(&mut state, &program).unwrap();
assert_ne!(state.gene[1], 0xAA);
}
#[test]
fn test_opcode_finalize_gene_hash() {
let mut state = new_state(4).unwrap();
let trace = execute_program(&mut state, &[OP_FINALIZE_GENE_HASH]).unwrap();
assert_eq!(trace.final_stack.len(), 1);
}
#[test]
fn test_opcode_consume() {
let mut state = new_state(4).unwrap();
set_env_quantity(&mut state, 3, 100).unwrap();
let program = vec![
OP_GENE_LOAD,
0,
0, // stack = [0]
OP_MUTATE_POINT,
0,
0,
15, // gene[0]=15
OP_GENE_LOAD,
0,
0, // stack=[0,15]
OP_CONSUME,
3,
0, // consume 15
];
let trace = execute_program(&mut state, &program).unwrap();
assert_eq!(get_env_quantity(&state, 3), 85);
assert_eq!(trace.final_stack.last().copied().unwrap(), 85);
}
#[test]
fn test_opcode_produce() {
let mut state = new_state(4).unwrap();
set_env_quantity(&mut state, 9, 5).unwrap();
let program = vec![
OP_GENE_LOAD,
0,
0, // stack [0]
OP_MUTATE_POINT,
0,
0,
10, // gene[0]=10
OP_GENE_LOAD,
0,
0, // stack [0,10]
OP_PRODUCE,
9,
0, // +10
];
let trace = execute_program(&mut state, &program).unwrap();
assert_eq!(get_env_quantity(&state, 9), 15);
assert_eq!(trace.final_stack.last().copied().unwrap(), 15);
}
#[test]
fn test_zero_length_gene_is_rejected() {
let mut state = GeneState {
gene: vec![],
environment: vec![],
};
let err = execute_program(&mut state, &[OP_FINALIZE_GENE_HASH]).unwrap_err();
assert_eq!(err, MutationError::EmptyGene);
}
#[test]
fn test_insert_rejects_max_size_gene() {
let mut state = new_state(MAX_GENE_SIZE).unwrap();
let program = vec![
OP_GENE_LOAD,
0,
0, // push value
OP_INSERT,
0,
0,
];
let err = execute_program(&mut state, &program).unwrap_err();
assert!(matches!(err, MutationError::GeneFull { .. }));
}
#[test]
fn test_invalid_positions_wrap_deterministically() {
let mut state_a = new_state(5).unwrap();
let mut state_b = new_state(5).unwrap();
let max_u16 = u16::MAX;
let [a0, a1] = u16_bytes(max_u16);
let program = vec![OP_MUTATE_POINT, a0, a1, 1];
execute_program(&mut state_a, &program).unwrap();
let wrapped = (max_u16 as usize % 5) as u16;
let [w0, w1] = u16_bytes(wrapped);
let wrapped_program = vec![OP_MUTATE_POINT, w0, w1, 1];
execute_program(&mut state_b, &wrapped_program).unwrap();
assert_eq!(state_a, state_b);
}
#[test]
fn test_quantity_underflow_is_saturating() {
let mut state = new_state(4).unwrap();
set_env_quantity(&mut state, 1, 3).unwrap();
state.gene[0] = 8;
let program = vec![
OP_GENE_LOAD,
0,
0, // 8
OP_CONSUME,
1,
0, // consume 8 from qty 3 => 0
];
let trace = execute_program(&mut state, &program).unwrap();
assert_eq!(get_env_quantity(&state, 1), 0);
assert_eq!(trace.final_stack.last().copied().unwrap(), 0);
}
#[test]
fn test_rejects_unknown_opcode() {
let mut state = new_state(8).unwrap();
let err = execute_program(&mut state, &[0xFF]).unwrap_err();
assert_eq!(err, MutationError::UnknownOpcode(0xFF));
}
#[test]
fn test_rejects_truncated_instruction() {
let mut state = new_state(8).unwrap();
let err = execute_program(&mut state, &[OP_GENE_LOAD, 1]).unwrap_err();
assert!(matches!(err, MutationError::TruncatedInstruction { .. }));
}
#[test]
fn test_rejects_stack_underflow() {
let mut state = new_state(8).unwrap();
let err = execute_program(&mut state, &[OP_GENE_STORE, 0, 0]).unwrap_err();
assert!(matches!(err, MutationError::StackUnderflow { .. }));
}
#[test]
fn test_base64_order_roundtrip() {
let order = MutationOrder {
step: 17,
program: vec![OP_GENE_LOAD, 1, 0, OP_GENE_STORE, 2, 0],
};
let b64 = encode_order_b64(&order);
let decoded = decode_order_b64(order.step, &b64).unwrap();
assert_eq!(decoded, order);
}
#[test]
fn test_generate_order_is_deterministic_for_seeded_rng() {
let mut rng_a = rand::rngs::StdRng::seed_from_u64(99);
let mut rng_b = rand::rngs::StdRng::seed_from_u64(99);
let order_a = generate_order_with_rng(&mut rng_a, 5, 64);
let order_b = generate_order_with_rng(&mut rng_b, 5, 64);
assert_eq!(order_a, order_b);
}
#[test]
fn test_mutation_chain() {
let mut server_state = new_state(32).unwrap();
let mut client_state = new_state(32).unwrap();
let program = vec![
OP_GENE_LOAD,
0,
0,
OP_PRODUCE,
2,
0, // env[2]+=gene[0]
OP_GENE_LOAD,
1,
0,
OP_APPLY_MUTAGEN,
2,
0,
1,
0, // mutagen at idx1
OP_TRANSCRIBE,
0,
0,
8, // hash window
OP_GENE_STORE,
2,
0, // gene[2]=transcription_low_byte
OP_DELETE,
0,
0, // stack pushes removed
OP_INSERT,
3,
0, // insert removed at position 3
OP_FINALIZE_GENE_HASH,
];
let server_trace = execute_program(&mut server_state, &program).unwrap();
let client_trace = execute_program(&mut client_state, &program).unwrap();
assert_eq!(server_state, client_state);
assert_eq!(server_trace.final_stack, client_trace.final_stack);
assert_eq!(
server_trace.final_gene_commitment_hex,
client_trace.final_gene_commitment_hex
);
}
#[test]
fn test_server_client_parity_across_random_orders() {
let mut rng = rand::rngs::StdRng::seed_from_u64(7);
for step in 0..128u64 {
let order = generate_order_with_rng(&mut rng, step, 128);
let mut server_state = new_state(128).unwrap();
let mut client_state = new_state(128).unwrap();
let server_result = execute_program(&mut server_state, &order.program);
let client_result = execute_program(&mut client_state, &order.program);
assert_eq!(server_result.is_ok(), client_result.is_ok());
match (server_result, client_result) {
(Ok(server_trace), Ok(client_trace)) => {
assert_eq!(server_state, client_state);
assert_eq!(server_trace.final_stack, client_trace.final_stack);
assert_eq!(
commitment(&server_state),
commitment(&client_state),
"step {step}"
);
}
(Err(a), Err(b)) => assert_eq!(a.to_string(), b.to_string()),
_ => unreachable!(),
}
}
}
#[test]
fn test_fuzz_style_random_program_bytes_do_not_diverge() {
let mut rng = rand::rngs::StdRng::seed_from_u64(2026);
for _ in 0..256 {
let len = rng.gen_range(1usize..=MAX_MUTATION_PROGRAM_BYTES);
let mut program = vec![0u8; len];
for b in &mut program {
*b = rng.r#gen::<u8>();
}
let mut a = new_state(64).unwrap();
let mut b = new_state(64).unwrap();
let ra = execute_program(&mut a, &program);
let rb = execute_program(&mut b, &program);
assert_eq!(ra.is_ok(), rb.is_ok());
if ra.is_ok() {
assert_eq!(a, b);
}
}
}
#[test]
fn test_performance_smoke_mutation_execution() {
let mut rng = rand::rngs::StdRng::seed_from_u64(11);
let mut programs = Vec::new();
for step in 0..200u64 {
programs.push(generate_order_with_rng(&mut rng, step + 1, 512).program);
}
let start = Instant::now();
let mut state = new_state(512).unwrap();
for program in &programs {
let _ = execute_program(&mut state, program);
}
let elapsed = start.elapsed();
// Wide bound for CI variability; this is a regression guard, not a strict benchmark.
assert!(
elapsed.as_secs_f64() < 2.0,
"mutation execution too slow: {elapsed:?}"
);
}
}
+3 -2
View File
@@ -1,10 +1,10 @@
[package] [package]
name = "chronoseal-wasm" name = "chronoseal-wasm"
version = "0.2.0" version = "0.6.0"
edition = "2021" edition = "2021"
[lib] [lib]
crate-type = ["cdylib"] crate-type = ["cdylib", "rlib"]
[dependencies] [dependencies]
shared = { path = "../shared" } shared = { path = "../shared" }
@@ -18,3 +18,4 @@ getrandom = { version = "0.2", features = ["js"] }
hex = "0.4" hex = "0.4"
base64 = "0.22" base64 = "0.22"
serde-wasm-bindgen = "0.6" serde-wasm-bindgen = "0.6"
tracing = "0.1"
+1 -1
View File
@@ -1,2 +1,2 @@
// Example: break debugger detection, console clearing, etc. // Example: break debugger detection, console clearing, etc.
// Currently empty. // Currently empty.
+4 -6
View File
@@ -3,7 +3,7 @@ use std::cell::RefCell;
use wasm_bindgen::prelude::*; use wasm_bindgen::prelude::*;
thread_local! { thread_local! {
static KEYPAIR: RefCell<Option<SigningKey>> = RefCell::new(None); static KEYPAIR: RefCell<Option<SigningKey>> = const { RefCell::new(None) };
} }
#[wasm_bindgen] #[wasm_bindgen]
@@ -51,10 +51,8 @@ pub fn compute_next_hash(
) -> String { ) -> String {
let prev = hex::decode(prev_hash_hex).unwrap_or_default(); let prev = hex::decode(prev_hash_hex).unwrap_or_default();
let salt = hex::decode(salt_hex).unwrap_or_default(); let salt = hex::decode(salt_hex).unwrap_or_default();
let entropy = let entropy = serde_json::from_str::<shared::protocol::EntropyData>(entropy_data_json).unwrap();
serde_json::from_str::<shared::protocol::EntropyData>(entropy_data_json).unwrap(); let stack = serde_json::from_str::<shared::protocol::StackState>(stack_state_json).unwrap();
let stack =
serde_json::from_str::<shared::protocol::StackState>(stack_state_json).unwrap();
let new = shared::hashing::next_chain_hash(&prev, timestamp, &entropy, &stack, &salt); let new = shared::hashing::next_chain_hash(&prev, timestamp, &entropy, &stack, &salt);
hex::encode(new) hex::encode(new)
} }
+1 -1
View File
@@ -1,2 +1,2 @@
// This module is handled on the JS side; WASM only receives the prepared entropy data. // This module is handled on the JS side; WASM only receives the prepared entropy data.
// Could be used to add extra entropy sources (e.g., from JS via import). // Could be used to add extra entropy sources (e.g., from JS via import).
+1 -1
View File
@@ -1 +1 @@
// Fingerprint collection is done in JS, this module is a placeholder. // Fingerprint collection is done in JS, this module is a placeholder.
+2 -1
View File
@@ -3,4 +3,5 @@ pub mod crypto;
pub mod entropy; pub mod entropy;
pub mod fingerprint; pub mod fingerprint;
pub mod transport; pub mod transport;
pub mod vm; pub mod vm;
pub mod vm_extensions;
+1 -1
View File
@@ -1 +1 @@
// Could contain WebTransport related code if needed later. // Could contain WebTransport related code if needed later.
+156 -8
View File
@@ -1,10 +1,12 @@
use wasm_bindgen::prelude::*;
use shared::protocol::StackState; use shared::protocol::StackState;
use wasm_bindgen::prelude::*;
#[wasm_bindgen] #[wasm_bindgen]
pub fn run_program(program_b64: &str) -> JsValue { pub fn run_program(program_b64: &str) -> JsValue {
use base64::Engine; use base64::Engine;
let bytes = base64::engine::general_purpose::STANDARD.decode(program_b64).unwrap(); let bytes = base64::engine::general_purpose::STANDARD
.decode(program_b64)
.unwrap();
let state = execute(&bytes); let state = execute(&bytes);
serde_wasm_bindgen::to_value(&state).unwrap() serde_wasm_bindgen::to_value(&state).unwrap()
} }
@@ -17,13 +19,22 @@ fn execute(program: &[u8]) -> StackState {
ip += 1; ip += 1;
match op { match op {
0x00 => { 0x00 => {
if ip + 4 > program.len() { break; } if ip + 4 > program.len() {
let val = u32::from_le_bytes([program[ip], program[ip+1], program[ip+2], program[ip+3]]); break;
}
let val = u32::from_le_bytes([
program[ip],
program[ip + 1],
program[ip + 2],
program[ip + 3],
]);
ip += 4; ip += 4;
stack.push(val); stack.push(val);
} }
0x01..=0x07 => { 0x01..=0x07 => {
if stack.len() < 2 { break; } if stack.len() < 2 {
break;
}
let b = stack.pop().unwrap(); let b = stack.pop().unwrap();
let a = stack.pop().unwrap(); let a = stack.pop().unwrap();
let r = match op { let r = match op {
@@ -39,7 +50,9 @@ fn execute(program: &[u8]) -> StackState {
stack.push(r); stack.push(r);
} }
0x08 => { 0x08 => {
if stack.is_empty() { break; } if stack.is_empty() {
break;
}
let a = stack.pop().unwrap(); let a = stack.pop().unwrap();
stack.push(!a); stack.push(!a);
} }
@@ -51,5 +64,140 @@ fn execute(program: &[u8]) -> StackState {
_ => break, _ => break,
} }
} }
StackState { stack, ip: ip as u16 } StackState {
} stack,
ip: ip as u16,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_push() {
// PUSH 42, PUSH 100
let program = vec![0x00, 42, 0, 0, 0, 0x00, 100, 0, 0, 0];
let state = execute(&program);
assert_eq!(state.stack, vec![42, 100]);
assert_eq!(state.ip, 10);
}
#[test]
fn test_add() {
// PUSH 5, PUSH 10, ADD
let program = vec![0x00, 5, 0, 0, 0, 0x00, 10, 0, 0, 0, 0x01];
let state = execute(&program);
assert_eq!(state.stack, vec![15]);
}
#[test]
fn test_add_wrapping() {
// PUSH u32::MAX, PUSH 1, ADD
let program = vec![0x00, 0xff, 0xff, 0xff, 0xff, 0x00, 1, 0, 0, 0, 0x01];
let state = execute(&program);
assert_eq!(state.stack, vec![0]);
}
#[test]
fn test_sub() {
// PUSH 20, PUSH 7, SUB
let program = vec![0x00, 20, 0, 0, 0, 0x00, 7, 0, 0, 0, 0x02];
let state = execute(&program);
assert_eq!(state.stack, vec![13]);
}
#[test]
fn test_sub_wrapping() {
// PUSH 0, PUSH 1, SUB
let program = vec![0x00, 0, 0, 0, 0, 0x00, 1, 0, 0, 0, 0x02];
let state = execute(&program);
assert_eq!(state.stack, vec![u32::MAX]);
}
#[test]
fn test_mul() {
// PUSH 6, PUSH 7, MUL
let program = vec![0x00, 6, 0, 0, 0, 0x00, 7, 0, 0, 0, 0x03];
let state = execute(&program);
assert_eq!(state.stack, vec![42]);
}
#[test]
fn test_xor() {
// PUSH 0b1010, PUSH 0b1100, XOR
let program = vec![0x00, 0b1010, 0, 0, 0, 0x00, 0b1100, 0, 0, 0, 0x04];
let state = execute(&program);
assert_eq!(state.stack, vec![0b0110]);
}
#[test]
fn test_and() {
// PUSH 0b1010, PUSH 0b1100, AND
let program = vec![0x00, 0b1010, 0, 0, 0, 0x00, 0b1100, 0, 0, 0, 0x05];
let state = execute(&program);
assert_eq!(state.stack, vec![0b1000]);
}
#[test]
fn test_or() {
// PUSH 0b1010, PUSH 0b1100, OR
let program = vec![0x00, 0b1010, 0, 0, 0, 0x00, 0b1100, 0, 0, 0, 0x06];
let state = execute(&program);
assert_eq!(state.stack, vec![0b1110]);
}
#[test]
fn test_rot() {
// PUSH 1, PUSH 4, ROT
let program = vec![0x00, 1, 0, 0, 0, 0x00, 4, 0, 0, 0, 0x07];
let state = execute(&program);
assert_eq!(state.stack, vec![16]);
}
#[test]
fn test_not() {
// PUSH 0, NOT
let program = vec![0x00, 0, 0, 0, 0, 0x08];
let state = execute(&program);
assert_eq!(state.stack, vec![u32::MAX]);
}
#[test]
fn test_hash() {
// PUSH 10, PUSH 20, HASH
let program = vec![0x00, 10, 0, 0, 0, 0x00, 20, 0, 0, 0, 0x09];
let state = execute(&program);
assert_eq!(state.stack.len(), 1);
let expected_hash = shared::hashing::hash_stack(&[10, 20]);
assert_eq!(state.stack[0], expected_hash);
}
#[test]
fn test_underflow_binary() {
// PUSH 42, ADD (needs 2 values, only 1 on stack)
let program = vec![0x00, 42, 0, 0, 0, 0x01];
let state = execute(&program);
// ADD breaks when stack.len() < 2, stack has 42 left, ip is at the opcode ADD (6)
assert_eq!(state.stack, vec![42]);
assert_eq!(state.ip, 6);
}
#[test]
fn test_underflow_unary() {
// NOT (needs 1 value, empty stack)
let program = vec![0x08];
let state = execute(&program);
assert_eq!(state.stack, Vec::<u32>::new());
assert_eq!(state.ip, 1);
}
#[test]
fn test_incomplete_push() {
// PUSH opcode, but only 2 bytes instead of 4
let program = vec![0x00, 42, 0];
let state = execute(&program);
assert_eq!(state.stack, Vec::<u32>::new());
assert_eq!(state.ip, 1); // execution stopped at op 0x00 because ip + 4 > program.len()
}
}
+226
View File
@@ -0,0 +1,226 @@
use std::cell::RefCell;
use std::time::Instant;
use wasm_bindgen::prelude::*;
thread_local! {
static GENE_STATE: RefCell<Option<shared::gene::GeneState>> = const { RefCell::new(None) };
static PREVIEW_STATE: RefCell<Option<shared::gene::GeneState>> = const { RefCell::new(None) };
}
#[wasm_bindgen]
pub fn init_gene_state(gene_size: u32) -> bool {
let Ok(state) = shared::gene::new_state(gene_size as usize) else {
return false;
};
GENE_STATE.with(|slot| *slot.borrow_mut() = Some(state));
PREVIEW_STATE.with(|slot| *slot.borrow_mut() = None);
true
}
#[wasm_bindgen]
pub fn preview_gene_commitment(
order_b64: &str,
session_id: &str,
mutation_step: u64,
rounds: u8,
) -> String {
let order = match shared::vm_extensions::decode_order_b64(mutation_step, order_b64) {
Ok(order) => order,
Err(_) => return String::new(),
};
let start = Instant::now();
let candidate = GENE_STATE.with(|slot| {
let state = slot.borrow();
let current = state.as_ref()?;
shared::vm_extensions::apply_program_clone_with_rounds(
current,
&order.program,
if rounds == 0 {
shared::constants::DEFAULT_MUTATION_ROUNDS
} else {
rounds
},
)
.ok()
});
let elapsed = start.elapsed();
tracing::debug!(session_id = %session_id, mutation_step = mutation_step, elapsed_ms = elapsed.as_millis(), "wasm mutation preview execution");
let Some(candidate) = candidate else {
return String::new();
};
let commitment =
shared::gene::commitment_hex_with_context(&candidate, session_id, mutation_step);
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(session_id: &str, mutation_step: u64) -> String {
GENE_STATE.with(|slot| {
slot.borrow()
.as_ref()
.map(|state| {
shared::gene::commitment_hex_with_context(state, session_id, mutation_step)
})
.unwrap_or_default()
})
}
#[cfg(test)]
mod tests {
use super::*;
use rand::SeedableRng;
fn order_b64(program: Vec<u8>) -> String {
let order = shared::vm_extensions::MutationOrder { step: 1, program };
shared::vm_extensions::encode_order_b64(&order)
}
#[test]
fn test_init_gene_state_success() {
assert!(init_gene_state(64));
let commitment = current_gene_commitment("deadbeef", 1);
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]),
"deadbeef",
1,
0,
);
assert!(c.is_empty());
}
#[test]
fn test_preview_rejects_invalid_order() {
init_gene_state(16);
let c = preview_gene_commitment("***bad-base64***", "deadbeef", 1, 0);
assert!(c.is_empty());
}
#[test]
fn test_commit_applies_preview() {
init_gene_state(16);
let before = current_gene_commitment("deadbeef", 1);
let order = order_b64(vec![shared::vm_extensions::OP_MUTATE_POINT, 0, 0, 1]);
let preview = preview_gene_commitment(&order, "deadbeef", 1, 0);
assert_ne!(preview, before);
assert!(commit_gene_preview());
let after = current_gene_commitment("deadbeef", 1);
assert_eq!(preview, after);
}
#[test]
fn test_discard_preview_keeps_committed_state() {
init_gene_state(16);
let before = current_gene_commitment("deadbeef", 1);
let order = order_b64(vec![shared::vm_extensions::OP_MUTATE_POINT, 0, 0, 0xFF]);
let preview = preview_gene_commitment(&order, "deadbeef", 1, 0);
assert_ne!(preview, before);
discard_gene_preview();
let after = current_gene_commitment("deadbeef", 1);
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, "deadbeef", 3, 0);
let mut expected = shared::gene::new_state(16).unwrap();
shared::vm_extensions::apply_program_with_rounds(
&mut expected,
&order.program,
shared::constants::DEFAULT_MUTATION_ROUNDS,
)
.unwrap();
assert_eq!(
preview,
shared::gene::commitment_hex_with_context(&expected, "deadbeef", 3)
);
}
#[test]
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, "deadbeef", step + 1, 0);
shared::vm_extensions::apply_program_with_rounds(
&mut expected,
&order.program,
shared::constants::DEFAULT_MUTATION_ROUNDS,
)
.unwrap();
let expected_commitment =
shared::gene::commitment_hex_with_context(&expected, "deadbeef", step + 1);
assert_eq!(preview, expected_commitment);
assert!(commit_gene_preview());
assert_eq!(
current_gene_commitment("deadbeef", step + 1),
expected_commitment
);
}
}
}