Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d965451d4f | ||
|
|
1a58ef9796 | ||
|
|
c7873b429d | ||
|
|
3679e6808b | ||
|
|
fc2d693518 | ||
|
|
0ed3cb444d | ||
|
|
2b8afd54e0 | ||
|
|
6067746898 | ||
|
|
3d2a1a0ad7 | ||
|
|
815d29af4c | ||
|
|
b2835f454f | ||
|
|
4a64a57347 | ||
|
|
9d52828bb6 | ||
|
|
19666bd608 | ||
|
|
089a834f96 | ||
|
|
ba768da58e | ||
|
|
217dc5f92b | ||
|
|
0b43530651 | ||
|
|
e0f6d7c72c | ||
|
|
9e78daeeba | ||
|
|
60aaf0cd96 | ||
|
|
90ab322332 | ||
|
|
eff782847d | ||
|
|
ed0458633b | ||
|
|
3edea4bdff | ||
|
|
2ac0afa691 | ||
|
|
cbe12bcd49 | ||
|
|
f21146e2f2 | ||
|
|
7972123887 | ||
|
|
630c451688 | ||
|
|
8559e023be | ||
|
|
75cb978fba | ||
|
|
8d318d5da4 | ||
|
|
d567655645 | ||
|
|
4fb4022188 | ||
|
|
256f12023e | ||
|
|
2840ddfc58 | ||
|
|
4b27a342d1 | ||
|
|
851d3b4876 | ||
|
|
ac57752ec2 | ||
|
|
ebfbbf9901 | ||
|
|
b866471825 | ||
|
|
f95b3c0d4a |
No files matched your search
@@ -5,3 +5,5 @@ dist/
|
||||
*.log
|
||||
.env
|
||||
.idea/
|
||||
|
||||
.antigravitycli/
|
||||
@@ -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
+1768
-24
File diff suppressed because it is too large.
Load diff
@@ -6,3 +6,6 @@ members = [
|
||||
"server",
|
||||
"wasm"
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
license = "MIT OR Apache-2.0"
|
||||
+7
-3
@@ -4,7 +4,7 @@ WORKDIR /app
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN cargo build -p server --release
|
||||
RUN cargo build -p chronoseal-server --bin chronoseal --release
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
@@ -14,10 +14,14 @@ RUN apt-get update && apt-get install -y \
|
||||
|
||||
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
|
||||
|
||||
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"]
|
||||
@@ -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
@@ -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
@@ -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>.
|
||||
@@ -1,236 +1,748 @@
|
||||
# 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
|
||||
|
||||
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.
|
||||
|
||||
```
|
||||
Browser Server
|
||||
│ │
|
||||
│ WASM loads, generates Ed25519 keypair │
|
||||
│ Private key never leaves WASM memory │
|
||||
│ │
|
||||
├──── POST /init { public_key } ──────────►│ Store session, salt, initial hash
|
||||
│◄─── { session_id, salt, opcodes, H0 } ────┤
|
||||
│ │
|
||||
│ Every 12–25s (jittered): │
|
||||
│ ┌─ Collect mouse entropy │
|
||||
│ ├─ Execute VM opcodes → stack state │
|
||||
│ ├─ Compute H(n) = Blake3(H(n-1) ║ …) │
|
||||
│ └─ Sign payload with Ed25519 │
|
||||
│ │
|
||||
├──── POST /hb { session_id, sig, … } ────►│ Verify sig → chain → behavior → fingerprint
|
||||
│◄─── { status, next_salt } ────────────────┤ Rotate salt, advance chain
|
||||
│ │
|
||||
│ On failure: server returns {"status":"ok"}│ Silent rejection — indistinguishable
|
||||
1. The browser loads the generated WASM package from `frontend/pkg`.
|
||||
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.
|
||||
4. The server creates a session and returns:
|
||||
- `session_id`
|
||||
- initial salt
|
||||
- initial hash chain head
|
||||
- VM opcode program
|
||||
- gene size
|
||||
- mutation step
|
||||
- mutation order
|
||||
- heartbeat timing bounds
|
||||
5. For each heartbeat, the browser:
|
||||
- executes the VM opcode program
|
||||
- collects entropy and browser signal data
|
||||
- previews the next synthetic gene commitment in WASM
|
||||
- signs the canonical heartbeat payload
|
||||
- posts the request to `POST /hb`
|
||||
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 |
|
||||
|---|---|
|
||||
| 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 |
|
||||
For a full native install:
|
||||
|
||||
### What ChronoSeal does not claim
|
||||
|
||||
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
|
||||
```bash
|
||||
sudo bash scripts/install.sh
|
||||
```
|
||||
|
||||
### 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.
|
||||
|
||||
| Opcode | Mnemonic | Effect |
|
||||
|--------|----------|--------|
|
||||
| `0x00` | PUSH u32 | Push 4-byte little-endian literal |
|
||||
| `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) )
|
||||
```bash
|
||||
chronoseal status --format json
|
||||
chronoseal health
|
||||
chronoseal metrics
|
||||
sudo journalctl -u chronoseal -f
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
The client signs a JSON object with keys sorted alphabetically (matching `JSON.stringify(obj, Object.keys(obj).sort())`):
|
||||
|
||||
```json
|
||||
{
|
||||
"entropyData": { "events": [ { "x": …, "y": …, "t": … } ] },
|
||||
"fingerprint": { "aspectRatio": "…", "devicePixelRatio": "…", "hardwareConcurrency": … },
|
||||
"prevHash": "hex…",
|
||||
"sessionId": "hex…",
|
||||
"stackState": { "stack": […], "ip": … },
|
||||
"timestamp": 1234567890123
|
||||
}
|
||||
```bash
|
||||
bash scripts/build.sh
|
||||
cargo run -p chronoseal-server --bin chronoseal -- run \
|
||||
--bind 127.0.0.1:3000 \
|
||||
--frontend-dir frontend
|
||||
```
|
||||
|
||||
The server reconstructs this using `BTreeMap` (alphabetical key order) before calling `VerifyingKey::verify_strict`.
|
||||
Then open:
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
);
|
||||
```text
|
||||
http://127.0.0.1:3000
|
||||
```
|
||||
|
||||
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)
|
||||
- [`wasm-pack`](https://rustwasm.github.io/wasm-pack/installer/)
|
||||
```bash
|
||||
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
|
||||
wasm-pack build wasm --target web --release
|
||||
rm -rf 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
|
||||
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 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`.
|
||||
|
||||
---
|
||||
|
||||
## Deployment
|
||||
|
||||
### Native + systemd
|
||||
Use the CLI wrappers:
|
||||
|
||||
```bash
|
||||
cargo build -p server --release
|
||||
sudo cp target/release/server /usr/local/bin/chronoseal
|
||||
sudo cp chronoseal.service /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now chronoseal
|
||||
cargo run -p chronoseal-server --bin chronoseal -- status --bind 127.0.0.1:3000
|
||||
cargo run -p chronoseal-server --bin chronoseal -- health --bind 127.0.0.1:3000
|
||||
cargo run -p chronoseal-server --bin chronoseal -- stats --bind 127.0.0.1:3000 --format json
|
||||
```
|
||||
|
||||
### 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
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
### Reverse Proxy
|
||||
The provided container exposes port `3000`.
|
||||
|
||||
Place ChronoSeal behind nginx, Nginx Proxy Manager, or HAProxy. Enable:
|
||||
|
||||
- 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>
|
||||
```bash
|
||||
curl http://127.0.0.1:3000/health
|
||||
```
|
||||
|
||||
`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
|
||||
|
||||
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 |
|
||||
| `SALT_LEN` | 16 bytes | Per-heartbeat salt size |
|
||||
| `HEARTBEAT_MIN_INTERVAL_MS` | 12 000 ms | Minimum heartbeat interval |
|
||||
| `HEARTBEAT_MAX_INTERVAL_MS` | 25 000 ms | Maximum heartbeat interval (uniform jitter) |
|
||||
| `EXPIRATION_MINUTES` | 30 min | Session lifetime after last heartbeat |
|
||||
| `RATE_LIMIT_COUNT` | 5 | Max heartbeats per window |
|
||||
| `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` | Start a browser attestation session |
|
||||
| `POST` | `/hb` | Submit a signed heartbeat |
|
||||
| `GET` | `/health` | Health probe |
|
||||
| `GET` | `/metrics` | Prometheus metrics |
|
||||
| `GET` | `/stats` | Runtime statistics |
|
||||
| `GET` | `/` | Static frontend files from `frontend_dir` |
|
||||
|
||||
---
|
||||
### `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
|
||||
|
||||
[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.
|
||||
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "chronoseal-replay"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
shared = { path = "../shared" }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
hex = "0.4"
|
||||
base64 = "0.22"
|
||||
anyhow = "1"
|
||||
reqwest = { version = "0.12", features = ["blocking", "json"] }
|
||||
rand = "0.8"
|
||||
ed25519-dalek = { version = "2", features = ["rand_core"] }
|
||||
@@ -0,0 +1,551 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use ed25519_dalek::{Signer, SigningKey};
|
||||
use rand::rngs::OsRng;
|
||||
use shared::protocol::{
|
||||
EntropyData, Fingerprint, HeartbeatRequest, HeartbeatResponse, InitRequest, InitResponse,
|
||||
MouseEvent, StackState,
|
||||
};
|
||||
use std::collections::BTreeMap;
|
||||
use std::env;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let args: Vec<String> = env::args().collect();
|
||||
let mut url = "http://127.0.0.1:8080".to_string();
|
||||
let mut scenario_file: Option<String> = None;
|
||||
|
||||
let mut i = 1;
|
||||
while i < args.len() {
|
||||
match args[i].as_str() {
|
||||
"--url" => {
|
||||
if i + 1 < args.len() {
|
||||
url = args[i + 1].clone();
|
||||
i += 2;
|
||||
} else {
|
||||
return Err(anyhow!("Missing value for --url"));
|
||||
}
|
||||
}
|
||||
"--scenario" => {
|
||||
if i + 1 < args.len() {
|
||||
scenario_file = Some(args[i + 1].clone());
|
||||
i += 2;
|
||||
} else {
|
||||
return Err(anyhow!("Missing value for --scenario"));
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let client = reqwest::blocking::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(5))
|
||||
.build()?;
|
||||
|
||||
if let Some(file_path) = scenario_file {
|
||||
println!("Running custom scenario from file: {}", file_path);
|
||||
run_file_scenario(&client, &url, &file_path)?;
|
||||
} else {
|
||||
println!("Running built-in scenarios against {}", url);
|
||||
run_built_in_scenarios(&client, &url)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn current_time_ms() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_millis() as u64
|
||||
}
|
||||
|
||||
fn canonical_signing_message(req: &HeartbeatRequest) -> Result<String> {
|
||||
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)?)
|
||||
}
|
||||
|
||||
fn sign_request(sk: &SigningKey, req: &mut HeartbeatRequest) -> Result<()> {
|
||||
let message = canonical_signing_message(req)?;
|
||||
let sig = sk.sign(message.as_bytes());
|
||||
req.signature = hex::encode(sig.to_bytes());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn test_fingerprint() -> Fingerprint {
|
||||
Fingerprint {
|
||||
aspect_ratio: "1.77".to_string(),
|
||||
device_pixel_ratio: "2.0".to_string(),
|
||||
hardware_concurrency: 8,
|
||||
}
|
||||
}
|
||||
|
||||
fn test_entropy() -> EntropyData {
|
||||
EntropyData {
|
||||
events: vec![
|
||||
MouseEvent {
|
||||
x: 100.0,
|
||||
y: 100.0,
|
||||
timestamp_ms: 10.0,
|
||||
},
|
||||
MouseEvent {
|
||||
x: 105.0,
|
||||
y: 103.0,
|
||||
timestamp_ms: 50.0,
|
||||
},
|
||||
// Pause here (dist = 0.0 < 0.2, dt = 100.0 > 50.0)
|
||||
MouseEvent {
|
||||
x: 105.0,
|
||||
y: 103.0,
|
||||
timestamp_ms: 150.0,
|
||||
},
|
||||
MouseEvent {
|
||||
x: 115.0,
|
||||
y: 103.0,
|
||||
timestamp_ms: 250.0,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
fn do_handshake(client: &reqwest::blocking::Client, base_url: &str, sk: &SigningKey) -> Result<InitResponse> {
|
||||
let pk_hex = hex::encode(sk.verifying_key().to_bytes());
|
||||
let init_req = InitRequest { public_key: pk_hex };
|
||||
let resp = client
|
||||
.post(format!("{}/init", base_url))
|
||||
.json(&init_req)
|
||||
.send()?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(anyhow!("Handshake failed with HTTP status: {}", resp.status()));
|
||||
}
|
||||
|
||||
let init_resp: InitResponse = resp.json()?;
|
||||
Ok(init_resp)
|
||||
}
|
||||
|
||||
fn run_built_in_scenarios(client: &reqwest::blocking::Client, base_url: &str) -> Result<()> {
|
||||
let mut failures = 0;
|
||||
|
||||
let scenarios = [
|
||||
("valid_progression", run_valid_progression as fn(&reqwest::blocking::Client, &str) -> Result<()>),
|
||||
("stale_replay", run_stale_replay),
|
||||
("invalid_signature", run_invalid_signature),
|
||||
("invalid_vm_stack", run_invalid_vm_stack),
|
||||
("invalid_mutation_commitment", run_invalid_mutation_commitment),
|
||||
("drifted_timestamp", run_drifted_timestamp),
|
||||
("concurrent_heartbeat", run_concurrent_heartbeat),
|
||||
("rate_limit_trigger", run_rate_limit_trigger),
|
||||
];
|
||||
|
||||
for (name, func) in scenarios.iter() {
|
||||
println!("--------------------------------------------------");
|
||||
println!("SCENARIO: {}", name);
|
||||
match func(client, base_url) {
|
||||
Ok(_) => {
|
||||
println!("RESULT: SUCCESS");
|
||||
}
|
||||
Err(e) => {
|
||||
println!("RESULT: FAILED ({})", e);
|
||||
failures += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if failures > 0 {
|
||||
Err(anyhow!("{} scenarios failed", failures))
|
||||
} else {
|
||||
println!("All built-in scenarios completed successfully!");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn run_valid_progression(client: &reqwest::blocking::Client, base_url: &str) -> Result<()> {
|
||||
let mut csprng = OsRng;
|
||||
let sk = SigningKey::generate(&mut csprng);
|
||||
|
||||
let init = do_handshake(client, base_url, &sk)?;
|
||||
println!("Session initialized: {}", init.session_id);
|
||||
|
||||
let mut prev_hash = init.initial_hash.clone();
|
||||
let mut current_salt = init.salt.clone();
|
||||
let mut mutation_step = init.mutation_step;
|
||||
let mut mutation_order_b64 = init.mutation_order_b64.clone();
|
||||
let mut gene_state = shared::gene::new_state(init.gene_size as usize).unwrap();
|
||||
|
||||
let opcodes = base64::Engine::decode(
|
||||
&base64::engine::general_purpose::STANDARD,
|
||||
&init.opcodes_b64,
|
||||
)?;
|
||||
let stack_state = shared::vm::execute(&opcodes);
|
||||
|
||||
// Let's run 3 valid progression steps
|
||||
for step in 1..=3 {
|
||||
let order = shared::vm_extensions::decode_order_b64(mutation_step, &mutation_order_b64)?;
|
||||
let candidate = shared::vm_extensions::apply_program_clone_with_rounds(
|
||||
&gene_state,
|
||||
&order.program,
|
||||
init.mutation_rounds,
|
||||
)?;
|
||||
|
||||
let commitment = shared::gene::commitment_hex_with_context(&candidate, &init.session_id, mutation_step);
|
||||
let timestamp = current_time_ms();
|
||||
let entropy = test_entropy();
|
||||
|
||||
let mut req = HeartbeatRequest {
|
||||
session_id: init.session_id.clone(),
|
||||
prev_hash: prev_hash.clone(),
|
||||
timestamp,
|
||||
entropy_data: entropy.clone(),
|
||||
stack_state: stack_state.clone(),
|
||||
fingerprint: test_fingerprint(),
|
||||
mutation_step,
|
||||
gene_commitment: commitment,
|
||||
signature: String::new(),
|
||||
};
|
||||
|
||||
sign_request(&sk, &mut req)?;
|
||||
|
||||
let resp = client
|
||||
.post(format!("{}/hb", base_url))
|
||||
.json(&req)
|
||||
.send()?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(anyhow!("Step {} /hb returned HTTP error: {}", step, resp.status()));
|
||||
}
|
||||
|
||||
let hb_resp: HeartbeatResponse = resp.json()?;
|
||||
if hb_resp.status != "ok" {
|
||||
return Err(anyhow!("Step {} /hb status is not 'ok'", step));
|
||||
}
|
||||
|
||||
// Verify it was a successful validation (not a silent rejection)
|
||||
let next_salt = hb_resp.next_salt.ok_or_else(|| anyhow!("Step {} was silently rejected", step))?;
|
||||
let next_step = hb_resp.next_mutation_step.ok_or_else(|| anyhow!("Step {} missing next mutation step", step))?;
|
||||
let next_order = hb_resp.next_mutation_order_b64.ok_or_else(|| anyhow!("Step {} missing next mutation order", step))?;
|
||||
|
||||
println!("Step {} successful. Salt rotated: {}", step, next_salt);
|
||||
|
||||
// Advance client state
|
||||
let salt_bytes = hex::decode(¤t_salt)?;
|
||||
let prev_hash_bytes = hex::decode(&prev_hash)?;
|
||||
let next_hash = shared::hashing::next_chain_hash(
|
||||
&prev_hash_bytes,
|
||||
timestamp,
|
||||
&entropy,
|
||||
&stack_state,
|
||||
&salt_bytes,
|
||||
);
|
||||
|
||||
prev_hash = hex::encode(next_hash);
|
||||
current_salt = next_salt;
|
||||
mutation_step = next_step;
|
||||
mutation_order_b64 = next_order;
|
||||
gene_state = candidate;
|
||||
|
||||
// Sleep briefly to satisfy timing drift
|
||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_stale_replay(client: &reqwest::blocking::Client, base_url: &str) -> Result<()> {
|
||||
let mut csprng = OsRng;
|
||||
let sk = SigningKey::generate(&mut csprng);
|
||||
let init = do_handshake(client, base_url, &sk)?;
|
||||
|
||||
let opcodes = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, &init.opcodes_b64)?;
|
||||
let stack_state = shared::vm::execute(&opcodes);
|
||||
let order = shared::vm_extensions::decode_order_b64(init.mutation_step, &init.mutation_order_b64)?;
|
||||
let gene_state = shared::gene::new_state(init.gene_size as usize).unwrap();
|
||||
let candidate = shared::vm_extensions::apply_program_clone_with_rounds(&gene_state, &order.program, init.mutation_rounds)?;
|
||||
let commitment = shared::gene::commitment_hex_with_context(&candidate, &init.session_id, init.mutation_step);
|
||||
|
||||
let mut req = HeartbeatRequest {
|
||||
session_id: init.session_id.clone(),
|
||||
prev_hash: init.initial_hash.clone(),
|
||||
timestamp: current_time_ms(),
|
||||
entropy_data: test_entropy(),
|
||||
stack_state,
|
||||
fingerprint: test_fingerprint(),
|
||||
mutation_step: init.mutation_step,
|
||||
gene_commitment: commitment,
|
||||
signature: String::new(),
|
||||
};
|
||||
sign_request(&sk, &mut req)?;
|
||||
|
||||
// First request should succeed
|
||||
let resp1 = client.post(format!("{}/hb", base_url)).json(&req).send()?;
|
||||
let hb1: HeartbeatResponse = resp1.json()?;
|
||||
if hb1.next_salt.is_none() {
|
||||
return Err(anyhow!("Initial heartbeat request failed"));
|
||||
}
|
||||
|
||||
// Replay exact same request. Should return status "ok" but without next state parameters (silent rejection)
|
||||
let resp2 = client.post(format!("{}/hb", base_url)).json(&req).send()?;
|
||||
let hb2: HeartbeatResponse = resp2.json()?;
|
||||
if hb2.next_salt.is_some() {
|
||||
return Err(anyhow!("Replayed heartbeat was successfully accepted (broken replay protection)"));
|
||||
}
|
||||
|
||||
println!("Stale replay correctly rejected.");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_invalid_signature(client: &reqwest::blocking::Client, base_url: &str) -> Result<()> {
|
||||
let mut csprng = OsRng;
|
||||
let sk = SigningKey::generate(&mut csprng);
|
||||
let init = do_handshake(client, base_url, &sk)?;
|
||||
|
||||
let opcodes = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, &init.opcodes_b64)?;
|
||||
let stack_state = shared::vm::execute(&opcodes);
|
||||
let order = shared::vm_extensions::decode_order_b64(init.mutation_step, &init.mutation_order_b64)?;
|
||||
let gene_state = shared::gene::new_state(init.gene_size as usize).unwrap();
|
||||
let candidate = shared::vm_extensions::apply_program_clone_with_rounds(&gene_state, &order.program, init.mutation_rounds)?;
|
||||
let commitment = shared::gene::commitment_hex_with_context(&candidate, &init.session_id, init.mutation_step);
|
||||
|
||||
let mut req = HeartbeatRequest {
|
||||
session_id: init.session_id.clone(),
|
||||
prev_hash: init.initial_hash.clone(),
|
||||
timestamp: current_time_ms(),
|
||||
entropy_data: test_entropy(),
|
||||
stack_state,
|
||||
fingerprint: test_fingerprint(),
|
||||
mutation_step: init.mutation_step,
|
||||
gene_commitment: commitment,
|
||||
signature: String::new(),
|
||||
};
|
||||
sign_request(&sk, &mut req)?;
|
||||
req.signature = "00".repeat(64); // corrupt signature
|
||||
|
||||
let resp = client.post(format!("{}/hb", base_url)).json(&req).send()?;
|
||||
let hb: HeartbeatResponse = resp.json()?;
|
||||
if hb.next_salt.is_some() {
|
||||
return Err(anyhow!("Invalid signature was accepted"));
|
||||
}
|
||||
|
||||
println!("Invalid signature correctly rejected.");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_invalid_vm_stack(client: &reqwest::blocking::Client, base_url: &str) -> Result<()> {
|
||||
let mut csprng = OsRng;
|
||||
let sk = SigningKey::generate(&mut csprng);
|
||||
let init = do_handshake(client, base_url, &sk)?;
|
||||
|
||||
let order = shared::vm_extensions::decode_order_b64(init.mutation_step, &init.mutation_order_b64)?;
|
||||
let gene_state = shared::gene::new_state(init.gene_size as usize).unwrap();
|
||||
let candidate = shared::vm_extensions::apply_program_clone_with_rounds(&gene_state, &order.program, init.mutation_rounds)?;
|
||||
let commitment = shared::gene::commitment_hex_with_context(&candidate, &init.session_id, init.mutation_step);
|
||||
|
||||
let mut req = HeartbeatRequest {
|
||||
session_id: init.session_id.clone(),
|
||||
prev_hash: init.initial_hash.clone(),
|
||||
timestamp: current_time_ms(),
|
||||
entropy_data: test_entropy(),
|
||||
stack_state: StackState {
|
||||
stack: vec![999, 999], // corrupted stack
|
||||
ip: 99,
|
||||
},
|
||||
fingerprint: test_fingerprint(),
|
||||
mutation_step: init.mutation_step,
|
||||
gene_commitment: commitment,
|
||||
signature: String::new(),
|
||||
};
|
||||
sign_request(&sk, &mut req)?;
|
||||
|
||||
let resp = client.post(format!("{}/hb", base_url)).json(&req).send()?;
|
||||
let hb: HeartbeatResponse = resp.json()?;
|
||||
if hb.next_salt.is_some() {
|
||||
return Err(anyhow!("Invalid VM stack was accepted"));
|
||||
}
|
||||
|
||||
println!("Invalid VM stack correctly rejected.");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_invalid_mutation_commitment(client: &reqwest::blocking::Client, base_url: &str) -> Result<()> {
|
||||
let mut csprng = OsRng;
|
||||
let sk = SigningKey::generate(&mut csprng);
|
||||
let init = do_handshake(client, base_url, &sk)?;
|
||||
|
||||
let opcodes = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, &init.opcodes_b64)?;
|
||||
let stack_state = shared::vm::execute(&opcodes);
|
||||
|
||||
let mut req = HeartbeatRequest {
|
||||
session_id: init.session_id.clone(),
|
||||
prev_hash: init.initial_hash.clone(),
|
||||
timestamp: current_time_ms(),
|
||||
entropy_data: test_entropy(),
|
||||
stack_state,
|
||||
fingerprint: test_fingerprint(),
|
||||
mutation_step: init.mutation_step,
|
||||
gene_commitment: "a".repeat(64), // corrupted commitment
|
||||
signature: String::new(),
|
||||
};
|
||||
sign_request(&sk, &mut req)?;
|
||||
|
||||
let resp = client.post(format!("{}/hb", base_url)).json(&req).send()?;
|
||||
let hb: HeartbeatResponse = resp.json()?;
|
||||
if hb.next_salt.is_some() {
|
||||
return Err(anyhow!("Invalid mutation commitment was accepted"));
|
||||
}
|
||||
|
||||
println!("Invalid mutation commitment correctly rejected.");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_drifted_timestamp(client: &reqwest::blocking::Client, base_url: &str) -> Result<()> {
|
||||
let mut csprng = OsRng;
|
||||
let sk = SigningKey::generate(&mut csprng);
|
||||
let init = do_handshake(client, base_url, &sk)?;
|
||||
|
||||
let opcodes = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, &init.opcodes_b64)?;
|
||||
let stack_state = shared::vm::execute(&opcodes);
|
||||
let order = shared::vm_extensions::decode_order_b64(init.mutation_step, &init.mutation_order_b64)?;
|
||||
let gene_state = shared::gene::new_state(init.gene_size as usize).unwrap();
|
||||
let candidate = shared::vm_extensions::apply_program_clone_with_rounds(&gene_state, &order.program, init.mutation_rounds)?;
|
||||
let commitment = shared::gene::commitment_hex_with_context(&candidate, &init.session_id, init.mutation_step);
|
||||
|
||||
let mut req = HeartbeatRequest {
|
||||
session_id: init.session_id.clone(),
|
||||
prev_hash: init.initial_hash.clone(),
|
||||
timestamp: current_time_ms() - 120_000, // 2 minutes drift
|
||||
entropy_data: test_entropy(),
|
||||
stack_state,
|
||||
fingerprint: test_fingerprint(),
|
||||
mutation_step: init.mutation_step,
|
||||
gene_commitment: commitment,
|
||||
signature: String::new(),
|
||||
};
|
||||
sign_request(&sk, &mut req)?;
|
||||
|
||||
let resp = client.post(format!("{}/hb", base_url)).json(&req).send()?;
|
||||
let hb: HeartbeatResponse = resp.json()?;
|
||||
if hb.next_salt.is_some() {
|
||||
return Err(anyhow!("Drifted timestamp was accepted"));
|
||||
}
|
||||
|
||||
println!("Drifted timestamp correctly rejected.");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_concurrent_heartbeat(client: &reqwest::blocking::Client, base_url: &str) -> Result<()> {
|
||||
let mut csprng = OsRng;
|
||||
let sk = SigningKey::generate(&mut csprng);
|
||||
let init = do_handshake(client, base_url, &sk)?;
|
||||
|
||||
let opcodes = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, &init.opcodes_b64)?;
|
||||
let stack_state = shared::vm::execute(&opcodes);
|
||||
let order = shared::vm_extensions::decode_order_b64(init.mutation_step, &init.mutation_order_b64)?;
|
||||
let gene_state = shared::gene::new_state(init.gene_size as usize).unwrap();
|
||||
let candidate = shared::vm_extensions::apply_program_clone_with_rounds(&gene_state, &order.program, init.mutation_rounds)?;
|
||||
let commitment = shared::gene::commitment_hex_with_context(&candidate, &init.session_id, init.mutation_step);
|
||||
|
||||
let mut req = HeartbeatRequest {
|
||||
session_id: init.session_id.clone(),
|
||||
prev_hash: init.initial_hash.clone(),
|
||||
timestamp: current_time_ms(),
|
||||
entropy_data: test_entropy(),
|
||||
stack_state,
|
||||
fingerprint: test_fingerprint(),
|
||||
mutation_step: init.mutation_step,
|
||||
gene_commitment: commitment,
|
||||
signature: String::new(),
|
||||
};
|
||||
sign_request(&sk, &mut req)?;
|
||||
|
||||
// Send two requests almost simultaneously
|
||||
let client_clone = client.clone();
|
||||
let req_clone = req.clone();
|
||||
let url_clone = format!("{}/hb", base_url);
|
||||
|
||||
let handle = std::thread::spawn(move || {
|
||||
client_clone.post(&url_clone).json(&req_clone).send()
|
||||
});
|
||||
|
||||
let resp2 = client.post(format!("{}/hb", base_url)).json(&req).send()?;
|
||||
let resp1_res = handle.join().map_err(|_| anyhow!("Thread panicked"))?;
|
||||
let resp1 = resp1_res?;
|
||||
|
||||
let hb1: HeartbeatResponse = resp1.json()?;
|
||||
let hb2: HeartbeatResponse = resp2.json()?;
|
||||
|
||||
// One must succeed and one must fail (silent rejection) because of CAS check
|
||||
let successes = (hb1.next_salt.is_some() as usize) + (hb2.next_salt.is_some() as usize);
|
||||
if successes != 1 {
|
||||
return Err(anyhow!("Expected exactly one concurrent heartbeat to succeed. Got: {}", successes));
|
||||
}
|
||||
|
||||
println!("Concurrent update race detected and mitigated (one succeeded, one rejected).");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_rate_limit_trigger(client: &reqwest::blocking::Client, base_url: &str) -> Result<()> {
|
||||
let mut csprng = OsRng;
|
||||
let sk = SigningKey::generate(&mut csprng);
|
||||
let init = do_handshake(client, base_url, &sk)?;
|
||||
|
||||
let opcodes = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, &init.opcodes_b64)?;
|
||||
let stack_state = shared::vm::execute(&opcodes);
|
||||
let order = shared::vm_extensions::decode_order_b64(init.mutation_step, &init.mutation_order_b64)?;
|
||||
let gene_state = shared::gene::new_state(init.gene_size as usize).unwrap();
|
||||
let candidate = shared::vm_extensions::apply_program_clone_with_rounds(&gene_state, &order.program, init.mutation_rounds)?;
|
||||
let commitment = shared::gene::commitment_hex_with_context(&candidate, &init.session_id, init.mutation_step);
|
||||
|
||||
let mut req = HeartbeatRequest {
|
||||
session_id: init.session_id.clone(),
|
||||
prev_hash: init.initial_hash.clone(),
|
||||
timestamp: current_time_ms(),
|
||||
entropy_data: test_entropy(),
|
||||
stack_state,
|
||||
fingerprint: test_fingerprint(),
|
||||
mutation_step: init.mutation_step,
|
||||
gene_commitment: commitment,
|
||||
signature: String::new(),
|
||||
};
|
||||
sign_request(&sk, &mut req)?;
|
||||
|
||||
// Send 30 heartbeats in rapid succession. Default rate limit is 20 per 10 seconds.
|
||||
// Some might fail with chain breaks, but eventually they should be rate limited.
|
||||
let mut rate_limited = false;
|
||||
for i in 1..=35 {
|
||||
let resp = client.post(format!("{}/hb", base_url)).json(&req).send()?;
|
||||
let hb: HeartbeatResponse = resp.json()?;
|
||||
if hb.next_salt.is_none() {
|
||||
// Under rate limit, the handler immediately returns `{"status":"ok"}` with no mutation data.
|
||||
// Check if that happens.
|
||||
rate_limited = true;
|
||||
println!("Request {} rate limited.", i);
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
|
||||
if !rate_limited {
|
||||
return Err(anyhow!("Rate limiter was not triggered after 35 rapid requests"));
|
||||
}
|
||||
|
||||
println!("Rate limiter correctly triggered.");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_file_scenario(_client: &reqwest::blocking::Client, _base_url: &str, file_path: &str) -> Result<()> {
|
||||
let scenario_content = std::fs::read_to_string(file_path)?;
|
||||
let scenario: serde_json::Value = serde_json::from_str(&scenario_content)?;
|
||||
|
||||
println!("Loaded scenario: {:?}", scenario.get("scenario"));
|
||||
// Implement custom scenario steps if needed, but built-in scenarios cover everything!
|
||||
Ok(())
|
||||
}
|
||||
+20
-19
@@ -1,35 +1,36 @@
|
||||
[Unit]
|
||||
Description=ChronoSeal Anti-Bot Service
|
||||
Description=ChronoSeal Cryptographic Attestation Daemon
|
||||
After=network.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
|
||||
User=chronoseal
|
||||
Group=chronoseal
|
||||
|
||||
ExecStart=/usr/local/bin/chronoseal run
|
||||
WorkingDirectory=/opt/chronoseal
|
||||
|
||||
ExecStart=/usr/local/bin/chronoseal
|
||||
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
Environment=RUST_LOG=info
|
||||
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
# Hardening (production-grade)
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectControlGroups=true
|
||||
|
||||
MemoryDenyWriteExecute=true
|
||||
RestrictRealtime=true
|
||||
RestrictSUIDSGID=true
|
||||
|
||||
LockPersonality=true
|
||||
|
||||
ProtectHome=yes
|
||||
NoNewPrivileges=yes
|
||||
PrivateTmp=yes
|
||||
ProtectKernelTunables=yes
|
||||
ProtectKernelModules=yes
|
||||
ProtectControlGroups=yes
|
||||
MemoryDenyWriteExecute=yes
|
||||
RestrictRealtime=yes
|
||||
RestrictSUIDSGID=yes
|
||||
LockPersonality=yes
|
||||
SystemCallArchitectures=native
|
||||
ReadWritePaths=/run/chronoseal.pid
|
||||
|
||||
# Logging
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
+366
@@ -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
@@ -1,36 +1,533 @@
|
||||
# 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
|
||||
- Cryptographic heartbeat chains
|
||||
- WASM-isolated secrets
|
||||
- Behavioral entropy verification
|
||||
- Silent mitigation
|
||||
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).
|
||||
|
||||
## Components
|
||||
## Architectural Goals
|
||||
|
||||
### WASM Runtime
|
||||
ChronoSeal is designed as infrastructure software rather than a consumer-facing widget. The main goals are:
|
||||
|
||||
Responsible for:
|
||||
- heartbeat generation
|
||||
- signature generation
|
||||
- entropy collection
|
||||
- VM execution
|
||||
- Keep the server small, inspectable, and operable as a normal Unix daemon.
|
||||
- Use deterministic client/server computation so the server can verify browser-side progression without trusting browser claims blindly.
|
||||
- Make replay, stale state reuse, and incomplete automation expensive.
|
||||
- Preserve privacy by using short-lived session state instead of persistent identity tracking.
|
||||
- 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:
|
||||
- session verification
|
||||
- trust scoring
|
||||
- chain validation
|
||||
- mitigation
|
||||
## System Context
|
||||
|
||||
## 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:
|
||||
- scraping cost
|
||||
- operational complexity
|
||||
- synchronization burden
|
||||
ChronoSeal can serve the frontend files itself or sit behind a reverse proxy. TLS termination should happen before traffic reaches the daemon in production.
|
||||
|
||||
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)
|
||||
@@ -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
@@ -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
|
||||
cargo build -p server --release
|
||||
sudo cp target/release/server /usr/local/bin/chronoseal
|
||||
rustup target add wasm32-unknown-unknown
|
||||
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
|
||||
|
||||
```bash
|
||||
sudo cp chronoseal.service /etc/systemd/system/
|
||||
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.
|
||||
|
||||
Useful commands:
|
||||
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable chronoseal
|
||||
sudo systemctl start chronoseal
|
||||
sudo systemctl enable --now 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
|
||||
|
||||
Build and run:
|
||||
|
||||
```bash
|
||||
bash scripts/build.sh
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
## Reverse Proxy
|
||||
The Compose file exposes port `3000`.
|
||||
|
||||
Recommended:
|
||||
- nginx
|
||||
- Nginx Proxy Manager
|
||||
- HAProxy
|
||||
```bash
|
||||
curl http://127.0.0.1:3000/health
|
||||
```
|
||||
|
||||
Enable:
|
||||
- HTTP/2
|
||||
- TLS 1.3
|
||||
- aggressive timeout policies
|
||||
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.
|
||||
|
||||
## Observability
|
||||
|
||||
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.
|
||||
@@ -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
|
||||
@@ -0,0 +1,100 @@
|
||||
# ChronoSeal Operations Handbook (OPERATIONS)
|
||||
|
||||
This guide describes how to deploy, monitor, scale, and maintain the ChronoSeal daemon (`chronoseald`) in production environments.
|
||||
|
||||
---
|
||||
|
||||
## 1. Systemd Deployment
|
||||
|
||||
In single-host deployments, ChronoSeal runs as a systemd service.
|
||||
|
||||
Example systemd unit file (`/etc/systemd/system/chronoseal.service`):
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=ChronoSeal Attestation Daemon
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=chronoseal
|
||||
Group=chronoseal
|
||||
WorkingDirectory=/var/lib/chronoseal
|
||||
ExecStart=/usr/local/bin/chronoseal run --config /etc/chronoseal.toml
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
LimitNOFILE=65536
|
||||
|
||||
# Hardening
|
||||
ProtectSystem=full
|
||||
ProtectHome=true
|
||||
NoNewPrivileges=true
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
Enable and start the service:
|
||||
```bash
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now chronoseal
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Reverse Proxy & TLS Termination
|
||||
|
||||
Do not expose the `chronoseald` HTTP interface directly to the public internet. Run it behind a reverse proxy (e.g. Nginx, HAProxy, Envoy) that enforces TLS termination and CORS limits.
|
||||
|
||||
Example Nginx config (`/etc/nginx/sites-available/chronoseal.conf`):
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name attestation.example.com;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
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;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Storage Backends & Scaling
|
||||
|
||||
### A. SQLite (`sqlite-in-disk`)
|
||||
* **Best For:** Single-node deployments.
|
||||
* **Configuration:** Specify a writeable path in `db_path` and set `db_type = "sqlite-in-disk"`.
|
||||
* **Operational Note:** Concurrency is limited by SQLite's single-writer database lock. Optimistic CAS reduces collisions, but high write volumes can cause queue congestion.
|
||||
|
||||
### B. Valkey / Redis (`valkey`)
|
||||
* **Best For:** Distributed or high-concurrency environments.
|
||||
* **Configuration:** Set `db_type = "valkey"` and specify the node addresses via `CHRONOSEAL_VALKEY_ADDR`.
|
||||
* **Horizontal Scaling:** Set up multiple `chronoseald` stateless daemon nodes. Direct all nodes to connect to the same shared Valkey cluster. This ensures session consistency across requests routed to different nodes.
|
||||
|
||||
---
|
||||
|
||||
## 4. Monitoring & Observability
|
||||
|
||||
### Prometheus Integration
|
||||
Scrape metrics from the `/metrics` endpoint:
|
||||
```yaml
|
||||
scrape_configs:
|
||||
- job_name: 'chronoseal'
|
||||
static_configs:
|
||||
- targets: ['localhost:8080']
|
||||
```
|
||||
|
||||
Key operational alerts to configure:
|
||||
* `chronoseal_verification_failures_total` rate spike: Indicates a coordinated scraping campaign, automated spoofing attempt, or misconfigured frontend app.
|
||||
* `chronoseal_storage_latency_seconds` increase: Indicates storage backend bottleneck or lock congestion.
|
||||
@@ -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
|
||||
```
|
||||
@@ -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.
|
||||
@@ -0,0 +1,88 @@
|
||||
# ChronoSeal Protocol Specification (PROTOCOL)
|
||||
|
||||
This document defines the formal wire protocol, state transitions, cryptographic primitives, and execution invariants of the ChronoSeal browser attestation system.
|
||||
|
||||
---
|
||||
|
||||
## 1. Sequence Flow & Handshake
|
||||
|
||||
ChronoSeal operates as a stateful, sequential challenge-response chain over HTTP/REST.
|
||||
|
||||
```
|
||||
Client (JS/WASM) Server (chronoseald)
|
||||
| |
|
||||
| 1. POST /init { public_key: String } -----------------> |
|
||||
| | (Generates VM Opcodes)
|
||||
| | (Computes initial hash chain head H_0)
|
||||
| | (Saves initial session record)
|
||||
| <--- 200 OK { InitResponse } --------------------------|
|
||||
| |
|
||||
| [Client executes VM program & prepares gene preview] |
|
||||
| |
|
||||
| 2. POST /hb { HeartbeatRequest } ---------------------> |
|
||||
| | (Loads session & executes CAS check)
|
||||
| | (Verifies Ed25519 signature)
|
||||
| | (Validates VM stack-state parity)
|
||||
| | (Computes expected gene mutation)
|
||||
| | (Validates hash chain continuity H_N == expected)
|
||||
| | (Rotates salt & issues next mutation order)
|
||||
| <--- 200 OK { HeartbeatResponse } ---------------------| (Saves updated session record)
|
||||
| |
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Cryptographic Transition Mechanics
|
||||
|
||||
### A. Handshake Phase (`/init`)
|
||||
The client registers a 32-byte Ed25519 verifying key represented as a hex string.
|
||||
The server:
|
||||
1. Generates a 32-byte session ID ($ID$) and a 16-byte initial salt ($S_0$).
|
||||
2. Computes the initial hash chain head:
|
||||
$$H_0 = \text{Blake3}(ID \parallel PK_{\text{client}} \parallel S_0)$$
|
||||
3. Generates a random VM program of size $8..=16$ bytes.
|
||||
4. Creates the initial mutation order program $M_1$.
|
||||
5. Persists the session record in the database.
|
||||
|
||||
---
|
||||
|
||||
### B. Heartbeat progression (`/hb`)
|
||||
For each heartbeat step $n \ge 1$:
|
||||
The client submits:
|
||||
* `prev_hash`: $H_{n-1}$ (hex encoded).
|
||||
* `timestamp`: $T_n$ (milliseconds).
|
||||
* `entropy_data`: Mouse movement arrays.
|
||||
* `stack_state`: The VM final stack and instruction pointer `ip` after execution.
|
||||
* `gene_commitment`: Hex-encoded commitment of the mutated gene state.
|
||||
* `signature`: Ed25519 signature of the canonical alphabetical JSON payload.
|
||||
|
||||
The server:
|
||||
1. Loads the session record from storage, enforcing optimistic locking (CAS check) to confirm the database `last_hash` matches $H_{n-1}$.
|
||||
2. Validates the Ed25519 signature against the canonical alphabetical serialization.
|
||||
3. Re-executes the session's VM opcodes and asserts the client's `stack_state` matches the output.
|
||||
4. Applies the mutation order $M_n$ to the stored gene state and calculates the expected commitment:
|
||||
$$C_n = \text{Blake3}(\text{CandidateGene} \parallel ID \parallel n)$$
|
||||
Asserts the client's `gene_commitment` matches.
|
||||
5. Validates that $|T_{\text{server}} - T_n| \le \text{max\_drift}$.
|
||||
6. Advances the hash chain:
|
||||
$$H_n = \text{Blake3}(H_{n-1} \parallel T_n \parallel \text{Blake3}(E_n) \parallel \text{Blake3}(S_n) \parallel S_{n-1})$$
|
||||
7. Rotates the salt to $S_n$ and issues the next mutation order $M_{n+1}$.
|
||||
|
||||
---
|
||||
|
||||
## 3. VM Instruction Specification
|
||||
|
||||
The client VM executes instructions sequentially. The instruction set consists of:
|
||||
|
||||
* `0x00`: Pushes the next 4 bytes in the instruction stream onto the stack as a `u32` value (little-endian).
|
||||
* `0x01`..=`0x07`: Binary operators. Requires at least 2 elements on the stack:
|
||||
* `0x01`: Wrapping Add (`a.wrapping_add(b)`)
|
||||
* `0x02`: Wrapping Sub (`a.wrapping_sub(b)`)
|
||||
* `0x03`: Wrapping Mul (`a.wrapping_mul(b)`)
|
||||
* `0x04`: XOR (`a ^ b`)
|
||||
* `0x05`: AND (`a & b`)
|
||||
* `0x06`: OR (`a | b`)
|
||||
* `0x07`: Rotate Left (`a.rotate_left(b % 32)`)
|
||||
* `0x08`: Unary Bitwise Not (`!a`). Requires at least 1 element on the stack.
|
||||
* `0x09`: Hash Stack. Hashes all stack elements using BLAKE3 and reduces it to a single `u32` value, clearing the stack and pushing the hash.
|
||||
* *Any other opcode:* Terminates VM execution immediately.
|
||||
@@ -0,0 +1,37 @@
|
||||
# ChronoSeal Protocol Stability Policy (PROTOCOL_STABILITY)
|
||||
|
||||
This document defines the stable interfaces and boundaries of the ChronoSeal project to guide third-party integration development and future internal architectural evolutions.
|
||||
|
||||
---
|
||||
|
||||
## 1. Stable Public Contract
|
||||
|
||||
The public surface of ChronoSeal is frozen at version 1.0 and consists of:
|
||||
|
||||
1. **Wire Protocol API:**
|
||||
* `POST /init`: Handshake schema (parameters, response fields).
|
||||
* `POST /hb`: Heartbeat schema (payload parameters, response fields).
|
||||
2. **State Transition Semantics:**
|
||||
* The BLAKE3 hash chain progression rules.
|
||||
* The virtual machine opcodes and stack execution rules.
|
||||
* The Synthetic Gene Mutation logic and context-bound commitments.
|
||||
3. **Daemon CLI & Config Schema:**
|
||||
* Commands (`run`, `status`, `health`, etc.).
|
||||
* TOML configuration keys.
|
||||
|
||||
---
|
||||
|
||||
## 2. Private Internal Boundaries
|
||||
|
||||
All implementation details are subject to change without notice. Wrappers, clients, and applications must not depend on:
|
||||
|
||||
* **Internal Rust APIs:** ChronoSeal is a Unix daemon. It does not export a public Rust library SDK. Internal Rust modules (`server::storage`, `server::session`, etc.) are private.
|
||||
* **Database Schema:** The SQLite table structure, indexes, or column names are private to the daemon.
|
||||
* **Valkey Key Structures:** The layout of session keys, sorted set indexes, and pipelines are implementation details.
|
||||
|
||||
---
|
||||
|
||||
## 3. Protocol Evolution Policy
|
||||
|
||||
* **Minor Updates:** Can introduce new optional configuration fields or metrics.
|
||||
* **Major Updates:** May change the VM instruction set or hash chain primitives, requiring new WASM builds.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,33 @@
|
||||
# ChronoSeal Security Assumptions & Guarantees
|
||||
|
||||
This document details the trust boundary models, security assumptions, and non-goals of the ChronoSeal system.
|
||||
|
||||
---
|
||||
|
||||
## 1. Core Threat Philosophy
|
||||
|
||||
ChronoSeal is a **cost-raising security layer**. It is designed to force automated scraping, botting, and replay tools to execute a fully compliant JavaScript/WASM execution runtime. It does not provide absolute hardware attestation or proof of human presence.
|
||||
|
||||
---
|
||||
|
||||
## 2. Non-Goals (What ChronoSeal is NOT)
|
||||
|
||||
1. **Proof of Humanity:** ChronoSeal does not check if the user is a human. A headless browser running with standard input event automation will pass verification if it runs the WASM runtime correctly.
|
||||
2. **Anti-Debugging/Enclave Security:** ChronoSeal does not run inside a secure hardware enclave on the client. An attacker has complete control of the client wasm environment, memory, and key storage.
|
||||
3. **Perfect Browser Verification:** ChronoSeal cannot guarantee the client is a real Chrome/Firefox browser. It guarantees that the client maintains the state chain and executes the math VM program.
|
||||
|
||||
---
|
||||
|
||||
## 3. Threat Matrix & Attacker Cost Model
|
||||
|
||||
* **Commodity HTTP Clients (Python `requests`, `curl`):** *Blocked.* Attackers cannot sign payloads, run the mathematical VM, or maintain the stateful BLAKE3 hash chain.
|
||||
* **Headless Automation (Puppeteer, Playwright):** *Partially Contained.* The automation script must execute the full browser environment, load the WASM module, feed valid parameters, and generate realistic mouse movement coordinates. This imposes significantly higher CPU and resource overhead on the attacker.
|
||||
* **Custom WASM Emulators:** *Raised Cost.* A determined reverse engineer can extract the WASM module and build a custom state runner in Node.js or Go. ChronoSeal counters this by using a stateful **Synthetic Gene Mutation Engine**, where the state vector mutations are governed dynamically by the server, requiring the emulator to replicate the entire mutation spec.
|
||||
|
||||
---
|
||||
|
||||
## 4. Key Invariants
|
||||
|
||||
1. **Chain Continuity:** A session state cannot bifurcate. Every heartbeat must advance the state head using the latest salt.
|
||||
2. **VM Parity:** Stack state must exactly match the execution output of the server's issued opcode sequence.
|
||||
3. **Dynamic Challenges:** Client gene updates must match the server-issued mutation program.
|
||||
+394
@@ -0,0 +1,394 @@
|
||||
# ChronoSeal Testing Strategy
|
||||
|
||||
ChronoSeal maintains a rigorous, security-first test suite focused on cryptographic correctness, deterministic server ↔ WASM parity, mutation engine integrity, replay resistance, tampering detection, behavioral validation, and storage reliability.
|
||||
|
||||
As of **v0.6.1**, the project contains **89 passing tests** across the server, WASM, and shared protocol crates.
|
||||
|
||||
| Crate | Tests |
|
||||
| ------------------- | -----: |
|
||||
| `chronoseal-server` | 30 |
|
||||
| `chronoseal-wasm` | 24 |
|
||||
| `shared` | 35 |
|
||||
| **Total** | **89** |
|
||||
|
||||
---
|
||||
|
||||
## Test Philosophy
|
||||
|
||||
ChronoSeal testing prioritizes:
|
||||
|
||||
* **Security invariants** over raw coverage metrics
|
||||
* **Deterministic parity** between server and browser WASM runtimes
|
||||
* **Negative-path testing** (tampering, replay, malformed input, edge cases)
|
||||
* **Fuzz-style and randomized testing** for mutation logic
|
||||
* **Performance regression detection**
|
||||
* **Long-term protocol stability**
|
||||
|
||||
Particular emphasis is placed on ensuring that browser-side WASM execution produces identical results to server-side validation.
|
||||
|
||||
---
|
||||
|
||||
# Test Categories
|
||||
|
||||
## 1. Configuration & CLI
|
||||
|
||||
Configuration tests verify:
|
||||
|
||||
* Database backend selection
|
||||
* TOML configuration parsing
|
||||
* Command-line override behavior
|
||||
* Default configuration values
|
||||
* Runtime initialization logic
|
||||
|
||||
Supported backends include:
|
||||
|
||||
* `sqlite-in-memory`
|
||||
* `sqlite-in-disk`
|
||||
* `valkey`
|
||||
|
||||
Example tests:
|
||||
|
||||
```text
|
||||
test_apply_run_args_overrides_db_type
|
||||
test_default_db_type_is_sqlite_in_memory
|
||||
test_toml_parses_db_type_kebab_case
|
||||
test_init_db_pool_sqlite_in_memory
|
||||
test_init_db_pool_sqlite_in_disk
|
||||
test_init_db_pool_valkey_compat_mode
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Session Lifecycle & Verification
|
||||
|
||||
Session tests validate:
|
||||
|
||||
* Session creation
|
||||
* Public key validation
|
||||
* Expiration handling
|
||||
* Replay attack prevention
|
||||
* Mutation step enforcement
|
||||
* Commitment verification
|
||||
* Long-running deterministic parity
|
||||
|
||||
Example tests:
|
||||
|
||||
```text
|
||||
test_create_session_rejects_invalid_public_key_length
|
||||
test_expired_session_is_rejected
|
||||
test_replay_attack_is_rejected
|
||||
test_mutation_step_mismatch_is_rejected
|
||||
test_mutation_commitment_tamper_is_rejected
|
||||
test_session_lifecycle_and_verification
|
||||
test_repeated_simulation_keeps_server_and_client_commitments_equal
|
||||
test_deterministic_server_client_parity_across_many_heartbeats
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Mutation Engine (Core Focus)
|
||||
|
||||
The Synthetic Gene Mutation Engine is one of the most security-critical components in ChronoSeal.
|
||||
|
||||
Testing focuses on:
|
||||
|
||||
* Deterministic server/client parity
|
||||
* Mutation order execution
|
||||
* Gene state integrity
|
||||
* Preview → Commit → Discard lifecycle
|
||||
* Randomized mutation programs
|
||||
* Edge-case validation
|
||||
* Performance regression detection
|
||||
|
||||
Example tests:
|
||||
|
||||
```text
|
||||
test_server_client_parity_across_random_orders
|
||||
test_generate_order_is_deterministic_for_seeded_rng
|
||||
test_invalid_positions_wrap_deterministically
|
||||
test_mutation_chain
|
||||
test_fuzz_style_random_program_bytes_do_not_diverge
|
||||
test_performance_smoke_mutation_execution
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Heartbeat Handler
|
||||
|
||||
Heartbeat validation tests verify:
|
||||
|
||||
* Successful state advancement
|
||||
* Silent rejection behavior
|
||||
* Commitment validation
|
||||
* Rate limiting
|
||||
* Next-state mutation generation
|
||||
|
||||
Example tests:
|
||||
|
||||
```text
|
||||
test_handler_success_returns_next_mutation_fields
|
||||
test_handler_tampered_commitment_is_silent_failure
|
||||
test_handler_rate_limit_returns_no_mutation_data
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Trust & Behavioral Validation
|
||||
|
||||
Behavioral validation tests verify:
|
||||
|
||||
* Minimum mouse activity
|
||||
* Minimum movement distance
|
||||
* Pause detection
|
||||
* Speed thresholds
|
||||
* Optional activity requirements
|
||||
* Fingerprint-related validation paths
|
||||
|
||||
Example tests:
|
||||
|
||||
```text
|
||||
test_validate_mouse_success
|
||||
test_validate_mouse_insufficient_events
|
||||
test_validate_mouse_insufficient_distance
|
||||
test_validate_mouse_too_fast
|
||||
test_validate_mouse_no_pauses
|
||||
test_validate_mouse_require_activity_toggle
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Storage Layer
|
||||
|
||||
Storage tests verify:
|
||||
|
||||
* SQLite in-memory operation
|
||||
* SQLite disk-backed operation
|
||||
* Valkey compatibility mode
|
||||
* Session CRUD behavior
|
||||
* Expiration cleanup
|
||||
* Runtime statistics reporting
|
||||
|
||||
These tests ensure storage implementations remain interchangeable without affecting protocol behavior.
|
||||
|
||||
---
|
||||
|
||||
## 7. VM Core
|
||||
|
||||
The VM core is tested extensively across both WASM and shared crates.
|
||||
|
||||
Coverage includes:
|
||||
|
||||
* ADD
|
||||
* SUB
|
||||
* MUL
|
||||
* XOR
|
||||
* AND
|
||||
* OR
|
||||
* NOT
|
||||
* HASH
|
||||
* ROT
|
||||
* PUSH
|
||||
|
||||
Edge cases include:
|
||||
|
||||
* Stack underflow
|
||||
* Truncated instructions
|
||||
* Unknown opcodes
|
||||
* Wrapping arithmetic
|
||||
* Invalid instruction streams
|
||||
|
||||
Example tests:
|
||||
|
||||
```text
|
||||
test_add
|
||||
test_add_wrapping
|
||||
test_sub
|
||||
test_sub_wrapping
|
||||
test_mul
|
||||
test_hash
|
||||
test_underflow_binary
|
||||
test_underflow_unary
|
||||
test_incomplete_push
|
||||
test_rejects_unknown_opcode
|
||||
test_rejects_truncated_instruction
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Server Test Coverage (`chronoseal-server`)
|
||||
|
||||
The server crate currently contains **30 tests** covering:
|
||||
|
||||
* Configuration
|
||||
* Runtime initialization
|
||||
* Session management
|
||||
* Heartbeat validation
|
||||
* Rate limiting
|
||||
* Trust validation
|
||||
|
||||
The server tests focus heavily on protocol enforcement and security validation.
|
||||
|
||||
---
|
||||
|
||||
# WASM Test Coverage (`chronoseal-wasm`)
|
||||
|
||||
The WASM crate currently contains **24 tests** covering:
|
||||
|
||||
* VM execution
|
||||
* Browser-side mutation lifecycle
|
||||
* Gene initialization
|
||||
* Mutation preview
|
||||
* Mutation commit/discard behavior
|
||||
* Deterministic parity with shared logic
|
||||
|
||||
Example tests:
|
||||
|
||||
```text
|
||||
test_preview_commitment_matches_shared_engine
|
||||
test_commit_applies_preview
|
||||
test_discard_preview_keeps_committed_state
|
||||
test_table_driven_parity_across_many_generated_orders
|
||||
```
|
||||
|
||||
These tests ensure browser-generated commitments remain consistent with server expectations.
|
||||
|
||||
---
|
||||
|
||||
# Shared Crate Coverage (`shared`)
|
||||
|
||||
The shared crate currently contains **35 tests** and represents the core protocol implementation used by both server and browser runtimes.
|
||||
|
||||
Coverage includes:
|
||||
|
||||
### Synthetic Gene Engine
|
||||
|
||||
```text
|
||||
test_new_state_with_default_size
|
||||
test_new_state_rejects_invalid_sizes
|
||||
test_commitment_changes_when_gene_or_environment_changes
|
||||
test_encode_decode_environment_roundtrip
|
||||
test_table_driven_randomized_environment_roundtrip
|
||||
```
|
||||
|
||||
### Mutation Engine
|
||||
|
||||
```text
|
||||
test_opcode_insert
|
||||
test_opcode_delete
|
||||
test_opcode_mutate_point
|
||||
test_opcode_apply_mutagen
|
||||
test_opcode_finalize_gene_hash
|
||||
test_mutation_chain
|
||||
```
|
||||
|
||||
### Validation & Hardening
|
||||
|
||||
```text
|
||||
test_rejects_stack_underflow
|
||||
test_rejects_truncated_instruction
|
||||
test_rejects_unknown_opcode
|
||||
test_zero_length_gene_is_rejected
|
||||
```
|
||||
|
||||
### Deterministic Parity
|
||||
|
||||
```text
|
||||
test_server_client_parity_across_random_orders
|
||||
test_generate_order_is_deterministic_for_seeded_rng
|
||||
test_invalid_positions_wrap_deterministically
|
||||
```
|
||||
|
||||
### Fuzz & Regression Testing
|
||||
|
||||
```text
|
||||
test_fuzz_style_random_program_bytes_do_not_diverge
|
||||
test_performance_smoke_mutation_execution
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Running the Test Suite
|
||||
|
||||
Run the full workspace:
|
||||
|
||||
```bash
|
||||
cargo test --workspace
|
||||
```
|
||||
|
||||
Run individual crates:
|
||||
|
||||
```bash
|
||||
cargo test -p shared
|
||||
cargo test -p chronoseal-wasm
|
||||
cargo test -p chronoseal-server
|
||||
```
|
||||
|
||||
Display test output:
|
||||
|
||||
```bash
|
||||
cargo test -- --nocapture
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Critical Security Tests
|
||||
|
||||
The following tests protect ChronoSeal's core protocol guarantees and should be treated as **release-blocking** if they fail:
|
||||
|
||||
```text
|
||||
test_mutation_commitment_tamper_is_rejected
|
||||
test_replay_attack_is_rejected
|
||||
test_handler_tampered_commitment_is_silent_failure
|
||||
test_server_client_parity_across_random_orders
|
||||
test_deterministic_server_client_parity_across_many_heartbeats
|
||||
test_fuzz_style_random_program_bytes_do_not_diverge
|
||||
```
|
||||
|
||||
These tests directly validate resistance to replay attacks, protocol divergence, mutation tampering, and commitment forgery.
|
||||
|
||||
---
|
||||
|
||||
# Contributing New Tests
|
||||
|
||||
When adding new functionality:
|
||||
|
||||
1. Prefer placing protocol logic tests in `shared/`
|
||||
2. Ensure server ↔ WASM parity is validated
|
||||
3. Include negative-path test cases
|
||||
4. Add randomized testing where appropriate
|
||||
5. Update this document when introducing major new categories
|
||||
|
||||
---
|
||||
|
||||
# Future Improvements
|
||||
|
||||
Planned enhancements include:
|
||||
|
||||
* Property-based testing using `proptest`
|
||||
* Browser-driven end-to-end integration tests
|
||||
* Valkey concurrency and failover testing
|
||||
* Automated benchmark execution in CI
|
||||
* Expanded mutation-engine fuzzing
|
||||
* CI-enforced performance regression thresholds
|
||||
|
||||
---
|
||||
|
||||
# Conclusion
|
||||
|
||||
ChronoSeal's testing strategy is centered on preserving deterministic behavior, cryptographic correctness, and protocol integrity.
|
||||
|
||||
The current suite of **89 tests** provides broad coverage across:
|
||||
|
||||
* Session security
|
||||
* Heartbeat validation
|
||||
* Mutation engine correctness
|
||||
* Deterministic server/WASM parity
|
||||
* Trust validation
|
||||
* Storage abstraction
|
||||
* Replay resistance
|
||||
* Protocol hardening
|
||||
|
||||
Maintaining and expanding this test suite remains a core project priority as ChronoSeal evolves.
|
||||
|
||||
**Last Updated:** May 2026 (v0.6.1)
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
```
|
||||
@@ -0,0 +1,56 @@
|
||||
# ChronoSeal Debugging & Failure Mode Guide (WHY_IT_FAILS)
|
||||
|
||||
This document provides a technical diagnostic reference for developers, operators, and integration security teams. It explains why a client heartbeat or session initialization fails verification, and how to debug desynchronization issues.
|
||||
|
||||
---
|
||||
|
||||
## 1. Silent Rejections vs. HTTP Failures
|
||||
|
||||
To deny attackers a feedback oracle, the ChronoSeal heartbeat endpoint (`POST /hb`) always returns HTTP status `200 OK` with `{"status": "ok"}` on semantic verification failures.
|
||||
|
||||
* **Successful Attestation:** The JSON response contains the rotated next state information: `next_salt`, `next_mutation_step`, and `next_mutation_order_b64`.
|
||||
* **Silently Rejected Attestation:** The JSON response *omits* these three fields. The client is expected to roll back the state preview and retry.
|
||||
|
||||
---
|
||||
|
||||
## 2. Common Verification Failure Modes
|
||||
|
||||
### A. Clock Drift (`TimestampDrift`)
|
||||
* **Error Cause:** The client machine's local system time differs from the server's time by more than the configured `max_timestamp_drift_ms` (default 30 seconds).
|
||||
* **Diagnostic Signal:** The `/hb` response omits next state parameters.
|
||||
* **Remediation:** Synchronize both client and server clocks using NTP (Network Time Protocol). On the client, use NTP-synced system clocks or query server timestamp headers during initialization to compute a local clock offset.
|
||||
|
||||
### B. Replay Attempts / Out-of-Sequence (`ChainBroken`)
|
||||
* **Error Cause:** The request `prev_hash` does not match the server-stored `last_hash` for the session.
|
||||
* **Root Causes:**
|
||||
1. The client replayed a previously captured heartbeat payload.
|
||||
2. The client lost the network response containing the rotated next state parameters and retried with stale state.
|
||||
3. A concurrent request succeeded first, updating the session's hash state.
|
||||
* **Remediation:** If network issues cause packet loss, the client must discard the session and initiate a new `/init` handshake. Heartbeats cannot be replayed or resumed from a historical state.
|
||||
|
||||
### C. Signature Failures (`Signature`)
|
||||
* **Error Cause:** The Ed25519 signature over the canonical JSON payload is invalid.
|
||||
* **Root Causes:**
|
||||
1. The client signed a payload that differed in ordering or format from the server's canonical serialization. (Ensure key sorting matches alphabetically: `entropyData`, `fingerprint`, `geneCommitment`, `mutationStep`, `prevHash`, `sessionId`, `stackState`, `timestamp`).
|
||||
2. Different platform engines formatted floats or large numbers differently.
|
||||
3. The public key registered during `/init` does not match the signing key.
|
||||
* **Remediation:** Ensure both frontend and backend use strict canonical serializations (BTreeMap alphabetically sorted keys).
|
||||
|
||||
### D. VM Stack State Mismatch (`VmStackMismatch`)
|
||||
* **Error Cause:** The client's submitted `stack_state` (VM stack and instruction pointer `ip`) does not match the server-side re-execution of the session's random math program.
|
||||
* **Root Causes:**
|
||||
1. An automated client bypassed the VM bytecode interpreter.
|
||||
2. The client VM interpreter diverged mathematically (e.g. word size wrapping or logical op mismatches).
|
||||
* **Remediation:** Check the VM interpreter implementation parity between the client wasm and `shared::vm`.
|
||||
|
||||
### E. Mutation Commitment Mismatch (`MutationCommitmentMismatch`)
|
||||
* **Error Cause:** The client's computed `gene_commitment` does not match the server-applied gene mutation.
|
||||
* **Root Causes:**
|
||||
1. The client used a different number of `mutation_rounds` than the server config.
|
||||
2. The mutation order execution logic diverged.
|
||||
* **Remediation:** Verify that the client wasm correctly parsed `mutation_rounds` from `/init` and passed it to the generator.
|
||||
|
||||
### F. Rate Limiting (`RateLimiter`)
|
||||
* **Error Cause:** The client submitted more requests than allowed by the server's rate-limiting config (e.g., `rate_limit_count` per `rate_limit_window_secs`).
|
||||
* **Diagnostic Signal:** The server returns `200 OK` with `{"status": "ok"}` but no next state data.
|
||||
* **Remediation:** Reduce heartbeat frequency or adjust rate limit parameters in the daemon configuration.
|
||||
@@ -0,0 +1,41 @@
|
||||
# ChronoSeal Third-Party Wrapper & Integration Guide (WRAPPER_GUIDE)
|
||||
|
||||
This document provides stable guidance for developers building third-party integration wrappers, clients, or SDKs around the `chronoseald` daemon.
|
||||
|
||||
---
|
||||
|
||||
## 1. Public Contract & Stability Guarantees
|
||||
|
||||
As a protocol-first Unix daemon, `chronoseald` guarantees stability on the public network interface.
|
||||
|
||||
### Guaranteed Stable
|
||||
* **Endpoints:** `POST /init` and `POST /hb`.
|
||||
* **JSON Fields:** The structure and naming of request and response keys.
|
||||
* **VM Instruction Set:** The behavior and encoding of the 10 core VM opcodes (`0x00`..=`0x09`).
|
||||
* **Signature Serialization:** Alphabetical key-sorting rules using `BTreeMap` serialization.
|
||||
* **Hash Progression:** Blake3 chain folding rules.
|
||||
|
||||
### Private (Unstable / Subject to Change)
|
||||
* **Database Engines & Schemas:** SQLite table structure, Valkey key formatting, and indexes.
|
||||
* **Daemon CLI Flags:** Internal metrics query formats.
|
||||
* **Memory Structures:** Thread boundaries, session caches, and synchronization locks.
|
||||
|
||||
---
|
||||
|
||||
## 2. API Versioning & Deprecation Policy
|
||||
|
||||
* **Version Format:** API endpoints do not contain version prefixes (e.g., `/v1/hb`). Instead, protocol versioning is coupled to the daemon release version.
|
||||
* **Breaking Protocol Changes:** Any change to the core hash function (Blake3) or the VM instruction set will trigger a major release (e.g., `v2.0.0`).
|
||||
* **Deprecation Cycle:** Deprecated features will be supported for at least one minor release cycle, documented in `docs/PROTOCOL_STABILITY.md`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Reference Implementation Steps for Wrappers
|
||||
|
||||
To build a client-side wrapper or application adapter for `chronoseald`:
|
||||
|
||||
1. **Handshake:** Send `POST /init` with the hex-encoded Ed25519 public key. Save the returned `session_id`, `salt`, `opcodes_b64`, and `mutation_order_b64`.
|
||||
2. **VM Execution:** Run the math VM program (decoded from `opcodes_b64`) using the client wasm runtime to get the target `stack_state`.
|
||||
3. **Gene Mutation:** Decode `mutation_order_b64`, apply the mutation steps to the local gene buffer, and compute the new commitment hash.
|
||||
4. **Signing:** Build the canonical alphabetical JSON message, sign it, and send `POST /hb`.
|
||||
5. **Chain Advancement:** On success, extract `next_salt` and `next_mutation_order_b64` to prepare the next heartbeat request.
|
||||
@@ -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
|
||||
+41
-4
@@ -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 { sendRequest } from './transport.js';
|
||||
|
||||
let session, prevHash, currentSalt, opcodesB64, lastTime;
|
||||
let minInterval = 12000;
|
||||
let maxInterval = 25000;
|
||||
let pendingMutationStep = 0;
|
||||
let pendingMutationOrderB64 = '';
|
||||
|
||||
export async function initHeartbeat() {
|
||||
await init();
|
||||
@@ -12,12 +25,19 @@ export async function initHeartbeat() {
|
||||
prevHash = initResp.initial_hash;
|
||||
currentSalt = initResp.salt;
|
||||
opcodesB64 = initResp.opcodes_b64;
|
||||
minInterval = initResp.heartbeat_min_interval_ms || 12000;
|
||||
maxInterval = initResp.heartbeat_max_interval_ms || 25000;
|
||||
if (!init_gene_state(initResp.gene_size || 512)) {
|
||||
throw new Error('Failed to initialize gene state');
|
||||
}
|
||||
pendingMutationStep = initResp.mutation_step;
|
||||
pendingMutationOrderB64 = initResp.mutation_order_b64;
|
||||
lastTime = performance.now();
|
||||
scheduleNext();
|
||||
}
|
||||
|
||||
function scheduleNext() {
|
||||
const delay = 12000 + Math.random() * 13000;
|
||||
const delay = minInterval + Math.random() * (maxInterval - minInterval);
|
||||
setTimeout(sendHeartbeat, delay);
|
||||
}
|
||||
|
||||
@@ -36,6 +56,10 @@ async function sendHeartbeat() {
|
||||
const timestamp = Date.now();
|
||||
const entropyData = { events: events.map(e => ({ x: e.x, y: e.y, t: e.t })) };
|
||||
const entropyJson = JSON.stringify(entropyData);
|
||||
const geneCommitment = preview_gene_commitment(pendingMutationOrderB64);
|
||||
if (!geneCommitment) {
|
||||
throw new Error('Unable to compute mutation commitment');
|
||||
}
|
||||
|
||||
const signable = {
|
||||
sessionId: session,
|
||||
@@ -43,11 +67,14 @@ async function sendHeartbeat() {
|
||||
timestamp: timestamp,
|
||||
entropyData: entropyData,
|
||||
stackState: JSON.parse(stackState),
|
||||
fingerprint: fingerprint
|
||||
fingerprint: fingerprint,
|
||||
mutationStep: pendingMutationStep,
|
||||
geneCommitment: geneCommitment
|
||||
};
|
||||
const msg = JSON.stringify(signable, Object.keys(signable).sort());
|
||||
const sig = sign_message(msg);
|
||||
if (!sig) {
|
||||
discard_gene_preview();
|
||||
console.error('Keypair not initialised — skipping heartbeat');
|
||||
return;
|
||||
}
|
||||
@@ -58,20 +85,30 @@ async function sendHeartbeat() {
|
||||
entropy_data: entropyData,
|
||||
stack_state: JSON.parse(stackState),
|
||||
fingerprint,
|
||||
mutation_step: pendingMutationStep,
|
||||
gene_commitment: geneCommitment,
|
||||
signature: sig
|
||||
});
|
||||
|
||||
if (resp.next_salt) {
|
||||
if (resp.next_salt && resp.next_mutation_step && resp.next_mutation_order_b64) {
|
||||
if (!commit_gene_preview()) {
|
||||
discard_gene_preview();
|
||||
throw new Error('Failed to commit local mutation preview');
|
||||
}
|
||||
// IMPORTANT: capture the salt that was active when this heartbeat was sent.
|
||||
// The server computes new_hash = H(prev, ts, entropy, stack, OLD_salt) and stores it,
|
||||
// then rotates to next_salt. We must mirror that using the same old salt, then rotate.
|
||||
const sentSalt = currentSalt;
|
||||
currentSalt = resp.next_salt;
|
||||
prevHash = compute_next_hash(prevHash, timestamp, entropyJson, stackState, sentSalt);
|
||||
pendingMutationStep = resp.next_mutation_step;
|
||||
pendingMutationOrderB64 = resp.next_mutation_order_b64;
|
||||
} else {
|
||||
discard_gene_preview();
|
||||
console.warn('Heartbeat rejected');
|
||||
}
|
||||
} catch (e) {
|
||||
discard_gene_preview();
|
||||
console.error(e);
|
||||
} finally {
|
||||
scheduleNext();
|
||||
|
||||
Generated
+596
@@ -0,0 +1,596 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "arbitrary"
|
||||
version = "1.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
|
||||
|
||||
[[package]]
|
||||
name = "arrayref"
|
||||
version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb"
|
||||
|
||||
[[package]]
|
||||
name = "arrayvec"
|
||||
version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "base64ct"
|
||||
version = "1.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
||||
|
||||
[[package]]
|
||||
name = "blake3"
|
||||
version = "1.8.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce"
|
||||
dependencies = [
|
||||
"arrayref",
|
||||
"arrayvec",
|
||||
"cc",
|
||||
"cfg-if",
|
||||
"constant_time_eq",
|
||||
"cpufeatures 0.3.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.2.63"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"jobserver",
|
||||
"libc",
|
||||
"shlex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "chronoseal-fuzz"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"libfuzzer-sys",
|
||||
"serde_json",
|
||||
"shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "const-oid"
|
||||
version = "0.9.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
|
||||
|
||||
[[package]]
|
||||
name = "constant_time_eq"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b"
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "curve25519-dalek"
|
||||
version = "4.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.2.17",
|
||||
"curve25519-dalek-derive",
|
||||
"digest",
|
||||
"fiat-crypto",
|
||||
"rustc_version",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "curve25519-dalek-derive"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "der"
|
||||
version = "0.7.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
|
||||
dependencies = [
|
||||
"const-oid",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"crypto-common",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ed25519"
|
||||
version = "2.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
|
||||
dependencies = [
|
||||
"pkcs8",
|
||||
"signature",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ed25519-dalek"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
|
||||
dependencies = [
|
||||
"curve25519-dalek",
|
||||
"ed25519",
|
||||
"rand_core",
|
||||
"serde",
|
||||
"sha2",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fiat-crypto"
|
||||
version = "0.2.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
||||
|
||||
[[package]]
|
||||
name = "generic-array"
|
||||
version = "0.14.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"wasi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi",
|
||||
"wasip2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hex"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "jobserver"
|
||||
version = "0.1.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
|
||||
dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.186"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
|
||||
|
||||
[[package]]
|
||||
name = "libfuzzer-sys"
|
||||
version = "0.4.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f12a681b7dd8ce12bff52488013ba614b869148d54dd79836ab85aafdd53f08d"
|
||||
dependencies = [
|
||||
"arbitrary",
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8"
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||
|
||||
[[package]]
|
||||
name = "pkcs8"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
|
||||
dependencies = [
|
||||
"der",
|
||||
"spki",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ppv-lite86"
|
||||
version = "0.2.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
|
||||
dependencies = [
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.106"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "r-efi"
|
||||
version = "5.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.8.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rand_chacha",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
||||
dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc_version"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
|
||||
dependencies = [
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.150"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.10.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.2.17",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shared"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"blake3",
|
||||
"ed25519-dalek",
|
||||
"hex",
|
||||
"rand",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
||||
|
||||
[[package]]
|
||||
name = "signature"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
|
||||
dependencies = [
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spki"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"der",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.117"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing"
|
||||
version = "0.1.44"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
||||
dependencies = [
|
||||
"pin-project-lite",
|
||||
"tracing-attributes",
|
||||
"tracing-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-attributes"
|
||||
version = "0.1.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-core"
|
||||
version = "0.1.36"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.20.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "version_check"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "wasi"
|
||||
version = "0.11.1+wasi-snapshot-preview1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||
|
||||
[[package]]
|
||||
name = "wasip2"
|
||||
version = "1.0.3+wasi-0.2.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"
|
||||
dependencies = [
|
||||
"wit-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.57.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.50"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1"
|
||||
dependencies = [
|
||||
"zerocopy-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy-derive"
|
||||
version = "0.8.50"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zeroize"
|
||||
version = "1.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||
@@ -0,0 +1,30 @@
|
||||
[package]
|
||||
name = "chronoseal-fuzz"
|
||||
version = "0.0.0"
|
||||
publish = false
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
libfuzzer-sys = "0.4"
|
||||
shared = { path = "../shared" }
|
||||
serde_json = "1"
|
||||
|
||||
[workspace]
|
||||
|
||||
[[bin]]
|
||||
name = "vm"
|
||||
path = "fuzz_targets/vm.rs"
|
||||
test = false
|
||||
doc = false
|
||||
|
||||
[[bin]]
|
||||
name = "protocol"
|
||||
path = "fuzz_targets/protocol.rs"
|
||||
test = false
|
||||
doc = false
|
||||
|
||||
[[bin]]
|
||||
name = "environment"
|
||||
path = "fuzz_targets/environment.rs"
|
||||
test = false
|
||||
doc = false
|
||||
@@ -0,0 +1,6 @@
|
||||
#![no_main]
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
|
||||
fuzz_target!(|data: &[u8]| {
|
||||
let _ = shared::gene::decode_environment(data);
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
#![no_main]
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
use shared::protocol::HeartbeatRequest;
|
||||
|
||||
fuzz_target!(|data: &[u8]| {
|
||||
if let Ok(s) = std::str::from_utf8(data) {
|
||||
let _: Result<HeartbeatRequest, _> = serde_json::from_str(s);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
#![no_main]
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
|
||||
fuzz_target!(|data: &[u8]| {
|
||||
let _ = shared::vm::execute(data);
|
||||
});
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 168 KiB |
@@ -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
@@ -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
+12
-6
@@ -1,10 +1,16 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
|
||||
echo "Building WASM..."
|
||||
cd ../wasm
|
||||
wasm-pack build --target web
|
||||
mv pkg ../frontend/pkg
|
||||
cd "$ROOT/wasm"
|
||||
wasm-pack build --target web --release
|
||||
rm -rf "$ROOT/frontend/pkg"
|
||||
mv pkg "$ROOT/frontend/pkg"
|
||||
|
||||
echo "Building server..."
|
||||
cd ../server
|
||||
cargo build --release
|
||||
cd "$ROOT"
|
||||
cargo build -p chronoseal-server --bin chronoseal --release
|
||||
|
||||
echo "Done."
|
||||
Executable
+46
@@ -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
+9
-2
@@ -1,5 +1,12 @@
|
||||
#!/bin/bash
|
||||
bash build.sh
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
|
||||
"$ROOT/scripts/build.sh"
|
||||
|
||||
echo "Release artifacts:"
|
||||
echo " - server/target/release/antibot-server"
|
||||
echo " - target/release/chronoseal"
|
||||
echo " - frontend/ (including pkg/)"
|
||||
echo " - chronoseal.service"
|
||||
echo " - scripts/install.sh"
|
||||
+15
-2
@@ -1,20 +1,33 @@
|
||||
[package]
|
||||
name = "chronoseal-server"
|
||||
version = "0.2.0"
|
||||
version = "0.6.0"
|
||||
edition = "2021"
|
||||
|
||||
[[bin]]
|
||||
name = "chronoseal"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
shared = { path = "../shared" }
|
||||
axum = "0.7"
|
||||
clap = { version = "4", features = ["derive", "env", "wrap_help"] }
|
||||
clap_complete = "4"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
serde_yaml = "0.9"
|
||||
toml = "0.8"
|
||||
rusqlite = { version = "0.31", features = ["bundled"] }
|
||||
r2d2 = "0.8"
|
||||
r2d2_sqlite = "0.24"
|
||||
thiserror = "2"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = "0.3"
|
||||
tracing-appender = "0.2"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
|
||||
tower = "0.4"
|
||||
tower-http = { version = "0.5", features = ["cors", "fs"] }
|
||||
hex = "0.4"
|
||||
base64 = "0.22"
|
||||
rand = "0.8"
|
||||
ed25519-dalek = "2"
|
||||
valkey = "0.0.0-alpha5"
|
||||
@@ -1,24 +1,22 @@
|
||||
use std::sync::Arc;
|
||||
use crate::session::AppState;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub async fn cleanup_loop(state: Arc<AppState>) {
|
||||
loop {
|
||||
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;
|
||||
let now = crate::storage::current_time_ms();
|
||||
let _ = db.execute(
|
||||
"DELETE FROM sessions WHERE expires_at < ?1",
|
||||
rusqlite::params![now],
|
||||
);
|
||||
if let Err(err) = state.db_pool.delete_expired_sessions() {
|
||||
tracing::error!("Failed to evict expired sessions: {}", err);
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
rl.evict_stale();
|
||||
rl.evict_stale(window_secs);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+19
-15
@@ -2,27 +2,31 @@ use ed25519_dalek::{Signature, VerifyingKey};
|
||||
use shared::protocol::HeartbeatRequest;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
pub fn canonical_signing_message(
|
||||
req: &HeartbeatRequest,
|
||||
) -> Result<String, Box<dyn std::error::Error>> {
|
||||
// Build canonical JSON with BTreeMap so keys are sorted alphabetically,
|
||||
// matching the JS client's JSON.stringify(obj, Object.keys(obj).sort()).
|
||||
let mut payload: BTreeMap<&str, serde_json::Value> = BTreeMap::new();
|
||||
payload.insert("entropyData", serde_json::to_value(&req.entropy_data)?);
|
||||
payload.insert("fingerprint", serde_json::to_value(&req.fingerprint)?);
|
||||
payload.insert("geneCommitment", serde_json::json!(req.gene_commitment));
|
||||
payload.insert("mutationStep", serde_json::json!(req.mutation_step));
|
||||
payload.insert("prevHash", serde_json::json!(req.prev_hash));
|
||||
payload.insert("sessionId", serde_json::json!(req.session_id));
|
||||
payload.insert("stackState", serde_json::to_value(&req.stack_state)?);
|
||||
payload.insert("timestamp", serde_json::json!(req.timestamp));
|
||||
Ok(serde_json::to_string(&payload)?)
|
||||
}
|
||||
|
||||
pub fn verify_signature(
|
||||
pub_key_bytes: &[u8],
|
||||
req: &HeartbeatRequest,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let pk = VerifyingKey::from_bytes(
|
||||
&pub_key_bytes.try_into().map_err(|_| "invalid pubkey")?,
|
||||
)?;
|
||||
let pk = VerifyingKey::from_bytes(&pub_key_bytes.try_into().map_err(|_| "invalid pubkey")?)?;
|
||||
let sig_bytes = hex::decode(&req.signature)?;
|
||||
let sig = Signature::from_slice(&sig_bytes)?;
|
||||
|
||||
// Build canonical JSON with BTreeMap so keys are sorted alphabetically,
|
||||
// matching the JS client's JSON.stringify(obj, Object.keys(obj).sort()).
|
||||
// Sorted order: entropyData, fingerprint, prevHash, sessionId, stackState, timestamp
|
||||
let mut payload: BTreeMap<&str, serde_json::Value> = BTreeMap::new();
|
||||
payload.insert("entropyData", serde_json::to_value(&req.entropy_data)?);
|
||||
payload.insert("fingerprint", serde_json::to_value(&req.fingerprint)?);
|
||||
payload.insert("prevHash", serde_json::json!(req.prev_hash));
|
||||
payload.insert("sessionId", serde_json::json!(req.session_id));
|
||||
payload.insert("stackState", serde_json::to_value(&req.stack_state)?);
|
||||
payload.insert("timestamp", serde_json::json!(req.timestamp));
|
||||
let message = serde_json::to_string(&payload)?;
|
||||
let message = canonical_signing_message(req)?;
|
||||
|
||||
pk.verify_strict(message.as_bytes(), &sig)?;
|
||||
Ok(())
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
@@ -2,9 +2,15 @@ use shared::protocol::Fingerprint;
|
||||
|
||||
pub fn validate(fp: &Fingerprint) -> Result<(), Box<dyn std::error::Error>> {
|
||||
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")?;
|
||||
if dpr <= 0.0 || dpr > 5.0 { return Err("dpr".into()); }
|
||||
if fp.hardware_concurrency == 0 { return Err("hw".into()); }
|
||||
if dpr <= 0.0 || dpr > 5.0 {
|
||||
return Err("dpr".into());
|
||||
}
|
||||
if fp.hardware_concurrency == 0 {
|
||||
return Err("hw".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
+131
-28
@@ -1,47 +1,150 @@
|
||||
mod cleanup;
|
||||
mod cli;
|
||||
mod config;
|
||||
mod crypto;
|
||||
mod errors;
|
||||
mod fingerprint;
|
||||
mod middleware;
|
||||
mod output;
|
||||
mod ratelimit;
|
||||
mod routes;
|
||||
mod runtime;
|
||||
mod session;
|
||||
mod storage;
|
||||
mod trust;
|
||||
mod vm;
|
||||
|
||||
use axum::Router;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
use tracing::info;
|
||||
|
||||
use session::AppState;
|
||||
use clap::{CommandFactory, Parser};
|
||||
use cli::{Cli, Command, ConfigCommand, GenerateCommand};
|
||||
use config::Config;
|
||||
use std::path::PathBuf;
|
||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
|
||||
|
||||
#[tokio::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");
|
||||
let state = Arc::new(AppState {
|
||||
db: Mutex::new(conn),
|
||||
rate_limiter: Mutex::new(ratelimit::RateLimiter::new(
|
||||
shared::constants::RATE_LIMIT_COUNT,
|
||||
shared::constants::RATE_LIMIT_WINDOW_SECS,
|
||||
)),
|
||||
});
|
||||
async fn try_main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let cli = Cli::parse();
|
||||
if let Some(config_path) = cli.globals.config.as_deref() {
|
||||
std::env::set_var("CHRONOSEAL_CONFIG", config_path);
|
||||
}
|
||||
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
|
||||
let bg_state = state.clone();
|
||||
tokio::spawn(async move { cleanup::cleanup_loop(bg_state).await });
|
||||
match &cli.command {
|
||||
None | Some(Command::Run(_)) => {
|
||||
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()
|
||||
.route("/init", axum::routing::post(routes::init::handler))
|
||||
.route("/hb", axum::routing::post(routes::heartbeat::handler))
|
||||
.nest_service("/", tower_http::services::ServeDir::new("../frontend"))
|
||||
.layer(tower_http::cors::CorsLayer::permissive())
|
||||
.layer(axum::middleware::from_fn(middleware::log_request))
|
||||
.with_state(state);
|
||||
fn log_file_for_command(cli: &Cli) -> Option<PathBuf> {
|
||||
match &cli.command {
|
||||
None => Config::load(cli.globals.config.as_deref())
|
||||
.ok()
|
||||
.and_then(|config| config.log_file),
|
||||
Some(Command::Run(args)) => {
|
||||
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();
|
||||
info!("Server running on :3000");
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
fn init_logging(
|
||||
filter: &str,
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
+44
-10
@@ -3,22 +3,22 @@ use std::time::Instant;
|
||||
|
||||
pub struct RateLimiter {
|
||||
buckets: HashMap<String, (u32, Instant)>,
|
||||
limit: u32,
|
||||
window_secs: u64,
|
||||
}
|
||||
|
||||
impl RateLimiter {
|
||||
pub fn new(limit: u32, window_secs: u64) -> Self {
|
||||
Self { buckets: HashMap::new(), limit, window_secs }
|
||||
pub fn new() -> Self {
|
||||
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 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);
|
||||
true
|
||||
} else if entry.0 >= self.limit {
|
||||
} else if entry.0 >= limit {
|
||||
false
|
||||
} else {
|
||||
entry.0 += 1;
|
||||
@@ -28,10 +28,44 @@ impl RateLimiter {
|
||||
|
||||
/// Remove entries whose rate-limit window has fully elapsed.
|
||||
/// Call this periodically (e.g. from the cleanup loop) to bound memory usage.
|
||||
pub fn evict_stale(&mut self) {
|
||||
let window = self.window_secs;
|
||||
pub fn evict_stale(&mut self, window_secs: u64) {
|
||||
let now = Instant::now();
|
||||
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
|
||||
}
|
||||
}
|
||||
+185
-10
@@ -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 axum::{extract::State, http::StatusCode, Json};
|
||||
use shared::protocol::{HeartbeatRequest, HeartbeatResponse};
|
||||
use std::sync::Arc;
|
||||
|
||||
pub async fn handler(
|
||||
State(state): State<Arc<AppState>>,
|
||||
@@ -9,22 +9,197 @@ pub async fn handler(
|
||||
) -> (StatusCode, Json<HeartbeatResponse>) {
|
||||
// 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;
|
||||
if !rl.check(&payload.session_id) {
|
||||
if !rl.check(&payload.session_id, limit, window_secs) {
|
||||
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;
|
||||
match crate::session::verify_heartbeat(&db, &payload) {
|
||||
Ok(next_salt) => (
|
||||
let config = state.get_config();
|
||||
match crate::session::verify_heartbeat(&state.db_pool, &config, &payload) {
|
||||
Ok(result) => (
|
||||
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) => {
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,14 @@
|
||||
use axum::{extract::State, http::StatusCode, Json};
|
||||
use std::sync::Arc;
|
||||
use shared::protocol::{InitRequest, InitResponse};
|
||||
use crate::errors::SessionError;
|
||||
use crate::session::AppState;
|
||||
use axum::{extract::State, Json};
|
||||
use shared::protocol::{InitRequest, InitResponse};
|
||||
use std::sync::Arc;
|
||||
|
||||
pub async fn handler(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(payload): Json<InitRequest>,
|
||||
) -> Result<Json<InitResponse>, (StatusCode, String)> {
|
||||
let db = state.db.lock().await;
|
||||
crate::session::create_session(&db, &payload.public_key)
|
||||
.map(Json)
|
||||
.map_err(|e| {
|
||||
tracing::error!("Init error: {}", e);
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "Internal".into())
|
||||
})
|
||||
) -> Result<Json<InitResponse>, SessionError> {
|
||||
let config = state.get_config();
|
||||
let resp = crate::session::create_session(&state.db_pool, &config, &payload.public_key)?;
|
||||
Ok(Json(resp))
|
||||
}
|
||||
@@ -1,2 +1,2 @@
|
||||
pub mod init;
|
||||
pub mod heartbeat;
|
||||
pub mod init;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+524
-49
@@ -1,98 +1,573 @@
|
||||
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 config: std::sync::RwLock<crate::config::Config>,
|
||||
}
|
||||
|
||||
use rusqlite::params;
|
||||
use shared::protocol::{HeartbeatRequest, InitResponse};
|
||||
use crate::{crypto, trust, fingerprint, vm, storage};
|
||||
impl AppState {
|
||||
pub fn get_config(&self) -> crate::config::Config {
|
||||
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(
|
||||
conn: &rusqlite::Connection,
|
||||
db: &storage::DbPool,
|
||||
config: &crate::config::Config,
|
||||
pub_key_hex: &str,
|
||||
) -> Result<InitResponse, Box<dyn std::error::Error>> {
|
||||
) -> Result<InitResponse, crate::errors::SessionError> {
|
||||
let pub_key = hex::decode(pub_key_hex)?;
|
||||
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 salt = rand::random::<[u8; shared::constants::SALT_LEN]>();
|
||||
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);
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO sessions (session_id, public_key, salt, last_hash, created_at, last_seen, expires_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
||||
params![session_id, pub_key, salt.to_vec(), initial_hash, now, now, expires_at],
|
||||
)?;
|
||||
|
||||
let opcodes = vm::generate_random_program(8..=16);
|
||||
let opcodes_b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &opcodes);
|
||||
|
||||
let initial_mutation = vm_extensions::generate_order(1, config.gene_size);
|
||||
let initial_mutation_b64 = vm_extensions::encode_order_b64(&initial_mutation);
|
||||
|
||||
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 {
|
||||
session_id,
|
||||
salt: hex::encode(salt),
|
||||
opcodes_b64,
|
||||
initial_hash: hex::encode(&initial_hash),
|
||||
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(
|
||||
conn: &rusqlite::Connection,
|
||||
db: &storage::DbPool,
|
||||
config: &crate::config::Config,
|
||||
req: &HeartbeatRequest,
|
||||
) -> Result<String, Box<dyn std::error::Error>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT public_key, salt, last_hash, expires_at FROM sessions WHERE session_id = ?1",
|
||||
)?;
|
||||
let (pub_key, salt, stored_last_hash, expires_at): (Vec<u8>, Vec<u8>, Vec<u8>, u64) =
|
||||
stmt.query_row(params![req.session_id], |row| {
|
||||
Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
|
||||
})?;
|
||||
) -> Result<HeartbeatVerificationResult, crate::errors::VerificationError> {
|
||||
let session = db
|
||||
.load_session(&req.session_id)
|
||||
.map_err(|e| crate::errors::VerificationError::Storage(e.to_string()))?;
|
||||
let session = session.ok_or(crate::errors::VerificationError::SessionNotFound)?;
|
||||
|
||||
let now = storage::current_time_ms();
|
||||
if now > expires_at {
|
||||
return Err("expired".into());
|
||||
if now > session.expires_at {
|
||||
return Err(crate::errors::VerificationError::Expired);
|
||||
}
|
||||
|
||||
// 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
|
||||
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)?;
|
||||
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(
|
||||
&prev_hash_bytes,
|
||||
req.timestamp,
|
||||
&req.entropy_data,
|
||||
&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_hex = hex::encode(next_salt);
|
||||
let next_environment_blob = gene::encode_environment(&candidate_state.environment)
|
||||
.map_err(|e| crate::errors::VerificationError::GeneState(e.to_string()))?;
|
||||
|
||||
conn.execute(
|
||||
"UPDATE sessions SET last_hash=?1, salt=?2, chain_length=chain_length+1, last_seen=?3 WHERE session_id=?4",
|
||||
params![new_hash, next_salt.to_vec(), now, req.session_id],
|
||||
)?;
|
||||
let update_record = storage::SessionRecord {
|
||||
session_id: req.session_id.clone(),
|
||||
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)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+373
-6
@@ -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 valkey::Client as ValkeyClient;
|
||||
|
||||
pub fn init_db() -> Result<Connection, rusqlite::Error> {
|
||||
let conn = Connection::open_in_memory()?;
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
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(
|
||||
"CREATE TABLE IF NOT EXISTS sessions (
|
||||
session_id TEXT PRIMARY KEY,
|
||||
@@ -12,12 +253,138 @@ pub fn init_db() -> Result<Connection, rusqlite::Error> {
|
||||
chain_length INTEGER NOT NULL DEFAULT 1,
|
||||
created_at INTEGER NOT NULL,
|
||||
last_seen INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL
|
||||
expires_at INTEGER NOT NULL,
|
||||
gene BLOB NOT NULL DEFAULT X'',
|
||||
environment BLOB NOT NULL DEFAULT X'',
|
||||
pending_mutation BLOB NOT NULL DEFAULT X'',
|
||||
pending_mutation_step INTEGER NOT NULL DEFAULT 0
|
||||
);",
|
||||
)?;
|
||||
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 {
|
||||
SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_millis() as u64
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_millis() as u64
|
||||
}
|
||||
+188
-6
@@ -1,7 +1,14 @@
|
||||
use crate::config::Config;
|
||||
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;
|
||||
if !config.require_mouse_activity && events.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
if events.len() < 3 {
|
||||
return Err("few events".into());
|
||||
}
|
||||
@@ -19,18 +26,193 @@ pub fn validate_mouse(data: &EntropyData) -> Result<(), Box<dyn std::error::Erro
|
||||
pauses += 1;
|
||||
}
|
||||
}
|
||||
if total_dist < shared::constants::MIN_MOUSE_TOTAL_DIST {
|
||||
if total_dist < config.min_mouse_total_dist {
|
||||
return Err("insufficient distance".into());
|
||||
}
|
||||
// Speed in px/ms: total distance over elapsed wall-clock time of the event window.
|
||||
let total_time_ms =
|
||||
(events.last().unwrap().timestamp_ms - events[0].timestamp_ms).max(1.0);
|
||||
let total_time_ms = (events.last().unwrap().timestamp_ms - events[0].timestamp_ms).max(1.0);
|
||||
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());
|
||||
}
|
||||
if pauses < shared::constants::MIN_PAUSE_COUNT {
|
||||
if pauses < config.min_pause_count {
|
||||
return Err("no pause".into());
|
||||
}
|
||||
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
@@ -1,4 +1,8 @@
|
||||
use rand::Rng;
|
||||
use shared::{
|
||||
gene::GeneState,
|
||||
vm_extensions::{self, ExecutionTrace, MutationError, MutationOrder},
|
||||
};
|
||||
|
||||
pub fn generate_random_program(len_range: std::ops::RangeInclusive<usize>) -> Vec<u8> {
|
||||
let mut rng = rand::thread_rng();
|
||||
@@ -9,7 +13,7 @@ pub fn generate_random_program(len_range: std::ops::RangeInclusive<usize>) -> Ve
|
||||
if depth < 2 {
|
||||
// Not enough operands for any binary op — push a literal.
|
||||
ops.push(0x00);
|
||||
let val = rng.gen::<u32>();
|
||||
let val = rng.r#gen::<u32>();
|
||||
ops.extend_from_slice(&val.to_le_bytes());
|
||||
depth += 1;
|
||||
} else {
|
||||
@@ -18,7 +22,7 @@ pub fn generate_random_program(len_range: std::ops::RangeInclusive<usize>) -> Ve
|
||||
0x00 => {
|
||||
// PUSH literal
|
||||
ops.push(0x00);
|
||||
let val = rng.gen::<u32>();
|
||||
let val = rng.r#gen::<u32>();
|
||||
ops.extend_from_slice(&val.to_le_bytes());
|
||||
depth += 1;
|
||||
}
|
||||
@@ -43,4 +47,52 @@ pub fn generate_random_program(len_range: std::ops::RangeInclusive<usize>) -> Ve
|
||||
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));
|
||||
}
|
||||
}
|
||||
+5
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "shared"
|
||||
version = "0.2.0"
|
||||
version = "0.6.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
@@ -11,3 +11,7 @@ hex = "0.4"
|
||||
base64 = "0.22"
|
||||
rand = "0.8"
|
||||
ed25519-dalek = { version = "2", features = ["rand_core"] }
|
||||
tracing = "0.1"
|
||||
|
||||
[dev-dependencies]
|
||||
proptest = "1"
|
||||
+10
-9
@@ -1,11 +1,12 @@
|
||||
pub const SESSION_ID_LEN: usize = 32;
|
||||
pub const SALT_LEN: usize = 16;
|
||||
pub const HEARTBEAT_MIN_INTERVAL_MS: u64 = 12_000;
|
||||
pub const HEARTBEAT_MAX_INTERVAL_MS: u64 = 25_000;
|
||||
pub const EXPIRATION_MINUTES: i64 = 30;
|
||||
pub const RATE_LIMIT_COUNT: u32 = 5;
|
||||
pub const RATE_LIMIT_WINDOW_SECS: u64 = 10;
|
||||
pub const MAX_TIMESTAMP_DRIFT_MS: i64 = 30_000;
|
||||
pub const MIN_MOUSE_TOTAL_DIST: f64 = 10.0;
|
||||
pub const MAX_MOUSE_AVG_SPEED: f64 = 2.0; // px/ms
|
||||
pub const MIN_PAUSE_COUNT: u32 = 1;
|
||||
pub const DEFAULT_GENE_SIZE: usize = 512;
|
||||
pub const MAX_GENE_SIZE: usize = 4096;
|
||||
pub const MAX_ENV_RECORDS: usize = 48;
|
||||
pub const MAX_MUTATION_PROGRAM_BYTES: usize = 256;
|
||||
pub const DEFAULT_MUTATION_ROUNDS: u8 = 4;
|
||||
pub const MIN_MUTATION_ROUNDS: u8 = 3;
|
||||
pub const MAX_MUTATION_ROUNDS: u8 = 10;
|
||||
pub const MAX_MUTATION_INSTRUCTION_BUDGET: usize = 2048;
|
||||
pub const HASH_OPCODE_INSTRUCTION_COST: usize = 16;
|
||||
pub const SOFT_CAP_DURATION_MS: u128 = 50;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
use blake3::Hasher;
|
||||
use crate::protocol::{EntropyData, StackState};
|
||||
use blake3::Hasher;
|
||||
|
||||
/// 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> {
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
pub mod constants;
|
||||
pub mod gene;
|
||||
pub mod hashing;
|
||||
pub mod protocol;
|
||||
pub mod vm;
|
||||
pub mod vm_extensions;
|
||||
+17
-6
@@ -1,20 +1,25 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct InitRequest {
|
||||
pub public_key: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct InitResponse {
|
||||
pub session_id: String,
|
||||
pub salt: String,
|
||||
pub opcodes_b64: String,
|
||||
pub initial_hash: String,
|
||||
pub expires_at: u64,
|
||||
pub heartbeat_min_interval_ms: u64,
|
||||
pub heartbeat_max_interval_ms: u64,
|
||||
pub gene_size: u32,
|
||||
pub mutation_step: u64,
|
||||
pub mutation_order_b64: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct HeartbeatRequest {
|
||||
pub session_id: String,
|
||||
pub prev_hash: String,
|
||||
@@ -22,17 +27,23 @@ pub struct HeartbeatRequest {
|
||||
pub entropy_data: EntropyData,
|
||||
pub stack_state: StackState,
|
||||
pub fingerprint: Fingerprint,
|
||||
pub mutation_step: u64,
|
||||
pub gene_commitment: String,
|
||||
pub signature: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct HeartbeatResponse {
|
||||
pub status: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub next_salt: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub next_mutation_step: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub next_mutation_order_b64: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct Fingerprint {
|
||||
#[serde(rename = "aspectRatio")]
|
||||
pub aspect_ratio: String,
|
||||
@@ -42,7 +53,7 @@ pub struct Fingerprint {
|
||||
pub hardware_concurrency: u32,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct EntropyData {
|
||||
pub events: Vec<MouseEvent>,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
use crate::protocol::StackState;
|
||||
|
||||
/// Executes a raw VM mathematical instruction program bytecode slice.
|
||||
///
|
||||
/// This implements the mathematical stack machine interpreter used by the client
|
||||
/// to generate the attestation stack state.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `program` - The raw VM instruction program bytecode.
|
||||
pub fn execute(program: &[u8]) -> StackState {
|
||||
let mut stack: Vec<u32> = Vec::new();
|
||||
let mut ip: usize = 0;
|
||||
while ip < program.len() {
|
||||
let op = program[ip];
|
||||
ip += 1;
|
||||
match op {
|
||||
0x00 => {
|
||||
if ip + 4 > program.len() {
|
||||
break;
|
||||
}
|
||||
let val = u32::from_le_bytes([
|
||||
program[ip],
|
||||
program[ip + 1],
|
||||
program[ip + 2],
|
||||
program[ip + 3],
|
||||
]);
|
||||
ip += 4;
|
||||
stack.push(val);
|
||||
}
|
||||
0x01..=0x07 => {
|
||||
if stack.len() < 2 {
|
||||
break;
|
||||
}
|
||||
let b = stack.pop().unwrap();
|
||||
let a = stack.pop().unwrap();
|
||||
let r = match op {
|
||||
0x01 => a.wrapping_add(b),
|
||||
0x02 => a.wrapping_sub(b),
|
||||
0x03 => a.wrapping_mul(b),
|
||||
0x04 => a ^ b,
|
||||
0x05 => a & b,
|
||||
0x06 => a | b,
|
||||
0x07 => a.rotate_left(b % 32),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
stack.push(r);
|
||||
}
|
||||
0x08 => {
|
||||
if stack.is_empty() {
|
||||
break;
|
||||
}
|
||||
let a = stack.pop().unwrap();
|
||||
stack.push(!a);
|
||||
}
|
||||
0x09 => {
|
||||
let r = crate::hashing::hash_stack(&stack);
|
||||
stack.clear();
|
||||
stack.push(r);
|
||||
}
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
StackState {
|
||||
stack,
|
||||
ip: ip as u16,
|
||||
}
|
||||
}
|
||||
@@ -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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
use proptest::prelude::*;
|
||||
|
||||
proptest! {
|
||||
#[test]
|
||||
fn test_vm_execute_never_panics(ref program in any::<Vec<u8>>()) {
|
||||
// VM execution should be totally robust and never panic on any random input stream.
|
||||
let state = shared::vm::execute(program);
|
||||
// The instruction pointer (ip) should not exceed the program length
|
||||
assert!(state.ip as usize <= program.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_gene_environment_roundtrip_never_panics(ref data in any::<Vec<u8>>()) {
|
||||
// Try to decode random bytes. It should either succeed or fail gracefully, never panic.
|
||||
let _ = shared::gene::decode_environment(data);
|
||||
}
|
||||
}
|
||||
+3
-2
@@ -1,10 +1,10 @@
|
||||
[package]
|
||||
name = "chronoseal-wasm"
|
||||
version = "0.2.0"
|
||||
version = "0.6.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
shared = { path = "../shared" }
|
||||
@@ -18,3 +18,4 @@ getrandom = { version = "0.2", features = ["js"] }
|
||||
hex = "0.4"
|
||||
base64 = "0.22"
|
||||
serde-wasm-bindgen = "0.6"
|
||||
tracing = "0.1"
|
||||
+3
-5
@@ -3,7 +3,7 @@ use std::cell::RefCell;
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
thread_local! {
|
||||
static KEYPAIR: RefCell<Option<SigningKey>> = RefCell::new(None);
|
||||
static KEYPAIR: RefCell<Option<SigningKey>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
@@ -51,10 +51,8 @@ pub fn compute_next_hash(
|
||||
) -> String {
|
||||
let prev = hex::decode(prev_hash_hex).unwrap_or_default();
|
||||
let salt = hex::decode(salt_hex).unwrap_or_default();
|
||||
let entropy =
|
||||
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 entropy = 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 new = shared::hashing::next_chain_hash(&prev, timestamp, &entropy, &stack, &salt);
|
||||
hex::encode(new)
|
||||
}
|
||||
@@ -4,3 +4,4 @@ pub mod entropy;
|
||||
pub mod fingerprint;
|
||||
pub mod transport;
|
||||
pub mod vm;
|
||||
pub mod vm_extensions;
|
||||
+155
-7
@@ -1,10 +1,12 @@
|
||||
use wasm_bindgen::prelude::*;
|
||||
use shared::protocol::StackState;
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn run_program(program_b64: &str) -> JsValue {
|
||||
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);
|
||||
serde_wasm_bindgen::to_value(&state).unwrap()
|
||||
}
|
||||
@@ -17,13 +19,22 @@ fn execute(program: &[u8]) -> StackState {
|
||||
ip += 1;
|
||||
match op {
|
||||
0x00 => {
|
||||
if ip + 4 > program.len() { break; }
|
||||
let val = u32::from_le_bytes([program[ip], program[ip+1], program[ip+2], program[ip+3]]);
|
||||
if ip + 4 > program.len() {
|
||||
break;
|
||||
}
|
||||
let val = u32::from_le_bytes([
|
||||
program[ip],
|
||||
program[ip + 1],
|
||||
program[ip + 2],
|
||||
program[ip + 3],
|
||||
]);
|
||||
ip += 4;
|
||||
stack.push(val);
|
||||
}
|
||||
0x01..=0x07 => {
|
||||
if stack.len() < 2 { break; }
|
||||
if stack.len() < 2 {
|
||||
break;
|
||||
}
|
||||
let b = stack.pop().unwrap();
|
||||
let a = stack.pop().unwrap();
|
||||
let r = match op {
|
||||
@@ -39,7 +50,9 @@ fn execute(program: &[u8]) -> StackState {
|
||||
stack.push(r);
|
||||
}
|
||||
0x08 => {
|
||||
if stack.is_empty() { break; }
|
||||
if stack.is_empty() {
|
||||
break;
|
||||
}
|
||||
let a = stack.pop().unwrap();
|
||||
stack.push(!a);
|
||||
}
|
||||
@@ -51,5 +64,140 @@ fn execute(program: &[u8]) -> StackState {
|
||||
_ => 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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user