commit e537e8bed5ff1c2ae8a56b9152f7a02c2b76eec5 Author: Niko Marmeladkov Date: Tue Jun 30 12:49:11 2026 +0300 Initial commit: QuiC Call server diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ac0251e --- /dev/null +++ b/.gitignore @@ -0,0 +1,23 @@ +# Binaries +./qcc +./qccd + +# Config +config.yaml + +# Runtime data +data/ +acme/ + +# IDE / OS +.idea/ +.vscode/ +*.swp +*.swo +*~ +.DS_Store +Thumbs.db + +# Temp +/tmp +*.log diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..506f081 --- /dev/null +++ b/LICENSE @@ -0,0 +1,659 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are 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. + + 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. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, may become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + 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 Affero 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 be +otherwise 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. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + 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 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 work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero 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 Affero 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 Affero General Public License, +you may choose any version ever published by the Free Software Foundation. + + If the Program specifies that a proxy can decide whether future +versions of the GNU Affero 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. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero 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 Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + 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 AGPL, see +. diff --git a/README.md b/README.md new file mode 100644 index 0000000..d55fe11 --- /dev/null +++ b/README.md @@ -0,0 +1,69 @@ +# QuiC Call + +A server for audio/video calls between people. A family phone system replacement. + +No WebRTC. No STUN/TURN. Everything goes through the server. + +## How it works + +1. Client connects via QUIC (TLS + ACME Let's Encrypt) +2. Server sends a PoW (Hashcash) challenge +3. Client solves it, sends solution with their Ed25519 public key +4. Server allocates a phone number (`+0 XXX YYY ZZZ`), issues an X.509 certificate proving ownership +5. Client can re-roll the number once per 24h +6. To call someone, dial their number — server relays media blindly (E2EE) + +## Architecture + +- **Transport**: QUIC (hysteria fork of quic-go) over UDP +- **TLS**: Automatic via certmagic + Cloudflare DNS-01 (ACME) +- **Auth**: Proof-of-Work (Hashcash SHA-256), no passwords +- **PKI**: Ed25519 CA, X.509 client certs with phone number as CN +- **DB**: Custom append-only frame-based engine with AES-256-GCM at-rest encryption (hash-chain integrity) +- **Signaling**: Binary varint frames over QUIC streams +- **Media relay**: Unreliable QUIC datagrams, server forwards blindly +- **Congestion control**: Brutal (CBR) from Hysteria +- **E2EE**: X25519 + HKDF + ChaCha20-Poly1305 (key exchange during signaling) + +## Status + +Pre-alpha. Server compiles and passes vet. No client application exists yet. + +## Quick start + +```bash +# Build +go build -o qccd ./cmd/qccd + +# Configure +cp config.yaml.example config.yaml +# edit config.yaml — set ACME domains, email, Cloudflare API token + +# Run +./qccd server +``` + +## Configuration + +See `config.yaml.example`. + +Key options: + +| Field | Default | Description | +|-------|---------|-------------| +| `server.listen` | `:1963` | UDP listen address | +| `pow.difficulty` | `22` | PoW leading zero bits | +| `identity.cooldown` | `24h` | Minimum time between number re-rolls | +| `identity.prefix` | `+0` | Phone number prefix | +| `ca.key_type` | `ed25519` | CA key type | +| `relay.bandwidth_up` | `100 mbps` | Media relay upstream bandwidth | + +## Dependencies + +- `github.com/apernet/quic-go` — QUIC transport (Hysteria fork) +- `github.com/caddyserver/certmagic` + `github.com/libdns/cloudflare` — ACME TLS +- `golang.org/x/crypto` — X25519, ChaCha20-Poly1305, HKDF + +## License + +AGPL-3.0-only diff --git a/cmd/qccd/config.go b/cmd/qccd/config.go new file mode 100644 index 0000000..a0e7f4b --- /dev/null +++ b/cmd/qccd/config.go @@ -0,0 +1,54 @@ +package qccd + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/spf13/viper" +) + +func initConfig() { + if cfgFile != "" { + viper.SetConfigFile(cfgFile) + } else { + viper.SetConfigName("config") + viper.SetConfigType("yaml") + viper.AddConfigPath(".") + viper.AddConfigPath("./data") + } + + viper.SetEnvPrefix("QCC") + viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) + viper.AutomaticEnv() + + viper.SetDefault("server.listen", ":1963") + viper.SetDefault("acme.type", "dns") + viper.SetDefault("pow.difficulty", 22) + viper.SetDefault("pow.challenge_ttl", "30s") + viper.SetDefault("identity.cooldown", "24h") + viper.SetDefault("identity.prefix", "+0") + viper.SetDefault("ca.key_type", "ed25519") + viper.SetDefault("ca.validity", "87600h") + viper.SetDefault("relay.bandwidth_up", "100 mbps") + viper.SetDefault("relay.bandwidth_down", "100 mbps") + viper.SetDefault("relay.media_timeout", "30s") + viper.SetDefault("data_dir", "./data") + viper.SetDefault("log_level", "info") + + if err := viper.ReadInConfig(); err != nil { + if _, ok := err.(viper.ConfigFileNotFoundError); ok && cfgFile == "" { + fmt.Fprintln(os.Stderr, "Warning: config.yaml not found, using defaults") + } else { + fmt.Fprintf(os.Stderr, "Error reading config file: %s\n", err) + os.Exit(1) + } + } + + dataDir := viper.GetString("data_dir") + if err := os.MkdirAll(filepath.Join(dataDir, "acme"), 0700); err != nil { + fmt.Fprintf(os.Stderr, "Error creating data directory: %s\n", err) + os.Exit(1) + } +} diff --git a/cmd/qccd/main.go b/cmd/qccd/main.go new file mode 100644 index 0000000..999038f --- /dev/null +++ b/cmd/qccd/main.go @@ -0,0 +1,30 @@ +package qccd + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/spf13/cobra" +) + +var cfgFile string + +var rootCmd = &cobra.Command{ + Use: filepath.Base(os.Args[0]), + Short: "QuiC Call server daemon", + Long: "QuiC Call — audio/video call server with PoW authentication and X.509 PKI. A phone system replacement.", +} + +func Execute() { + if err := rootCmd.Execute(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func init() { + cobra.OnInitialize(initConfig) + rootCmd.PersistentFlags().StringVarP(&cfgFile, "config", "c", "", "config file path (default ./config.yaml)") + rootCmd.AddCommand(serverCmd) +} diff --git a/cmd/qccd/server.go b/cmd/qccd/server.go new file mode 100644 index 0000000..60d79cc --- /dev/null +++ b/cmd/qccd/server.go @@ -0,0 +1,55 @@ +package qccd + +import ( + "context" + "fmt" + "os" + "os/signal" + "syscall" + + "github.com/niko/qcc/internal/config" + "github.com/niko/qcc/internal/server" + "github.com/spf13/cobra" + "go.uber.org/zap" +) + +var serverCmd = &cobra.Command{ + Use: "server", + Short: "Start QuiC Call server", + Run: runServer, +} + +func runServer(cmd *cobra.Command, args []string) { + logger, _ := zap.NewProduction() + defer logger.Sync() + + cfg := config.Load() + + srv, err := server.New(cfg, logger) + if err != nil { + logger.Fatal("Failed to create server", zap.Error(err)) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + + go func() { + sig := <-sigCh + logger.Info("Received signal, shutting down", zap.String("signal", sig.String())) + cancel() + }() + + logger.Info("Starting server", + zap.String("listen", cfg.Server.Listen), + zap.Strings("domains", cfg.ACME.Domains), + ) + + if err := srv.Start(ctx); err != nil { + logger.Fatal("Server error", zap.Error(err)) + } + + fmt.Println("Server stopped gracefully") +} diff --git a/config.yaml.example b/config.yaml.example new file mode 100644 index 0000000..d2ef7f1 --- /dev/null +++ b/config.yaml.example @@ -0,0 +1,32 @@ +server: + listen: ":1963" + +acme: + domains: ["n1kt.ru"] + email: "admin@n1kt.ru" + ca: "letsencrypt" + type: "dns" + dns: + name: "cloudflare" + config: + cloudflare_api_token: "" # или через env: CF_API_TOKEN + +pow: + difficulty: 22 + challenge_ttl: 30s + +identity: + cooldown: 24h + prefix: "+0" + +ca: + key_type: "ed25519" + validity: 87600h # 10 лет + +relay: + bandwidth_up: "100 mbps" + bandwidth_down: "100 mbps" + media_timeout: 30s + +data_dir: "./data" +log_level: "info" diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..44a589e --- /dev/null +++ b/go.mod @@ -0,0 +1,41 @@ +module github.com/niko/qcc + +go 1.26 + +require ( + github.com/apernet/quic-go v0.59.1-0.20260425001925-6c6cc9bcb716 + github.com/caddyserver/certmagic v0.17.2 + github.com/libdns/cloudflare v0.1.1 + github.com/spf13/cobra v1.10.2 + github.com/spf13/viper v1.21.0 + go.uber.org/zap v1.24.0 + golang.org/x/crypto v0.47.0 +) + +require ( + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/go-viper/mapstructure/v2 v2.5.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/sagikazarmark/locafero v0.12.0 // indirect + github.com/spf13/afero v1.15.0 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + go.uber.org/atomic v1.11.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.34.0 // indirect +) + +require ( + github.com/klauspost/cpuid/v2 v2.1.1 // indirect + github.com/libdns/libdns v0.2.2 // indirect + github.com/mholt/acmez v1.0.4 // indirect + github.com/miekg/dns v1.1.50 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/mod v0.32.0 // indirect + golang.org/x/net v0.49.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/tools v0.41.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..7973fee --- /dev/null +++ b/go.sum @@ -0,0 +1,141 @@ +github.com/apernet/quic-go v0.59.1-0.20260425001925-6c6cc9bcb716 h1:J1O+xpLuJWkdYbw5JPGwBqIHs2J8tiEP7Py9lPqkN2I= +github.com/apernet/quic-go v0.59.1-0.20260425001925-6c6cc9bcb716/go.mod h1:Npbg8qBtAZlsAB3FWmqwlVh5jtVG6a4DlYsOylUpvzA= +github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8= +github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= +github.com/caddyserver/certmagic v0.17.2 h1:o30seC1T/dBqBCNNGNHWwj2i5/I/FMjBbTAhjADP3nE= +github.com/caddyserver/certmagic v0.17.2/go.mod h1:ouWUuC490GOLJzkyN35eXfV8bSbwMwSf4bdhkIxtdQE= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= +github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/klauspost/cpuid/v2 v2.1.1 h1:t0wUqjowdm8ezddV5k0tLWVklVuvLJpoHeb4WBdydm0= +github.com/klauspost/cpuid/v2 v2.1.1/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/libdns/cloudflare v0.1.1 h1:FVPfWwP8zZCqj268LZjmkDleXlHPlFU9KC4OJ3yn054= +github.com/libdns/cloudflare v0.1.1/go.mod h1:9VK91idpOjg6v7/WbjkEW49bSCxj00ALesIFDhJ8PBU= +github.com/libdns/libdns v0.2.2 h1:O6ws7bAfRPaBsgAYt8MDe2HcNBGC29hkZ9MX2eUSX3s= +github.com/libdns/libdns v0.2.2/go.mod h1:4Bj9+5CQiNMVGf87wjX4CY3HQJypUHRuLvlsfsZqLWQ= +github.com/mholt/acmez v1.0.4 h1:N3cE4Pek+dSolbsofIkAYz6H1d3pE+2G0os7QHslf80= +github.com/mholt/acmez v1.0.4/go.mod h1:qFGLZ4u+ehWINeJZjzPlsnjJBCPAADWTcIqE/7DAYQY= +github.com/miekg/dns v1.1.50 h1:DQUfb9uc6smULcREF09Uc+/Gd46YWqJd5DbpPE9xkcA= +github.com/miekg/dns v1.1.50/go.mod h1:e3IlAVfNqAllflbibAZEWOXOQ+Ynzk/dDozDxY7XnME= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4= +github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= +github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +go.uber.org/goleak v1.1.11 h1:wy28qYRKZgnJTxGxvye5/wgWr1EKjmUDGYox5mGlRlI= +go.uber.org/goleak v1.1.11/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ= +go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko= +go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o= +go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.21.0/go.mod h1:wjWOCqI0f2ZZrJF/UufIOkiC8ii6tm1iqIsLo76RfJw= +go.uber.org/zap v1.24.0 h1:FiJd5l1UOLj0wCgbSE0rwwXHzEdAZS6hiiSnxJN/D60= +go.uber.org/zap v1.24.0/go.mod h1:2kMP+WWQ8aoFoedH3T2sq6iJ2yDWpHbP0f6MQbS9Gkg= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= +golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= +golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= +golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= +golang.org/x/net v0.0.0-20210726213435-c6fcb2dbf985/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20220630215102-69896b714898/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/tools v0.1.6-0.20210726203631-07bc1bf47fb2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= +golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/auth/pow.go b/internal/auth/pow.go new file mode 100644 index 0000000..7192dd4 --- /dev/null +++ b/internal/auth/pow.go @@ -0,0 +1,96 @@ +package auth + +import ( + "crypto/sha256" + "encoding/binary" + "fmt" + "math/bits" + "time" +) + +type PoWChallenge struct { + ServerNonce [8]byte + Timestamp int64 + Bits int +} + +type PoWSolution struct { + ClientNonce [8]byte + ClientPubKey [32]byte +} + +func NewChallenge(bits int) *PoWChallenge { + var nonce [8]byte + binary.BigEndian.PutUint64(nonce[:], uint64(time.Now().UnixNano()>>10)) + return &PoWChallenge{ + ServerNonce: nonce, + Timestamp: time.Now().UnixNano(), + Bits: bits, + } +} + +func (c *PoWChallenge) Verify(solution *PoWSolution, challengeTTL time.Duration) error { + if time.Since(time.Unix(0, c.Timestamp)) > challengeTTL { + return fmt.Errorf("challenge expired") + } + + hashInput := make([]byte, 32) + binary.BigEndian.PutUint64(hashInput[0:8], uint64(c.Timestamp)) + copy(hashInput[8:16], c.ServerNonce[:]) + copy(hashInput[16:24], solution.ClientNonce[:]) + copy(hashInput[24:], solution.ClientPubKey[:]) + + hash := sha256.Sum256(hashInput) + + leadingZeros := 0 + for i := 0; i < 32; i++ { + if hash[i] == 0 { + leadingZeros += 8 + } else { + leadingZeros += bits.LeadingZeros8(hash[i]) + break + } + } + + if leadingZeros < c.Bits { + return fmt.Errorf("insufficient PoW difficulty: got %d, need %d", leadingZeros, c.Bits) + } + + return nil +} + +func (c *PoWChallenge) Marshal() []byte { + buf := make([]byte, 20) + copy(buf[0:8], c.ServerNonce[:]) + binary.BigEndian.PutUint64(buf[8:16], uint64(c.Timestamp)) + binary.BigEndian.PutUint32(buf[16:20], uint32(c.Bits)) + return buf +} + +func UnmarshalChallenge(data []byte) *PoWChallenge { + if len(data) < 20 { + return nil + } + c := &PoWChallenge{} + copy(c.ServerNonce[:], data[0:8]) + c.Timestamp = int64(binary.BigEndian.Uint64(data[8:16])) + c.Bits = int(binary.BigEndian.Uint32(data[16:20])) + return c +} + +func MarshalSolution(nonce [8]byte, pubKey [32]byte) []byte { + buf := make([]byte, 40) + copy(buf[0:8], nonce[:]) + copy(buf[8:40], pubKey[:]) + return buf +} + +func UnmarshalSolution(data []byte) *PoWSolution { + if len(data) < 40 { + return nil + } + s := &PoWSolution{} + copy(s.ClientNonce[:], data[0:8]) + copy(s.ClientPubKey[:], data[8:40]) + return s +} diff --git a/internal/ca/ca.go b/internal/ca/ca.go new file mode 100644 index 0000000..638d050 --- /dev/null +++ b/internal/ca/ca.go @@ -0,0 +1,150 @@ +package ca + +import ( + "crypto" + "crypto/ed25519" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "fmt" + "math/big" + "os" + "path/filepath" + "time" +) + +type CA struct { + key crypto.Signer + cert *x509.Certificate +} + +func NewOrLoad(dataDir string, keyType string, validity time.Duration) (*CA, error) { + keyPath := filepath.Join(dataDir, "ca.key") + certPath := filepath.Join(dataDir, "ca.crt") + + caKey, err := loadOrGenKey(keyPath, keyType) + if err != nil { + return nil, fmt.Errorf("ca key: %w", err) + } + + caCert, err := loadOrGenCert(certPath, caKey, validity) + if err != nil { + return nil, fmt.Errorf("ca cert: %w", err) + } + + return &CA{key: caKey, cert: caCert}, nil +} + +func loadOrGenKey(path, keyType string) (crypto.Signer, error) { + if data, err := os.ReadFile(path); err == nil { + block, _ := pem.Decode(data) + if block == nil { + return nil, fmt.Errorf("invalid CA key PEM") + } + key, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + return nil, err + } + return key.(crypto.Signer), nil + } + + var signer crypto.Signer + switch keyType { + case "ed25519": + _, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return nil, err + } + signer = priv + default: + return nil, fmt.Errorf("unsupported key type: %s", keyType) + } + + b, err := x509.MarshalPKCS8PrivateKey(signer) + if err != nil { + return nil, err + } + pemData := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: b}) + if err := os.WriteFile(path, pemData, 0600); err != nil { + return nil, err + } + return signer, nil +} + +func loadOrGenCert(path string, key crypto.Signer, validity time.Duration) (*x509.Certificate, error) { + if data, err := os.ReadFile(path); err == nil { + block, _ := pem.Decode(data) + if block == nil { + return nil, fmt.Errorf("invalid CA cert PEM") + } + return x509.ParseCertificate(block.Bytes) + } + + pub := key.Public() + serial, _ := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + now := time.Now() + + template := &x509.Certificate{ + SerialNumber: serial, + Subject: pkix.Name{ + CommonName: "QuiC Call Root CA", + }, + NotBefore: now, + NotAfter: now.Add(validity), + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, + BasicConstraintsValid: true, + IsCA: true, + MaxPathLenZero: true, + } + + certDER, err := x509.CreateCertificate(rand.Reader, template, template, pub, key) + if err != nil { + return nil, err + } + + pemData := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}) + if err := os.WriteFile(path, pemData, 0644); err != nil { + return nil, err + } + + return x509.ParseCertificate(certDER) +} + +func (ca *CA) IssueCert(commonName string, pub crypto.PublicKey, validity time.Duration) ([]byte, error) { + serial, _ := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + now := time.Now() + + template := &x509.Certificate{ + SerialNumber: serial, + Subject: pkix.Name{ + CommonName: commonName, + Organization: []string{"QuiC Call"}, + }, + NotBefore: now, + NotAfter: now.Add(validity), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{ + x509.ExtKeyUsageClientAuth, + }, + } + + certDER, err := x509.CreateCertificate(rand.Reader, template, ca.cert, pub, ca.key) + if err != nil { + return nil, fmt.Errorf("create cert: %w", err) + } + + return certDER, nil +} + +func (ca *CA) CAKey() crypto.Signer { + return ca.key +} + +func (ca *CA) CACert() *x509.Certificate { + return ca.cert +} + +func (ca *CA) CACertPEM() []byte { + return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: ca.cert.Raw}) +} diff --git a/internal/call/signaling.go b/internal/call/signaling.go new file mode 100644 index 0000000..3fad6b9 --- /dev/null +++ b/internal/call/signaling.go @@ -0,0 +1,322 @@ +package call + +import ( + "encoding/binary" + "sync" + "time" + + "github.com/apernet/quic-go" + "go.uber.org/zap" + + "github.com/niko/qcc/internal/protocol" + "github.com/niko/qcc/pkg/types" +) + +type CallSession interface { + Number() string + PubKey() [32]byte + Stream() protocol.FrameReadWriter + Conn() *quic.Conn + SetCallID(id uint64) + CallID() uint64 + ClearCall() + IsAuthenticated() bool +} + +type SessionRegistry interface { + GetByNumber(number string) CallSession + GetAll() []CallSession + Register(sess CallSession, number string) + Unregister(number string) +} + +type BrutalApplier func(conn *quic.Conn, bps uint64) + +type Manager struct { + mu sync.RWMutex + calls map[uint64]*Call + userCalls map[string]uint64 + nextCallID uint64 + logger *zap.Logger + sessions SessionRegistry + mediaTimeout time.Duration + brutalBPS uint64 + applyBrutal BrutalApplier + stopCh chan struct{} +} + +type Call struct { + ID uint64 + From string + To string + FromSess CallSession + ToSess CallSession + StartedAt int64 + Active bool + FromE2EEKey [32]byte + ToE2EEKey [32]byte +} + +func NewManager(logger *zap.Logger, sessions SessionRegistry, mediaTimeout time.Duration, brutalBPS uint64, applyBrutal BrutalApplier) *Manager { + m := &Manager{ + calls: make(map[uint64]*Call), + userCalls: make(map[string]uint64), + nextCallID: 1, + logger: logger, + sessions: sessions, + mediaTimeout: mediaTimeout, + brutalBPS: brutalBPS, + applyBrutal: applyBrutal, + stopCh: make(chan struct{}), + } + go m.cleanupLoop() + return m +} + +func (m *Manager) cleanupLoop() { + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + for { + select { + case <-ticker.C: + m.cleanupStale() + case <-m.stopCh: + return + } + } +} + +func (m *Manager) Stop() { + close(m.stopCh) +} + +func (m *Manager) cleanupStale() { + m.mu.Lock() + defer m.mu.Unlock() + for id, call := range m.calls { + if !call.Active && time.Since(time.Unix(0, call.StartedAt)) > m.mediaTimeout { + delete(m.calls, id) + delete(m.userCalls, call.From) + delete(m.userCalls, call.To) + } + } +} + +func parseDialPayload(payload []byte) (targetNumber string, e2eePubKey [32]byte) { + if len(payload) < 1 { + return "", [32]byte{} + } + numLen := int(payload[0]) + if len(payload) < 1+numLen+32 { + return "", [32]byte{} + } + targetNumber = string(payload[1 : 1+numLen]) + copy(e2eePubKey[:], payload[1+numLen:1+numLen+32]) + return targetNumber, e2eePubKey +} + +func marshalRingPayload(number string, e2eePubKey [32]byte) []byte { + buf := make([]byte, 1+len(number)+32) + buf[0] = byte(len(number)) + copy(buf[1:], number) + copy(buf[1+len(number):], e2eePubKey[:]) + return buf +} + +func (m *Manager) HandleDial(sess CallSession, payload []byte) { + targetNumber, callerE2EE := parseDialPayload(payload) + if targetNumber == "" { + sendError(sess, types.ErrInvalidRequest, "empty target number") + return + } + + fromNumber := sess.Number() + if fromNumber == targetNumber { + sendError(sess, types.ErrSelfCall, "cannot call yourself") + return + } + + m.mu.Lock() + if _, busy := m.userCalls[fromNumber]; busy { + m.mu.Unlock() + sendError(sess, types.ErrBusy, "you already have an active call") + return + } + + targetSess := m.sessions.GetByNumber(targetNumber) + if targetSess == nil { + m.mu.Unlock() + sendError(sess, types.ErrNumberNotFound, "target offline") + return + } + + if _, busy := m.userCalls[targetNumber]; busy { + m.mu.Unlock() + sendError(sess, types.ErrBusy, "target is busy") + return + } + + callID := m.nextCallID + m.nextCallID++ + call := &Call{ + ID: callID, + From: fromNumber, + To: targetNumber, + FromSess: sess, + ToSess: targetSess, + StartedAt: time.Now().UnixNano(), + FromE2EEKey: callerE2EE, + } + m.calls[callID] = call + m.userCalls[fromNumber] = callID + m.userCalls[targetNumber] = callID + m.mu.Unlock() + + sess.SetCallID(callID) + targetSess.SetCallID(callID) + + ringPayload := marshalRingPayload(fromNumber, callerE2EE) + if st := targetSess.Stream(); st != nil { + st.WriteFrame(byte(types.OpRing), ringPayload) + } + + m.logger.Info("Call initiated", + zap.Uint64("call_id", callID), + zap.String("from", fromNumber), + zap.String("to", targetNumber)) +} + +func (m *Manager) HandleAccept(sess CallSession, payload []byte) { + callID := sess.CallID() + if callID == 0 { + sendError(sess, types.ErrCallNotFound, "no active call") + return + } + + var calleeE2EE [32]byte + if len(payload) >= 32 { + copy(calleeE2EE[:], payload[:32]) + } + + m.mu.Lock() + call, exists := m.calls[callID] + if !exists { + m.mu.Unlock() + sendError(sess, types.ErrCallNotFound, "call not found") + return + } + call.ToE2EEKey = calleeE2EE + call.Active = true + m.mu.Unlock() + + if m.brutalBPS > 0 && m.applyBrutal != nil { + if c := call.FromSess.Conn(); c != nil { + m.applyBrutal(c, m.brutalBPS) + } + if c := call.ToSess.Conn(); c != nil { + m.applyBrutal(c, m.brutalBPS) + } + } + + peerPayload := make([]byte, 32) + copy(peerPayload, calleeE2EE[:]) + + if st := call.FromSess.Stream(); st != nil { + st.WriteFrame(byte(types.OpPeerAccept), peerPayload) + } + + m.logger.Info("Call accepted", + zap.Uint64("call_id", callID), + zap.Bool("e2ee", calleeE2EE != [32]byte{})) +} + +func (m *Manager) HandleReject(sess CallSession, _ []byte) { + m.endCall(sess, false) +} + +func (m *Manager) HandleEnd(sess CallSession, _ []byte) { + m.endCall(sess, true) +} + +func (m *Manager) endCall(sess CallSession, notify bool) { + callID := sess.CallID() + if callID == 0 { + return + } + + m.mu.Lock() + call, exists := m.calls[callID] + if !exists { + m.mu.Unlock() + return + } + delete(m.calls, callID) + delete(m.userCalls, call.From) + delete(m.userCalls, call.To) + m.mu.Unlock() + + sess.ClearCall() + + var otherSess CallSession + if sess == call.FromSess { + otherSess = call.ToSess + } else { + otherSess = call.FromSess + } + + if otherSess != nil { + otherSess.ClearCall() + if notify { + if st := otherSess.Stream(); st != nil { + st.WriteFrame(byte(types.OpPeerEnd), nil) + } + } + } + + m.logger.Info("Call ended", + zap.Uint64("call_id", callID)) +} + +func (m *Manager) GetCall(callID uint64) *Call { + m.mu.RLock() + defer m.mu.RUnlock() + return m.calls[callID] +} + +func (m *Manager) RelayMedia(sender *quic.Conn, data []byte) { + pkt, ok := protocol.UnmarshalMediaPacket(data) + if !ok { + return + } + + m.mu.RLock() + call, exists := m.calls[pkt.CallID] + m.mu.RUnlock() + if !exists || !call.Active { + return + } + + var target *quic.Conn + if sender == call.FromSess.Conn() { + target = call.ToSess.Conn() + } else if sender == call.ToSess.Conn() { + target = call.FromSess.Conn() + } else { + return + } + + if err := target.SendDatagram(data); err != nil { + m.logger.Warn("Failed to relay media datagram", + zap.Uint64("call_id", pkt.CallID), + zap.Error(err)) + } +} + +func sendError(sess CallSession, code types.ErrorCode, msg string) { + payload := make([]byte, 2+len(msg)) + binary.BigEndian.PutUint16(payload[0:2], uint16(code)) + copy(payload[2:], msg) + if st := sess.Stream(); st != nil { + st.WriteFrame(byte(types.OpError), payload) + } +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..62a85b3 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,64 @@ +package config + +import ( + "time" + + "github.com/spf13/viper" +) + +type Config struct { + Server ServerConfig `mapstructure:"server"` + ACME ACMEConfig `mapstructure:"acme"` + PoW PoWConfig `mapstructure:"pow"` + Identity IdentityConfig `mapstructure:"identity"` + CA CAConfig `mapstructure:"ca"` + Relay RelayConfig `mapstructure:"relay"` + DataDir string `mapstructure:"data_dir"` + LogLevel string `mapstructure:"log_level"` +} + +type ServerConfig struct { + Listen string `mapstructure:"listen"` +} + +type ACMEConfig struct { + Domains []string `mapstructure:"domains"` + Email string `mapstructure:"email"` + CA string `mapstructure:"ca"` + Type string `mapstructure:"type"` + DNS ACMEDNSConfig `mapstructure:"dns"` +} + +type ACMEDNSConfig struct { + Name string `mapstructure:"name"` + Config map[string]string `mapstructure:"config"` +} + +type PoWConfig struct { + Difficulty int `mapstructure:"difficulty"` + ChallengeTTL time.Duration `mapstructure:"challenge_ttl"` +} + +type IdentityConfig struct { + Cooldown time.Duration `mapstructure:"cooldown"` + Prefix string `mapstructure:"prefix"` +} + +type CAConfig struct { + KeyType string `mapstructure:"key_type"` + Validity time.Duration `mapstructure:"validity"` +} + +type RelayConfig struct { + BandwidthUp string `mapstructure:"bandwidth_up"` + BandwidthDown string `mapstructure:"bandwidth_down"` + MediaTimeout time.Duration `mapstructure:"media_timeout"` +} + +func Load() *Config { + cfg := &Config{} + if err := viper.Unmarshal(cfg); err != nil { + panic(err) + } + return cfg +} diff --git a/internal/congestion/brutal/brutal.go b/internal/congestion/brutal/brutal.go new file mode 100644 index 0000000..9768f1a --- /dev/null +++ b/internal/congestion/brutal/brutal.go @@ -0,0 +1,144 @@ +package brutal + +import ( + "time" + + "github.com/apernet/quic-go/congestion" + "github.com/apernet/quic-go/monotime" + + "github.com/niko/qcc/internal/congestion/common" +) + +const ( + pktInfoSlotCount = 5 + minSampleCount = 50 + minAckRate = 0.8 + congestionWindowMultiplier = 2 +) + +var _ congestion.CongestionControl = &BrutalSender{} + +type BrutalSender struct { + rttStats congestion.RTTStatsProvider + bps congestion.ByteCount + maxDatagramSize congestion.ByteCount + pacer *common.Pacer + + pktInfoSlots [pktInfoSlotCount]pktInfo + ackRate float64 +} + +type pktInfo struct { + Timestamp int64 + AckCount uint64 + LossCount uint64 +} + +func NewBrutalSender(bps uint64) *BrutalSender { + bs := &BrutalSender{ + bps: congestion.ByteCount(bps), + maxDatagramSize: congestion.InitialPacketSize, + ackRate: 1, + } + bs.pacer = common.NewPacer(func() congestion.ByteCount { + return congestion.ByteCount(float64(bs.bps) / bs.ackRate) + }) + return bs +} + +func (b *BrutalSender) SetRTTStatsProvider(rttStats congestion.RTTStatsProvider) { + b.rttStats = rttStats +} + +func (b *BrutalSender) TimeUntilSend(bytesInFlight congestion.ByteCount) monotime.Time { + return b.pacer.TimeUntilSend() +} + +func (b *BrutalSender) HasPacingBudget(now monotime.Time) bool { + return b.pacer.Budget(now) >= b.maxDatagramSize +} + +func (b *BrutalSender) CanSend(bytesInFlight congestion.ByteCount) bool { + return bytesInFlight <= b.GetCongestionWindow() +} + +func (b *BrutalSender) GetCongestionWindow() congestion.ByteCount { + rtt := b.rttStats.SmoothedRTT() + if rtt <= 0 { + return 10240 + } + cwnd := congestion.ByteCount(float64(b.bps) * rtt.Seconds() * congestionWindowMultiplier / b.ackRate) + if cwnd < b.maxDatagramSize { + cwnd = b.maxDatagramSize + } + return cwnd +} + +func (b *BrutalSender) OnPacketSent(sentTime monotime.Time, bytesInFlight congestion.ByteCount, + packetNumber congestion.PacketNumber, bytes congestion.ByteCount, isRetransmittable bool, +) { + b.pacer.SentPacket(sentTime, bytes) +} + +func (b *BrutalSender) OnPacketAcked(number congestion.PacketNumber, ackedBytes congestion.ByteCount, + priorInFlight congestion.ByteCount, eventTime monotime.Time, +) { +} + +func (b *BrutalSender) OnCongestionEvent(number congestion.PacketNumber, lostBytes congestion.ByteCount, + priorInFlight congestion.ByteCount, +) { +} + +func (b *BrutalSender) OnCongestionEventEx(priorInFlight congestion.ByteCount, eventTime monotime.Time, ackedPackets []congestion.AckedPacketInfo, lostPackets []congestion.LostPacketInfo) { + currentTimestamp := int64(time.Duration(eventTime) / time.Second) + slot := currentTimestamp % pktInfoSlotCount + if b.pktInfoSlots[slot].Timestamp == currentTimestamp { + b.pktInfoSlots[slot].LossCount += uint64(len(lostPackets)) + b.pktInfoSlots[slot].AckCount += uint64(len(ackedPackets)) + } else { + b.pktInfoSlots[slot].Timestamp = currentTimestamp + b.pktInfoSlots[slot].AckCount = uint64(len(ackedPackets)) + b.pktInfoSlots[slot].LossCount = uint64(len(lostPackets)) + } + b.updateAckRate(currentTimestamp) +} + +func (b *BrutalSender) SetMaxDatagramSize(size congestion.ByteCount) { + b.maxDatagramSize = size + b.pacer.SetMaxDatagramSize(size) +} + +func (b *BrutalSender) updateAckRate(currentTimestamp int64) { + minTimestamp := currentTimestamp - pktInfoSlotCount + var ackCount, lossCount uint64 + for _, info := range b.pktInfoSlots { + if info.Timestamp < minTimestamp { + continue + } + ackCount += info.AckCount + lossCount += info.LossCount + } + if ackCount+lossCount < minSampleCount { + b.ackRate = 1 + return + } + rate := float64(ackCount) / float64(ackCount+lossCount) + if rate < minAckRate { + b.ackRate = minAckRate + return + } + b.ackRate = rate +} + +func (b *BrutalSender) InSlowStart() bool { + return false +} + +func (b *BrutalSender) InRecovery() bool { + return false +} + +func (b *BrutalSender) MaybeExitSlowStart() {} + +func (b *BrutalSender) OnRetransmissionTimeout(packetsRetransmitted bool) {} diff --git a/internal/congestion/common/pacer.go b/internal/congestion/common/pacer.go new file mode 100644 index 0000000..85a361d --- /dev/null +++ b/internal/congestion/common/pacer.go @@ -0,0 +1,74 @@ +package common + +import ( + "time" + + "github.com/apernet/quic-go/congestion" + "github.com/apernet/quic-go/monotime" +) + +const ( + maxBurstPackets = 10 + maxBurstPacingDelayMultiplier = 4 +) + +type Pacer struct { + budgetAtLastSent congestion.ByteCount + maxDatagramSize congestion.ByteCount + lastSentTime monotime.Time + getBandwidth func() congestion.ByteCount +} + +func NewPacer(getBandwidth func() congestion.ByteCount) *Pacer { + p := &Pacer{ + budgetAtLastSent: maxBurstPackets * congestion.InitialPacketSize, + maxDatagramSize: congestion.InitialPacketSize, + getBandwidth: getBandwidth, + } + return p +} + +func (p *Pacer) SentPacket(sendTime monotime.Time, size congestion.ByteCount) { + budget := p.Budget(sendTime) + if size > budget { + p.budgetAtLastSent = 0 + } else { + p.budgetAtLastSent = budget - size + } + p.lastSentTime = sendTime +} + +func (p *Pacer) Budget(now monotime.Time) congestion.ByteCount { + if p.lastSentTime.IsZero() { + return p.maxBurstSize() + } + budget := p.budgetAtLastSent + (p.getBandwidth()*congestion.ByteCount(now.Sub(p.lastSentTime).Nanoseconds()))/1e9 + if budget < 0 { + budget = congestion.ByteCount(1<<62 - 1) + } + return min(p.maxBurstSize(), budget) +} + +func (p *Pacer) maxBurstSize() congestion.ByteCount { + return max( + congestion.ByteCount((maxBurstPacingDelayMultiplier*congestion.MinPacingDelay).Nanoseconds())*p.getBandwidth()/1e9, + maxBurstPackets*p.maxDatagramSize, + ) +} + +func (p *Pacer) TimeUntilSend() monotime.Time { + if p.budgetAtLastSent >= p.maxDatagramSize { + return 0 + } + diff := 1e9 * uint64(p.maxDatagramSize-p.budgetAtLastSent) + bw := uint64(p.getBandwidth()) + d := diff / bw + if diff%bw > 0 { + d++ + } + return p.lastSentTime.Add(max(congestion.MinPacingDelay, time.Duration(d)*time.Nanosecond)) +} + +func (p *Pacer) SetMaxDatagramSize(s congestion.ByteCount) { + p.maxDatagramSize = s +} diff --git a/internal/crypto/atrest.go b/internal/crypto/atrest.go new file mode 100644 index 0000000..06d7e31 --- /dev/null +++ b/internal/crypto/atrest.go @@ -0,0 +1,43 @@ +package crypto + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "io" +) + +func EncryptRecord(plaintext []byte, key [32]byte) (nonce [12]byte, ciphertext []byte, authTag []byte, err error) { + block, err := aes.NewCipher(key[:]) + if err != nil { + return nonce, nil, nil, err + } + aead, err := cipher.NewGCM(block) + if err != nil { + return nonce, nil, nil, err + } + _, err = io.ReadFull(rand.Reader, nonce[:]) + if err != nil { + return nonce, nil, nil, err + } + sealed := aead.Seal(nil, nonce[:], plaintext, nil) + tagStart := len(sealed) - aead.Overhead() + ciphertext = sealed[:tagStart] + authTag = sealed[tagStart:] + return nonce, ciphertext, authTag, nil +} + +func DecryptRecord(ciphertext []byte, nonce [12]byte, authTag []byte, key [32]byte) ([]byte, error) { + block, err := aes.NewCipher(key[:]) + if err != nil { + return nil, err + } + aead, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + sealed := make([]byte, len(ciphertext)+len(authTag)) + copy(sealed, ciphertext) + copy(sealed[len(ciphertext):], authTag) + return aead.Open(nil, nonce[:], sealed, nil) +} diff --git a/internal/crypto/e2ee.go b/internal/crypto/e2ee.go new file mode 100644 index 0000000..717fb5c --- /dev/null +++ b/internal/crypto/e2ee.go @@ -0,0 +1,74 @@ +package crypto + +import ( + "crypto/rand" + "crypto/sha256" + "io" + + "golang.org/x/crypto/chacha20poly1305" + "golang.org/x/crypto/curve25519" + "golang.org/x/crypto/hkdf" +) + +type E2EEKeypair struct { + PrivateKey [32]byte + PublicKey [32]byte +} + +func GenerateE2EEKeypair() (*E2EEKeypair, error) { + priv := make([]byte, 32) + if _, err := io.ReadFull(rand.Reader, priv); err != nil { + return nil, err + } + // Clamp for X25519 + priv[0] &= 248 + priv[31] &= 127 + priv[31] |= 64 + + pub, err := curve25519.X25519(priv, curve25519.Basepoint) + if err != nil { + return nil, err + } + var privArr [32]byte + var pubArr [32]byte + copy(privArr[:], priv) + copy(pubArr[:], pub) + return &E2EEKeypair{PrivateKey: privArr, PublicKey: pubArr}, nil +} + +func DeriveE2EEKey(privateKey, publicKey [32]byte) []byte { + shared, _ := curve25519.X25519(privateKey[:], publicKey[:]) + + salt := make([]byte, 32) + info := []byte("qcc-e2ee-media-key") + hkdf := hkdf.New(sha256.New, shared, salt, info) + key := make([]byte, 32) + if _, err := io.ReadFull(hkdf, key); err != nil { + return nil + } + return key +} + +func EncryptE2EE(key []byte, plaintext []byte) ([]byte, error) { + aead, err := chacha20poly1305.New(key) + if err != nil { + return nil, err + } + nonce := make([]byte, chacha20poly1305.NonceSizeX) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return nil, err + } + return aead.Seal(nonce, nonce, plaintext, nil), nil +} + +func DecryptE2EE(key []byte, ciphertext []byte) ([]byte, error) { + aead, err := chacha20poly1305.New(key) + if err != nil { + return nil, err + } + if len(ciphertext) < chacha20poly1305.NonceSizeX { + return nil, io.ErrUnexpectedEOF + } + nonce := ciphertext[:chacha20poly1305.NonceSizeX] + return aead.Open(nil, nonce, ciphertext[chacha20poly1305.NonceSizeX:], nil) +} diff --git a/internal/identity/manager.go b/internal/identity/manager.go new file mode 100644 index 0000000..6fc7f40 --- /dev/null +++ b/internal/identity/manager.go @@ -0,0 +1,242 @@ +package identity + +import ( + "encoding/binary" + "fmt" + "time" + + "go.uber.org/zap" + + "github.com/niko/qcc/internal/store" + "github.com/niko/qcc/pkg/types" + "golang.org/x/crypto/blake2b" +) + +type Identity struct { + Number string + PubKey [32]byte + CertDER []byte + CreatedAt int64 + LastReroll int64 +} + +type Manager struct { + store *store.Table + masterKey [32]byte + logger *zap.Logger + prefix string + cooldown time.Duration +} + +func NewManager(engine *store.Engine, masterKey [32]byte, prefix string, cooldown time.Duration, logger *zap.Logger) (*Manager, error) { + t, err := engine.GetTable("identities") + if err != nil { + return nil, fmt.Errorf("get identities table: %w", err) + } + store.SetMasterKey(masterKey) + return &Manager{ + store: t, + masterKey: masterKey, + logger: logger, + prefix: prefix, + cooldown: cooldown, + }, nil +} + +func (m *Manager) Allocate(pubKey [32]byte) (*Identity, error) { + var number string + for attempts := 0; attempts < 100; attempts++ { + number = GenerateNumber(m.prefix) + keyHash := numberKeyHash(number) + if !m.store.Has(keyHash) { + break + } + } + + keyHash := numberKeyHash(number) + if m.store.Has(keyHash) { + return nil, fmt.Errorf("failed to generate unique number") + } + + now := time.Now().UnixNano() + ident := &Identity{ + Number: number, + PubKey: pubKey, + CreatedAt: now, + LastReroll: now, + } + + payload := m.marshalIdentity(ident) + if err := m.store.Insert(keyHash, types.RecIdentity, 0, payload, m.masterKey); err != nil { + return nil, fmt.Errorf("store identity: %w", err) + } + + return ident, nil +} + +func (m *Manager) Reroll(pubKey [32]byte) (*Identity, error) { + var foundIdent *Identity + var foundHash uint64 + + err := m.store.Iterate(func(keyHash uint64, payload []byte) bool { + ident := m.unmarshalIdentity(payload) + if ident != nil && ident.PubKey == pubKey { + foundIdent = ident + foundHash = keyHash + return false + } + return true + }, m.masterKey) + + if err != nil { + return nil, err + } + if foundIdent == nil { + return nil, fmt.Errorf("identity not found for pubkey") + } + + elapsed := time.Since(time.Unix(0, foundIdent.LastReroll)) + if elapsed < m.cooldown { + retryAfter := m.cooldown - elapsed + return nil, &CooldownError{RetryAfter: retryAfter} + } + + m.store.Delete(foundHash) + + number := GenerateNumber(m.prefix) + keyHash := numberKeyHash(number) + now := time.Now().UnixNano() + + newIdent := &Identity{ + Number: number, + PubKey: pubKey, + CreatedAt: foundIdent.CreatedAt, + LastReroll: now, + } + + payload := m.marshalIdentity(newIdent) + if err := m.store.Insert(keyHash, types.RecIdentity, 0, payload, m.masterKey); err != nil { + return nil, err + } + + return newIdent, nil +} + +func (m *Manager) GetByNumber(number string) (*Identity, error) { + keyHash := numberKeyHash(number) + payload, err := m.store.Get(keyHash, m.masterKey) + if err != nil { + return nil, fmt.Errorf("identity not found: %s", number) + } + ident := m.unmarshalIdentity(payload) + if ident == nil { + return nil, fmt.Errorf("invalid identity data") + } + return ident, nil +} + +func (m *Manager) Exists(number string) bool { + return m.store.Has(numberKeyHash(number)) +} + +func numberKeyHash(number string) uint64 { + h, _ := blake2b.New(8, nil) + h.Write([]byte(number)) + return binary.BigEndian.Uint64(h.Sum(nil)) +} + +func (m *Manager) marshalIdentity(ident *Identity) []byte { + buf := make([]byte, 8+32+2+len(ident.CertDER)+8+8) + off := 0 + PutString(buf, &off, ident.Number) + copy(buf[off:off+32], ident.PubKey[:]) + off += 32 + PutBytes(buf, &off, ident.CertDER) + binary.BigEndian.PutUint64(buf[off:off+8], uint64(ident.CreatedAt)) + off += 8 + binary.BigEndian.PutUint64(buf[off:off+8], uint64(ident.LastReroll)) + return buf +} + +func (m *Manager) unmarshalIdentity(data []byte) *Identity { + if len(data) < 50 { + return nil + } + ident := &Identity{} + off := 0 + number, ok := GetString(data, &off) + if !ok { + return nil + } + ident.Number = number + if off+32 > len(data) { + return nil + } + copy(ident.PubKey[:], data[off:off+32]) + off += 32 + certDER, ok := GetBytes(data, &off) + if !ok { + return nil + } + ident.CertDER = certDER + if off+16 > len(data) { + return nil + } + ident.CreatedAt = int64(binary.BigEndian.Uint64(data[off:])) + off += 8 + ident.LastReroll = int64(binary.BigEndian.Uint64(data[off:])) + return ident +} + +type CooldownError struct { + RetryAfter time.Duration +} + +func (e *CooldownError) Error() string { + return fmt.Sprintf("cooldown: retry after %s", e.RetryAfter) +} + +func PutString(buf []byte, off *int, s string) { + l := len(s) + binary.BigEndian.PutUint16(buf[*off:*off+2], uint16(l)) + *off += 2 + copy(buf[*off:*off+l], s) + *off += l +} + +func GetString(data []byte, off *int) (string, bool) { + if *off+2 > len(data) { + return "", false + } + l := int(binary.BigEndian.Uint16(data[*off:])) + *off += 2 + if *off+l > len(data) { + return "", false + } + s := string(data[*off : *off+l]) + *off += l + return s, true +} + +func PutBytes(buf []byte, off *int, b []byte) { + l := len(b) + binary.BigEndian.PutUint16(buf[*off:*off+2], uint16(l)) + *off += 2 + copy(buf[*off:*off+l], b) + *off += l +} + +func GetBytes(data []byte, off *int) ([]byte, bool) { + if *off+2 > len(data) { + return nil, false + } + l := int(binary.BigEndian.Uint16(data[*off:])) + *off += 2 + if *off+l > len(data) { + return nil, false + } + b := make([]byte, l) + copy(b, data[*off:*off+l]) + *off += l + return b, true +} diff --git a/internal/identity/number.go b/internal/identity/number.go new file mode 100644 index 0000000..126e194 --- /dev/null +++ b/internal/identity/number.go @@ -0,0 +1,40 @@ +package identity + +import ( + "crypto/rand" + "fmt" + "math/big" + "strings" +) + +func GenerateNumber(prefix string) string { + n1 := randInt(100, 999) + n2 := randInt(100, 999) + n3 := randInt(1000, 9999) + return fmt.Sprintf("%s %d %d %d", prefix, n1, n2, n3) +} + +func randInt(min, max int) int { + n, _ := rand.Int(rand.Reader, big.NewInt(int64(max-min+1))) + return min + int(n.Int64()) +} + +func NormalizeNumber(s string) string { + s = strings.TrimSpace(s) + parts := strings.Fields(s) + if len(parts) == 4 && strings.HasPrefix(parts[0], "+") { + return fmt.Sprintf("%s %s %s %s", parts[0], parts[1], parts[2], parts[3]) + } + return s +} + +func ValidateNumber(s string) bool { + parts := strings.Fields(s) + if len(parts) != 4 { + return false + } + if len(parts[0]) < 2 || parts[0][0] != '+' { + return false + } + return true +} diff --git a/internal/protocol/frame.go b/internal/protocol/frame.go new file mode 100644 index 0000000..6bec11f --- /dev/null +++ b/internal/protocol/frame.go @@ -0,0 +1,119 @@ +package protocol + +import ( + "encoding/binary" + "fmt" + "io" +) + +type FrameReadWriter interface { + ReadFrame() (*Frame, error) + WriteFrame(opCode byte, payload []byte) error +} + +const maxFramePayloadSize = 1 << 24 + +type Frame struct { + OpCode byte + Payload []byte +} + +func EncodeFrame(opCode byte, payload []byte) []byte { + payloadLen := uint64(len(payload)) + buf := make([]byte, 1+varintLen(payloadLen)+len(payload)) + buf[0] = opCode + off := 1 + off += encodeVarint(buf[off:], uint64(payloadLen)) + copy(buf[off:], payload) + return buf +} + +func ReadFrame(r io.Reader) (*Frame, error) { + opBuf := make([]byte, 1) + if _, err := io.ReadFull(r, opBuf); err != nil { + return nil, fmt.Errorf("read opcode: %w", err) + } + opCode := opBuf[0] + + payloadLen, err := readVarint(r) + if err != nil { + return nil, fmt.Errorf("read payload length: %w", err) + } + if payloadLen > maxFramePayloadSize { + return nil, fmt.Errorf("payload too large: %d", payloadLen) + } + + payload := make([]byte, payloadLen) + if payloadLen > 0 { + if _, err := io.ReadFull(r, payload); err != nil { + return nil, fmt.Errorf("read payload: %w", err) + } + } + + return &Frame{OpCode: opCode, Payload: payload}, nil +} + +func WriteFrame(w io.Writer, opCode byte, payload []byte) error { + data := EncodeFrame(opCode, payload) + _, err := w.Write(data) + return err +} + +func encodeVarint(buf []byte, v uint64) int { + i := 0 + for v >= 0x80 { + buf[i] = byte(v) | 0x80 + v >>= 7 + i++ + } + buf[i] = byte(v) + return i + 1 +} + +func varintLen(v uint64) int { + l := 0 + for { + l++ + if v < 0x80 { + break + } + v >>= 7 + } + return l +} + +func readVarint(r io.Reader) (uint64, error) { + var v uint64 + var shift uint + for { + b := make([]byte, 1) + if _, err := io.ReadFull(r, b); err != nil { + return 0, err + } + v |= uint64(b[0]&0x7F) << shift + if b[0]&0x80 == 0 { + break + } + shift += 7 + if shift > 63 { + return 0, fmt.Errorf("varint too long") + } + } + return v, nil +} + +func ReadUint64(buf []byte) uint64 { + return binary.BigEndian.Uint64(buf) +} + +func PutUint64(buf []byte, v uint64) { + binary.BigEndian.PutUint64(buf, v) +} + +func ReadUint32(buf []byte) uint32 { + return binary.BigEndian.Uint32(buf) +} + +func PutUint32(buf []byte, v uint32) { + binary.BigEndian.PutUint32(buf, v) +} diff --git a/internal/protocol/media.go b/internal/protocol/media.go new file mode 100644 index 0000000..e50d6f8 --- /dev/null +++ b/internal/protocol/media.go @@ -0,0 +1,82 @@ +package protocol + +import ( + "encoding/binary" + "io" + + "github.com/niko/qcc/pkg/types" +) + +const MediaHeaderSize = 8 + 4 + 1 + 1 + 4 + +type MediaPacket struct { + CallID uint64 + SeqNum uint32 + Codec types.CodecType + Flags types.MediaFlags + Timestamp uint32 + Payload []byte +} + +func MarshalMediaPacket(pkt *MediaPacket) []byte { + size := MediaHeaderSize + len(pkt.Payload) + buf := make([]byte, size) + off := 0 + binary.BigEndian.PutUint64(buf[off:], pkt.CallID) + off += 8 + binary.LittleEndian.PutUint32(buf[off:], pkt.SeqNum) + off += 4 + buf[off] = byte(pkt.Codec) + off++ + buf[off] = byte(pkt.Flags) + off++ + binary.BigEndian.PutUint32(buf[off:], pkt.Timestamp) + off += 4 + copy(buf[off:], pkt.Payload) + return buf +} + +func UnmarshalMediaPacket(data []byte) (*MediaPacket, bool) { + if len(data) < MediaHeaderSize { + return nil, false + } + pkt := &MediaPacket{} + off := 0 + pkt.CallID = binary.BigEndian.Uint64(data[off:]) + off += 8 + pkt.SeqNum = binary.LittleEndian.Uint32(data[off:]) + off += 4 + pkt.Codec = types.CodecType(data[off]) + off++ + pkt.Flags = types.MediaFlags(data[off]) + off++ + pkt.Timestamp = binary.BigEndian.Uint32(data[off:]) + off += 4 + pkt.Payload = make([]byte, len(data)-off) + copy(pkt.Payload, data[off:]) + return pkt, true +} + +func ReadMediaPacket(r io.Reader) (*MediaPacket, error) { + header := make([]byte, MediaHeaderSize) + if _, err := io.ReadFull(r, header); err != nil { + return nil, err + } + pkt := &MediaPacket{} + off := 0 + pkt.CallID = binary.BigEndian.Uint64(header[off:]) + off += 8 + pkt.SeqNum = binary.LittleEndian.Uint32(header[off:]) + off += 4 + pkt.Codec = types.CodecType(header[off]) + off++ + pkt.Flags = types.MediaFlags(header[off]) + off++ + pkt.Timestamp = binary.BigEndian.Uint32(header[off:]) + payload, err := io.ReadAll(r) + if err != nil { + return nil, err + } + pkt.Payload = payload + return pkt, nil +} diff --git a/internal/server/bandwidth.go b/internal/server/bandwidth.go new file mode 100644 index 0000000..7e7c493 --- /dev/null +++ b/internal/server/bandwidth.go @@ -0,0 +1,46 @@ +package server + +import ( + "errors" + "strconv" + "strings" +) + +const ( + _byte = 1 + kilobyte = _byte * 1000 + megabyte = kilobyte * 1000 + gigabyte = megabyte * 1000 +) + +func parseBandwidth(s string) (uint64, error) { + s = strings.ToLower(strings.TrimSpace(s)) + split := 0 + for i, c := range s { + if c < '0' || c > '9' { + split = i + break + } + } + if split == 0 { + return 0, errors.New("invalid bandwidth format") + } + v, err := strconv.ParseUint(s[:split], 10, 64) + if err != nil { + return 0, err + } + unit := strings.TrimSpace(s[split:]) + + switch unit { + case "b", "bps": + return v / 8, nil + case "k", "kb", "kbps": + return v * kilobyte / 8, nil + case "m", "mb", "mbps": + return v * megabyte / 8, nil + case "g", "gb", "gbps": + return v * gigabyte / 8, nil + default: + return 0, errors.New("unsupported bandwidth unit") + } +} diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..efc77d5 --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,374 @@ +package server + +import ( + "context" + "crypto/ed25519" + "crypto/rand" + "crypto/tls" + "encoding/binary" + "fmt" + "sync" + + "github.com/apernet/quic-go" + "github.com/caddyserver/certmagic" + "github.com/libdns/cloudflare" + "go.uber.org/zap" + + "github.com/niko/qcc/internal/auth" + "github.com/niko/qcc/internal/ca" + "github.com/niko/qcc/internal/call" + "github.com/niko/qcc/internal/congestion/brutal" + "github.com/niko/qcc/internal/config" + "github.com/niko/qcc/internal/identity" + "github.com/niko/qcc/internal/protocol" + "github.com/niko/qcc/internal/store" + "github.com/niko/qcc/internal/transport" + "github.com/niko/qcc/pkg/types" +) + +type Server struct { + cfg *config.Config + logger *zap.Logger + st *store.Engine + ca *ca.CA + identMgr *identity.Manager + callMgr *call.Manager + trans *transport.Server + + sessions map[string]*Session + sessionsMu sync.RWMutex + cancel context.CancelFunc +} + +func New(cfg *config.Config, logger *zap.Logger) (*Server, error) { + st, err := store.NewEngine(cfg.DataDir) + if err != nil { + return nil, fmt.Errorf("init store: %w", err) + } + + masterKey := make([]byte, 32) + if _, err := rand.Read(masterKey); err != nil { + return nil, fmt.Errorf("generate master key: %w", err) + } + var mk [32]byte + copy(mk[:], masterKey) + + ca, err := ca.NewOrLoad(cfg.DataDir, cfg.CA.KeyType, cfg.CA.Validity) + if err != nil { + return nil, fmt.Errorf("init ca: %w", err) + } + + identMgr, err := identity.NewManager(st, mk, cfg.Identity.Prefix, cfg.Identity.Cooldown, logger) + if err != nil { + return nil, fmt.Errorf("init identity manager: %w", err) + } + + return &Server{ + cfg: cfg, + logger: logger, + st: st, + ca: ca, + identMgr: identMgr, + sessions: make(map[string]*Session), + }, nil +} + +func (s *Server) Start(ctx context.Context) error { + ctx, s.cancel = context.WithCancel(ctx) + + tlsCfg, err := s.setupTLS() + if err != nil { + return fmt.Errorf("tls setup: %w", err) + } + + brutalBPS, err := parseBandwidth(s.cfg.Relay.BandwidthUp) + if err != nil { + s.logger.Warn("Failed to parse relay bandwidth, using default", zap.Error(err)) + brutalBPS = 10 * 1000 * 1000 / 8 // 10 mbps default + } + + s.callMgr = call.NewManager( + s.logger, + s, + s.cfg.Relay.MediaTimeout, + brutalBPS, + func(conn *quic.Conn, bps uint64) { + conn.SetCongestionControl(brutal.NewBrutalSender(bps)) + }, + ) + + s.trans, err = transport.NewServer(s.cfg.Server.Listen, tlsCfg, s.logger, s) + if err != nil { + return fmt.Errorf("transport: %w", err) + } + + err = s.trans.Start(ctx) + s.shutdown() + return err +} + +func (s *Server) shutdown() { + s.logger.Info("Shutting down server...") + + if s.callMgr != nil { + s.callMgr.Stop() + } + + if s.trans != nil { + s.trans.Close() + } + + s.sessionsMu.Lock() + for number, sess := range s.sessions { + if conn := sess.Conn(); conn != nil { + conn.CloseWithError(0, "server shutdown") + } + delete(s.sessions, number) + } + s.sessionsMu.Unlock() + + if s.st != nil { + s.st.Close() + } + + s.logger.Info("Server shutdown complete") +} + +func (s *Server) setupTLS() (*tls.Config, error) { + if len(s.cfg.ACME.Domains) == 0 || s.cfg.ACME.Email == "" { + s.logger.Warn("ACME not configured, starting without TLS (insecure)") + return &tls.Config{ + MinVersion: tls.VersionTLS13, + InsecureSkipVerify: true, + }, nil + } + + dnsSolver := &certmagic.DNS01Solver{ + DNSProvider: &cloudflare.Provider{ + APIToken: s.cfg.ACME.DNS.Config["cloudflare_api_token"], + }, + } + return transport.NewTLSConfig( + s.cfg.ACME.Domains, + s.cfg.ACME.Email, + s.cfg.ACME.CA, + dnsSolver, + s.logger, + ) +} + +func (s *Server) OnConnect(ctx context.Context, conn *quic.Conn) { + sess := NewSession(conn) + s.logger.Info("New connection", + zap.String("remote", conn.RemoteAddr().String())) + + go s.handleStreams(ctx, conn, sess) + go s.handleDatagrams(ctx, conn, sess) + + <-ctx.Done() + s.Unregister(sess.Number()) + conn.CloseWithError(0, "shutdown") +} + +func (s *Server) handleStreams(ctx context.Context, conn *quic.Conn, sess *Session) { + for { + str, err := conn.AcceptStream(ctx) + if err != nil { + return + } + go s.handleStream(ctx, str, sess) + } +} + +func (s *Server) handleDatagrams(ctx context.Context, conn *quic.Conn, sess *Session) { + for { + data, err := conn.ReceiveDatagram(ctx) + if err != nil { + return + } + if len(data) == 0 { + continue + } + if s.callMgr != nil && sess.IsAuthenticated() && sess.CallID() != 0 { + s.callMgr.RelayMedia(conn, data) + } + } +} + +func (s *Server) handleStream(ctx context.Context, stream *quic.Stream, sess *Session) { + st := transport.NewStream(stream) + sess.SetStream(st) + + for { + frame, err := st.ReadFrame() + if err != nil { + return + } + + switch types.OpCode(frame.OpCode) { + case types.OpGetChallenge: + s.handleGetChallenge(sess) + case types.OpSolve: + s.handleSolve(sess, frame.Payload) + case types.OpReroll: + s.handleReroll(sess) + case types.OpDial: + if !sess.IsAuthenticated() { + sendOpError(st, types.ErrInvalidRequest, "not authenticated") + continue + } + s.callMgr.HandleDial(sess, frame.Payload) + case types.OpAccept: + s.callMgr.HandleAccept(sess, frame.Payload) + case types.OpReject: + s.callMgr.HandleReject(sess, frame.Payload) + case types.OpEnd: + s.callMgr.HandleEnd(sess, frame.Payload) + default: + sendOpError(st, types.ErrInvalidRequest, "unknown opcode") + } + } +} + +func (s *Server) handleGetChallenge(sess *Session) { + challenge := auth.NewChallenge(s.cfg.PoW.Difficulty) + if st := sess.Stream(); st != nil { + st.WriteFrame(byte(types.OpChallenge), challenge.Marshal()) + } +} + +func (s *Server) handleSolve(sess *Session, payload []byte) { + solution := auth.UnmarshalSolution(payload) + if solution == nil { + sendOpError(sess.Stream(), types.ErrPoWInvalid, "invalid solution format") + return + } + + challenge := auth.NewChallenge(s.cfg.PoW.Difficulty) + if err := challenge.Verify(solution, s.cfg.PoW.ChallengeTTL); err != nil { + sendOpError(sess.Stream(), types.ErrPoWInvalid, err.Error()) + return + } + + var pubKey [32]byte + copy(pubKey[:], solution.ClientPubKey[:]) + + ident, err := s.identMgr.Allocate(pubKey) + if err != nil { + sendOpError(sess.Stream(), types.ErrInternal, err.Error()) + return + } + + certDER, err := s.ca.IssueCert(ident.Number, ed25519.PublicKey(pubKey[:]), s.cfg.CA.Validity) + if err != nil { + sendOpError(sess.Stream(), types.ErrInternal, "cert issuance failed") + return + } + ident.CertDER = certDER + + sess.Authenticate(ident.Number, pubKey, certDER) + s.Register(sess, ident.Number) + + payload = marshalIdentityResponse(ident.Number, certDER, s.ca.CACertPEM()) + + if st := sess.Stream(); st != nil { + st.WriteFrame(byte(types.OpIdentity), payload) + } + + s.logger.Info("Client authenticated", + zap.String("number", ident.Number)) +} + +func (s *Server) handleReroll(sess *Session) { + if !sess.IsAuthenticated() { + sendOpError(sess.Stream(), types.ErrInvalidRequest, "not authenticated") + return + } + + newIdent, err := s.identMgr.Reroll(sess.PubKey()) + if err != nil { + if _, ok := err.(*identity.CooldownError); ok { + sendOpError(sess.Stream(), types.ErrCooldown, err.Error()) + } else { + sendOpError(sess.Stream(), types.ErrInternal, err.Error()) + } + return + } + + certDER, err := s.ca.IssueCert(newIdent.Number, ed25519.PublicKey(newIdent.PubKey[:]), s.cfg.CA.Validity) + if err != nil { + sendOpError(sess.Stream(), types.ErrInternal, "cert issuance failed") + return + } + newIdent.CertDER = certDER + + s.Unregister(sess.Number()) + sess.Authenticate(newIdent.Number, newIdent.PubKey, certDER) + s.Register(sess, newIdent.Number) + + payload := marshalIdentityResponse(newIdent.Number, certDER, s.ca.CACertPEM()) + + if st := sess.Stream(); st != nil { + st.WriteFrame(byte(types.OpIdentity), payload) + } + + s.logger.Info("Number rerolled", + zap.String("old", sess.Number()), + zap.String("new", newIdent.Number)) +} + +func marshalIdentityResponse(number string, certDER, caCertPEM []byte) []byte { + payload := make([]byte, len(number)+1+2+len(certDER)+len(caCertPEM)) + off := 0 + copy(payload[off:], []byte(number)) + off += len(number) + payload[off] = 0 + off++ + binary.BigEndian.PutUint16(payload[off:off+2], uint16(len(certDER))) + off += 2 + copy(payload[off:], certDER) + off += len(certDER) + copy(payload[off:], caCertPEM) + return payload +} + +func (s *Server) Register(sess call.CallSession, number string) { + s.sessionsMu.Lock() + defer s.sessionsMu.Unlock() + s.sessions[number] = sess.(*Session) +} + +func (s *Server) Unregister(number string) { + if number == "" { + return + } + s.sessionsMu.Lock() + defer s.sessionsMu.Unlock() + delete(s.sessions, number) +} + +func (s *Server) GetByNumber(number string) call.CallSession { + s.sessionsMu.RLock() + defer s.sessionsMu.RUnlock() + return s.sessions[number] +} + +func (s *Server) GetAll() []call.CallSession { + s.sessionsMu.RLock() + defer s.sessionsMu.RUnlock() + result := make([]call.CallSession, 0, len(s.sessions)) + for _, sess := range s.sessions { + result = append(result, sess) + } + return result +} + +func sendOpError(st protocol.FrameReadWriter, code types.ErrorCode, msg string) { + if st == nil { + return + } + payload := make([]byte, 2+len(msg)) + binary.BigEndian.PutUint16(payload[0:2], uint16(code)) + copy(payload[2:], msg) + st.WriteFrame(byte(types.OpError), payload) +} diff --git a/internal/server/session.go b/internal/server/session.go new file mode 100644 index 0000000..f472cc6 --- /dev/null +++ b/internal/server/session.go @@ -0,0 +1,92 @@ +package server + +import ( + "sync" + + "github.com/apernet/quic-go" + "github.com/niko/qcc/internal/protocol" + "github.com/niko/qcc/internal/transport" +) + +type Session struct { + mu sync.Mutex + conn *quic.Conn + stream *transport.Stream + + authenticated bool + number string + pubKey [32]byte + certDER []byte + + activeCallID uint64 +} + +func NewSession(conn *quic.Conn) *Session { + return &Session{ + conn: conn, + } +} + +func (s *Session) SetStream(st *transport.Stream) { + s.mu.Lock() + defer s.mu.Unlock() + s.stream = st +} + +func (s *Session) Stream() protocol.FrameReadWriter { + s.mu.Lock() + defer s.mu.Unlock() + if s.stream == nil { + return nil + } + return s.stream +} + +func (s *Session) Conn() *quic.Conn { + return s.conn +} + +func (s *Session) Authenticate(number string, pubKey [32]byte, certDER []byte) { + s.mu.Lock() + defer s.mu.Unlock() + s.authenticated = true + s.number = number + s.pubKey = pubKey + s.certDER = certDER +} + +func (s *Session) IsAuthenticated() bool { + s.mu.Lock() + defer s.mu.Unlock() + return s.authenticated +} + +func (s *Session) Number() string { + s.mu.Lock() + defer s.mu.Unlock() + return s.number +} + +func (s *Session) PubKey() [32]byte { + s.mu.Lock() + defer s.mu.Unlock() + return s.pubKey +} + +func (s *Session) SetCallID(id uint64) { + s.mu.Lock() + defer s.mu.Unlock() + s.activeCallID = id +} + +func (s *Session) CallID() uint64 { + s.mu.Lock() + defer s.mu.Unlock() + return s.activeCallID +} + +func (s *Session) ClearCall() { + s.mu.Lock() + defer s.mu.Unlock() + s.activeCallID = 0 +} diff --git a/internal/store/encrypt.go b/internal/store/encrypt.go new file mode 100644 index 0000000..7a3afaf --- /dev/null +++ b/internal/store/encrypt.go @@ -0,0 +1,59 @@ +package store + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "io" + + "github.com/niko/qcc/internal/crypto" +) + +var MasterKey [32]byte + +func SetMasterKey(key [32]byte) { + MasterKey = key +} + +func EncryptRecordPayload(plaintext []byte, key [32]byte) (nonce [12]byte, ciphertext []byte, authTag []byte, err error) { + return crypto.EncryptRecord(plaintext, key) +} + +func DecryptRecordPayload(ciphertext []byte, nonce [12]byte, authTag []byte, key [32]byte) ([]byte, error) { + return crypto.DecryptRecord(ciphertext, nonce, authTag, key) +} + +func EncryptIndexData(data []byte) (nonce [12]byte, ciphertext []byte, authTag []byte, err error) { + block, err := aes.NewCipher(MasterKey[:]) + if err != nil { + return nonce, nil, nil, err + } + aead, err := cipher.NewGCM(block) + if err != nil { + return nonce, nil, nil, err + } + _, err = io.ReadFull(rand.Reader, nonce[:]) + if err != nil { + return nonce, nil, nil, err + } + sealed := aead.Seal(nil, nonce[:], data, nil) + tagStart := len(sealed) - aead.Overhead() + ciphertext = sealed[:tagStart] + authTag = sealed[tagStart:] + return nonce, ciphertext, authTag, nil +} + +func DecryptIndexData(ciphertext []byte, nonce [12]byte, authTag []byte) ([]byte, error) { + block, err := aes.NewCipher(MasterKey[:]) + if err != nil { + return nil, err + } + aead, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + sealed := make([]byte, len(ciphertext)+len(authTag)) + copy(sealed, ciphertext) + copy(sealed[len(ciphertext):], authTag) + return aead.Open(nil, nonce[:], sealed, nil) +} diff --git a/internal/store/engine.go b/internal/store/engine.go new file mode 100644 index 0000000..d58fb09 --- /dev/null +++ b/internal/store/engine.go @@ -0,0 +1,170 @@ +package store + +import ( + "fmt" + "os" + "path/filepath" + "sync" +) + +type Engine struct { + mu sync.RWMutex + DataDir string + Tables map[string]*Table + frames map[string]*frameState +} + +type frameState struct { + lastFrameID uint64 + lastHash [32]byte + frameCount int +} + +func NewEngine(dataDir string) (*Engine, error) { + dirs := []string{ + dataDir, + filepath.Join(dataDir, "identities"), + filepath.Join(dataDir, "calls"), + filepath.Join(dataDir, "indexes"), + filepath.Join(dataDir, "acme"), + } + for _, d := range dirs { + if err := os.MkdirAll(d, 0700); err != nil { + return nil, fmt.Errorf("mkdir %s: %w", d, err) + } + } + e := &Engine{ + DataDir: dataDir, + Tables: make(map[string]*Table), + frames: make(map[string]*frameState), + } + if err := e.initDB(); err != nil { + return nil, err + } + return e, nil +} + +func (e *Engine) initDB() error { + path := filepath.Join(e.DataDir, "qcc.db") + if _, err := os.Stat(path); os.IsNotExist(err) { + f, err := os.Create(path) + if err != nil { + return err + } + defer f.Close() + header := make([]byte, 14) + copy(header[0:4], []byte("QCC\x00")) + header[4] = 1 + header[5] = 0 + _, err = f.Write(header) + return err + } + return nil +} + +func (e *Engine) GetTable(name string) (*Table, error) { + e.mu.Lock() + defer e.mu.Unlock() + + if t, ok := e.Tables[name]; ok { + return t, nil + } + + fs, ok := e.frames[name] + if !ok { + fs = &frameState{} + e.frames[name] = fs + lastFrame, lastHash, count := e.scanFrames(name) + fs.lastFrameID = lastFrame + fs.lastHash = lastHash + fs.frameCount = count + } + + tablePath := filepath.Join(e.DataDir, name) + t := &Table{ + engine: e, + name: name, + path: tablePath, + lastHash: fs.lastHash, + nextID: fs.lastFrameID + 1, + frameCount: fs.frameCount, + index: make(map[uint64]IndexEntry), + } + e.Tables[name] = t + + if err := t.loadLastIndex(); err != nil { + return nil, err + } + return t, nil +} + +func (e *Engine) scanFrames(name string) (uint64, [32]byte, int) { + dir := filepath.Join(e.DataDir, name) + entries, err := os.ReadDir(dir) + if err != nil { + return 0, [32]byte{}, 0 + } + var lastID uint64 + var lastHash [32]byte + count := 0 + var prevFrameID uint64 + var prevHash [32]byte + for _, entry := range entries { + if filepath.Ext(entry.Name()) != ".frm" { + continue + } + data, err := os.ReadFile(filepath.Join(dir, entry.Name())) + if err != nil { + continue + } + if len(data) < 66 { + continue + } + storedChecksum := data[len(data)-32:] + computedChecksum := ComputeFrameChecksum(data[:len(data)-32]) + if string(storedChecksum) != string(computedChecksum[:]) { + continue + } + f, err := UnmarshalFrame(data) + if err != nil { + continue + } + if count > 0 { + if f.Header.PrevFrameID != prevFrameID || + string(f.Header.PrevHash[:]) != string(prevHash[:]) { + continue + } + } + if f.Header.FrameID > lastID { + lastID = f.Header.FrameID + lastHash = computedChecksum + } + prevFrameID = f.Header.FrameID + prevHash = computedChecksum + count++ + } + return lastID, lastHash, count +} + +func (e *Engine) UpdateFrameState(name string, frameID uint64, hash [32]byte) { + e.mu.Lock() + defer e.mu.Unlock() + if fs, ok := e.frames[name]; ok { + if frameID > fs.lastFrameID { + fs.lastFrameID = frameID + fs.lastHash = hash + fs.frameCount++ + } + } +} + +func (e *Engine) Close() error { + e.mu.Lock() + defer e.mu.Unlock() + for _, t := range e.Tables { + if err := t.flushIndex(); err != nil { + return err + } + } + return nil +} diff --git a/internal/store/frame.go b/internal/store/frame.go new file mode 100644 index 0000000..91dbec7 --- /dev/null +++ b/internal/store/frame.go @@ -0,0 +1,195 @@ +package store + +import ( + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "fmt" + "time" + + "github.com/niko/qcc/pkg/types" +) + +type FrameHeader struct { + Magic [4]byte + Version uint16 + FrameID uint64 + PrevFrameID uint64 + PrevHash [32]byte + Timestamp int64 + NumRecords uint32 +} + +const FrameHeaderSize = 4 + 2 + 8 + 8 + 32 + 8 + 4 +const RecordHeaderSize = 1 + 1 + 2 + 8 + 4 + 12 + 16 + +type RecordHeader struct { + RecType types.RecType + Flags uint8 + TableID uint16 + KeyHash uint64 + PayloadSize uint32 + Nonce [12]byte + AuthTag [16]byte +} + +type Frame struct { + Header FrameHeader + Records []Record +} + +type Record struct { + Header RecordHeader + Payload []byte +} + +func NewFrameHeader(prevFrameID uint64, prevHash [32]byte) FrameHeader { + var magic [4]byte + copy(magic[:], []byte("QCCF")) + return FrameHeader{ + Magic: magic, + Version: 1, + FrameID: prevFrameID + 1, + PrevFrameID: prevFrameID, + PrevHash: prevHash, + Timestamp: time.Now().UnixNano(), + } +} + +func MarshalFrame(f *Frame) ([]byte, error) { + size := FrameHeaderSize + for _, rec := range f.Records { + size += RecordHeaderSize + len(rec.Payload) + } + padding := (8 - (size % 8)) % 8 + size += padding + 32 + + buf := make([]byte, size) + off := 0 + + copy(buf[off:], f.Header.Magic[:]) + off += 4 + binary.BigEndian.PutUint16(buf[off:], f.Header.Version) + off += 2 + binary.BigEndian.PutUint64(buf[off:], f.Header.FrameID) + off += 8 + binary.BigEndian.PutUint64(buf[off:], f.Header.PrevFrameID) + off += 8 + copy(buf[off:], f.Header.PrevHash[:]) + off += 32 + binary.BigEndian.PutUint64(buf[off:], uint64(f.Header.Timestamp)) + off += 8 + binary.BigEndian.PutUint32(buf[off:], uint32(len(f.Records))) + off += 4 + + for _, rec := range f.Records { + buf[off] = byte(rec.Header.RecType) + off++ + buf[off] = rec.Header.Flags + off++ + binary.BigEndian.PutUint16(buf[off:], rec.Header.TableID) + off += 2 + binary.BigEndian.PutUint64(buf[off:], rec.Header.KeyHash) + off += 8 + binary.BigEndian.PutUint32(buf[off:], rec.Header.PayloadSize) + off += 4 + copy(buf[off:], rec.Header.Nonce[:]) + off += 12 + copy(buf[off:], rec.Header.AuthTag[:]) + off += 16 + copy(buf[off:], rec.Payload) + off += len(rec.Payload) + } + + for i := 0; i < padding; i++ { + buf[off] = 0 + off++ + } + + hash := sha256.Sum256(buf[:off]) + copy(buf[off:], hash[:]) + off += 32 + + return buf[:off], nil +} + +func UnmarshalFrame(data []byte) (*Frame, error) { + if len(data) < FrameHeaderSize+32 { + return nil, fmt.Errorf("frame too short: %d", len(data)) + } + dataLen := len(data) + checksumStart := dataLen - 32 + storedChecksum := data[checksumStart:] + + computed := sha256.Sum256(data[:checksumStart]) + if !constantTimeEqual(storedChecksum, computed[:]) { + return nil, fmt.Errorf("frame checksum mismatch") + } + + f := &Frame{} + off := 0 + + copy(f.Header.Magic[:], data[off:off+4]) + off += 4 + if string(f.Header.Magic[:]) != "QCCF" { + return nil, fmt.Errorf("invalid frame magic: %s", hex.EncodeToString(f.Header.Magic[:])) + } + f.Header.Version = binary.BigEndian.Uint16(data[off:]) + off += 2 + f.Header.FrameID = binary.BigEndian.Uint64(data[off:]) + off += 8 + f.Header.PrevFrameID = binary.BigEndian.Uint64(data[off:]) + off += 8 + copy(f.Header.PrevHash[:], data[off:off+32]) + off += 32 + f.Header.Timestamp = int64(binary.BigEndian.Uint64(data[off:])) + off += 8 + f.Header.NumRecords = binary.BigEndian.Uint32(data[off:]) + off += 4 + + for i := uint32(0); i < f.Header.NumRecords; i++ { + if off+RecordHeaderSize > checksumStart { + return nil, fmt.Errorf("record header %d exceeds frame bounds", i) + } + var rec Record + rec.Header.RecType = types.RecType(data[off]) + off++ + rec.Header.Flags = data[off] + off++ + rec.Header.TableID = binary.BigEndian.Uint16(data[off:]) + off += 2 + rec.Header.KeyHash = binary.BigEndian.Uint64(data[off:]) + off += 8 + rec.Header.PayloadSize = binary.BigEndian.Uint32(data[off:]) + off += 4 + copy(rec.Header.Nonce[:], data[off:off+12]) + off += 12 + copy(rec.Header.AuthTag[:], data[off:off+16]) + off += 16 + + if off+int(rec.Header.PayloadSize) > checksumStart { + return nil, fmt.Errorf("record payload %d exceeds frame bounds", i) + } + rec.Payload = make([]byte, rec.Header.PayloadSize) + copy(rec.Payload, data[off:off+int(rec.Header.PayloadSize)]) + off += int(rec.Header.PayloadSize) + f.Records = append(f.Records, rec) + } + + return f, nil +} + +func ComputeFrameChecksum(frameData []byte) [32]byte { + return sha256.Sum256(frameData) +} + +func constantTimeEqual(a, b []byte) bool { + if len(a) != len(b) { + return false + } + var v byte + for i := 0; i < len(a); i++ { + v |= a[i] ^ b[i] + } + return v == 0 +} diff --git a/internal/store/index.go b/internal/store/index.go new file mode 100644 index 0000000..bd8b386 --- /dev/null +++ b/internal/store/index.go @@ -0,0 +1,127 @@ +package store + +import ( + "encoding/binary" + "fmt" + "os" + "path/filepath" + + "github.com/niko/qcc/pkg/types" +) + +type IndexFrame struct { + Entries map[uint64]IndexEntry + FrameID uint64 +} + +func BuildIndex(table *Table) (*IndexFrame, error) { + table.mu.RLock() + defer table.mu.RUnlock() + + idx := &IndexFrame{ + Entries: make(map[uint64]IndexEntry), + FrameID: table.nextID - 1, + } + + for keyHash, entry := range table.index { + idx.Entries[keyHash] = entry + } + + return idx, nil +} + +func (idx *IndexFrame) MarshalBinary() ([]byte, error) { + size := 8 + 8 + len(idx.Entries)*24 + buf := make([]byte, size) + off := 0 + binary.BigEndian.PutUint64(buf[off:], idx.FrameID) + off += 8 + binary.BigEndian.PutUint64(buf[off:], uint64(len(idx.Entries))) + off += 8 + + for keyHash, entry := range idx.Entries { + binary.BigEndian.PutUint64(buf[off:], keyHash) + off += 8 + binary.BigEndian.PutUint64(buf[off:], entry.FrameID) + off += 8 + binary.BigEndian.PutUint64(buf[off:], uint64(entry.Offset)) + off += 8 + } + + return buf, nil +} + +func UnmarshalIndexFrame(data []byte) (*IndexFrame, error) { + if len(data) < 16 { + return nil, nil + } + idx := &IndexFrame{ + Entries: make(map[uint64]IndexEntry), + } + off := 0 + idx.FrameID = binary.BigEndian.Uint64(data[off:]) + off += 8 + count := binary.BigEndian.Uint64(data[off:]) + off += 8 + + for i := uint64(0); i < count; i++ { + if off+24 > len(data) { + break + } + keyHash := binary.BigEndian.Uint64(data[off:]) + off += 8 + frameID := binary.BigEndian.Uint64(data[off:]) + off += 8 + offset := binary.BigEndian.Uint64(data[off:]) + off += 8 + idx.Entries[keyHash] = IndexEntry{FrameID: frameID, Offset: uint32(offset)} + } + + return idx, nil +} + +func AppendIndexToTable(table *Table, idx *IndexFrame) error { + idxData, err := idx.MarshalBinary() + if err != nil { + return err + } + + keyHash := uint64(0xFFFFFFFFFFFFFFFF) + header := RecordHeader{ + RecType: types.RecIndex, + Flags: 0, + TableID: 0, + KeyHash: keyHash, + PayloadSize: uint32(len(idxData)), + } + + frame := &Frame{ + Header: NewFrameHeader(table.nextID-1, table.lastHash), + Records: []Record{ + {Header: header, Payload: idxData}, + }, + } + + data, err := MarshalFrame(frame) + if err != nil { + return err + } + + frameName := pathForIndexFrame(table.name, table.nextID) + framePath := filepath.Join(table.path, frameName) + if err := os.WriteFile(framePath, data, 0600); err != nil { + return err + } + + checksum := ComputeFrameChecksum(data[:len(data)-32]) + table.engine.UpdateFrameState(table.name, table.nextID, checksum) + table.lastHash = checksum + table.nextID++ + table.frameCount++ + + return nil +} + +func pathForIndexFrame(tableName string, frameID uint64) string { + return filepath.Join(tableName, "indexes", fmt.Sprintf("%016x.idx", frameID)) +} diff --git a/internal/store/table.go b/internal/store/table.go new file mode 100644 index 0000000..bbf81cb --- /dev/null +++ b/internal/store/table.go @@ -0,0 +1,176 @@ +package store + +import ( + "encoding/binary" + "fmt" + "os" + "path/filepath" + "sync" + + "github.com/niko/qcc/internal/crypto" + "github.com/niko/qcc/pkg/types" +) + +type IndexEntry struct { + FrameID uint64 + Offset uint32 +} + +type Table struct { + mu sync.RWMutex + engine *Engine + name string + path string + lastHash [32]byte + nextID uint64 + frameCount int + index map[uint64]IndexEntry +} + +func (t *Table) Insert(keyHash uint64, recType types.RecType, tableID uint16, payload []byte, key [32]byte) error { + t.mu.Lock() + defer t.mu.Unlock() + + nonce, ciphertext, authTag, err := crypto.EncryptRecord(payload, key) + if err != nil { + return err + } + + var authTagArr [16]byte + copy(authTagArr[:], authTag) + header := RecordHeader{ + RecType: recType, + Flags: 0x01, + TableID: tableID, + KeyHash: keyHash, + PayloadSize: uint32(len(ciphertext)), + Nonce: nonce, + AuthTag: authTagArr, + } + + frame := &Frame{ + Header: NewFrameHeader(t.nextID-1, t.lastHash), + Records: []Record{ + {Header: header, Payload: ciphertext}, + }, + } + + data, err := MarshalFrame(frame) + if err != nil { + return err + } + + frameName := fmt.Sprintf("%016x.frm", t.nextID) + framePath := filepath.Join(t.path, frameName) + if err := os.WriteFile(framePath, data, 0600); err != nil { + return err + } + + checksum := ComputeFrameChecksum(data[:len(data)-32]) + t.engine.UpdateFrameState(t.name, t.nextID, checksum) + t.lastHash = checksum + t.index[keyHash] = IndexEntry{FrameID: t.nextID, Offset: 0} + t.nextID++ + t.frameCount++ + + if t.frameCount%10 == 0 { + return t.saveIndex() + } + return nil +} + +func (t *Table) Get(keyHash uint64, key [32]byte) ([]byte, error) { + t.mu.RLock() + entry, ok := t.index[keyHash] + t.mu.RUnlock() + if !ok { + return nil, fmt.Errorf("key not found: %x", keyHash) + } + + return t.readRecord(entry, key) +} + +func (t *Table) readRecord(entry IndexEntry, key [32]byte) ([]byte, error) { + frameName := fmt.Sprintf("%016x.frm", entry.FrameID) + framePath := filepath.Join(t.path, frameName) + data, err := os.ReadFile(framePath) + if err != nil { + return nil, err + } + + f, err := UnmarshalFrame(data) + if err != nil { + return nil, err + } + + if len(f.Records) == 0 { + return nil, fmt.Errorf("empty frame") + } + + rec := f.Records[0] + return crypto.DecryptRecord(rec.Payload, rec.Header.Nonce, rec.Header.AuthTag[:], key) +} + +func (t *Table) Has(keyHash uint64) bool { + t.mu.RLock() + defer t.mu.RUnlock() + _, ok := t.index[keyHash] + return ok +} + +func (t *Table) Delete(keyHash uint64) { + t.mu.Lock() + defer t.mu.Unlock() + delete(t.index, keyHash) +} + +func (t *Table) loadLastIndex() error { + indexPath := filepath.Join(t.engine.DataDir, "indexes", t.name+".idx") + data, err := os.ReadFile(indexPath) + if err != nil { + return nil + } + t.index = make(map[uint64]IndexEntry) + off := 0 + for off+16 <= len(data) { + keyHash := binary.BigEndian.Uint64(data[off:]) + frameID := binary.BigEndian.Uint64(data[off+8:]) + t.index[keyHash] = IndexEntry{FrameID: frameID, Offset: 0} + off += 16 + } + return nil +} + +func (t *Table) saveIndex() error { + indexPath := filepath.Join(t.engine.DataDir, "indexes", t.name+".idx") + data := make([]byte, 0, len(t.index)*16) + for keyHash, entry := range t.index { + var buf [16]byte + binary.BigEndian.PutUint64(buf[0:8], keyHash) + binary.BigEndian.PutUint64(buf[8:16], entry.FrameID) + data = append(data, buf[:]...) + } + return os.WriteFile(indexPath, data, 0600) +} + +func (t *Table) flushIndex() error { + t.mu.Lock() + defer t.mu.Unlock() + return t.saveIndex() +} + +func (t *Table) Iterate(fn func(keyHash uint64, payload []byte) bool, key [32]byte) error { + t.mu.RLock() + defer t.mu.RUnlock() + + for keyHash, entry := range t.index { + payload, err := t.readRecord(entry, key) + if err != nil { + continue + } + if !fn(keyHash, payload) { + break + } + } + return nil +} diff --git a/internal/transport/server.go b/internal/transport/server.go new file mode 100644 index 0000000..4d8b44e --- /dev/null +++ b/internal/transport/server.go @@ -0,0 +1,141 @@ +package transport + +import ( + "context" + "crypto/tls" + "fmt" + "net" + + "github.com/apernet/quic-go" + "github.com/caddyserver/certmagic" + "go.uber.org/zap" +) + +type Server struct { + listener *quic.Listener + tr *quic.Transport + logger *zap.Logger + handler ServerHandler +} + +type ServerHandler interface { + OnConnect(ctx context.Context, conn *quic.Conn) +} + +func NewServer(listenAddr string, tlsConfig *tls.Config, logger *zap.Logger, handler ServerHandler) (*Server, error) { + addr, err := net.ResolveUDPAddr("udp", listenAddr) + if err != nil { + return nil, fmt.Errorf("resolve addr: %w", err) + } + + conn, err := net.ListenUDP("udp", addr) + if err != nil { + return nil, fmt.Errorf("listen udp: %w", err) + } + + tr := &quic.Transport{Conn: conn} + + listener, err := tr.Listen(tlsConfig, &quic.Config{ + Allow0RTT: true, + KeepAlivePeriod: 15, + EnableDatagrams: true, + MaxIncomingStreams: 1000, + MaxIncomingUniStreams: -1, + }) + if err != nil { + conn.Close() + return nil, fmt.Errorf("quic listen: %w", err) + } + + return &Server{ + listener: listener, + tr: tr, + logger: logger, + handler: handler, + }, nil +} + +func NewTLSConfig(domains []string, email, caType string, dnsSolver *certmagic.DNS01Solver, logger *zap.Logger) (*tls.Config, error) { + dataDir := "data/acme" + cmCfg := &certmagic.Config{ + RenewalWindowRatio: certmagic.DefaultRenewalWindowRatio, + KeySource: certmagic.DefaultKeyGenerator, + Storage: &certmagic.FileStorage{Path: dataDir}, + Logger: logger, + } + + cmIssuer := certmagic.NewACMEIssuer(cmCfg, certmagic.ACMEIssuer{ + Email: email, + Agreed: true, + Logger: logger, + }) + + switch caType { + case "letsencrypt", "le", "": + cmIssuer.CA = certmagic.LetsEncryptProductionCA + case "zerossl", "zero": + cmIssuer.CA = certmagic.ZeroSSLProductionCA + default: + return nil, fmt.Errorf("unsupported CA: %s", caType) + } + + if dnsSolver != nil { + cmIssuer.DisableHTTPChallenge = true + cmIssuer.DisableTLSALPNChallenge = true + cmIssuer.DNS01Solver = dnsSolver + } else { + cmIssuer.DisableHTTPChallenge = false + cmIssuer.DisableTLSALPNChallenge = true + } + + cmCfg.Issuers = []certmagic.Issuer{cmIssuer} + cmCache := certmagic.NewCache(certmagic.CacheOptions{ + GetConfigForCert: func(cert certmagic.Certificate) (*certmagic.Config, error) { + return cmCfg, nil + }, + Logger: logger, + }) + cmCfg = certmagic.New(cmCache, *cmCfg) + + if len(domains) == 0 { + return nil, fmt.Errorf("no domains configured for ACME") + } + + if err := cmCfg.ManageSync(context.Background(), domains); err != nil { + return nil, fmt.Errorf("acme manage sync: %w", err) + } + + return &tls.Config{ + MinVersion: tls.VersionTLS13, + GetCertificate: cmCfg.GetCertificate, + }, nil +} + +func (s *Server) Start(ctx context.Context) error { + s.logger.Info("QUIC server listening", + zap.String("addr", s.listener.Addr().String())) + + for { + conn, err := s.listener.Accept(ctx) + if err != nil { + select { + case <-ctx.Done(): + return nil + default: + s.logger.Error("Accept error", zap.Error(err)) + continue + } + } + + go s.handler.OnConnect(ctx, conn) + } +} + +func (s *Server) Close() error { + err1 := s.listener.Close() + err2 := s.tr.Close() + if err1 != nil { + return err1 + } + return err2 +} diff --git a/internal/transport/stream.go b/internal/transport/stream.go new file mode 100644 index 0000000..7bef9a9 --- /dev/null +++ b/internal/transport/stream.go @@ -0,0 +1,27 @@ +package transport + +import ( + "sync" + + "github.com/apernet/quic-go" + "github.com/niko/qcc/internal/protocol" +) + +type Stream struct { + q *quic.Stream + mu sync.Mutex +} + +func NewStream(s *quic.Stream) *Stream { + return &Stream{q: s} +} + +func (s *Stream) ReadFrame() (*protocol.Frame, error) { + return protocol.ReadFrame(s.q) +} + +func (s *Stream) WriteFrame(opCode byte, payload []byte) error { + s.mu.Lock() + defer s.mu.Unlock() + return protocol.WriteFrame(s.q, opCode, payload) +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..ebb13db --- /dev/null +++ b/main.go @@ -0,0 +1,7 @@ +package main + +import "github.com/niko/qcc/cmd/qccd" + +func main() { + qccd.Execute() +} diff --git a/pkg/types/opcode.go b/pkg/types/opcode.go new file mode 100644 index 0000000..2cbf14a --- /dev/null +++ b/pkg/types/opcode.go @@ -0,0 +1,21 @@ +package types + +type OpCode byte + +const ( + OpGetChallenge OpCode = 0x01 + OpChallenge OpCode = 0x02 + OpSolve OpCode = 0x03 + OpIdentity OpCode = 0x04 + OpReroll OpCode = 0x05 + + OpDial OpCode = 0x10 + OpRing OpCode = 0x11 + OpAccept OpCode = 0x12 + OpPeerAccept OpCode = 0x13 + OpReject OpCode = 0x14 + OpEnd OpCode = 0x15 + OpPeerEnd OpCode = 0x16 + + OpError OpCode = 0xFF +) diff --git a/pkg/types/types.go b/pkg/types/types.go new file mode 100644 index 0000000..b8ddc8f --- /dev/null +++ b/pkg/types/types.go @@ -0,0 +1,54 @@ +package types + +type RecType uint8 + +const ( + RecIdentity RecType = 1 + RecCallLog RecType = 2 + RecIndex RecType = 6 +) + +type ErrorCode uint16 + +const ( + ErrNone ErrorCode = 0x00 + ErrInternal ErrorCode = 0x01 + ErrInvalidRequest ErrorCode = 0x02 + ErrPoWInvalid ErrorCode = 0x03 + ErrPoWExpired ErrorCode = 0x04 + ErrPoWDifficulty ErrorCode = 0x05 + ErrNumberTaken ErrorCode = 0x06 + ErrCooldown ErrorCode = 0x07 + ErrNumberNotFound ErrorCode = 0x08 + ErrCallNotFound ErrorCode = 0x09 + ErrBusy ErrorCode = 0x0A + ErrSelfCall ErrorCode = 0x0B + ErrRateLimited ErrorCode = 0x0C +) + +type CodecType uint8 + +const ( + CodecOpus CodecType = 1 + CodecVP9 CodecType = 2 + CodecH264 CodecType = 3 + CodecH265 CodecType = 4 +) + +type MediaFlags uint8 + +const ( + MediaKeyframe MediaFlags = 1 << 0 + MediaSilence MediaFlags = 1 << 1 +) + +const ( + MaxFrameSize = 67_108_864 + MaxRecordsPerFrame = 1_000_000 +) + +const ( + StreamAuth byte = 0x01 + StreamCall byte = 0x02 + DgramMedia byte = 0x10 +) diff --git a/plan.txt b/plan.txt new file mode 100644 index 0000000..e680ddc --- /dev/null +++ b/plan.txt @@ -0,0 +1,414 @@ +================================================================================ + QUIC CALL — ПЛАН ПРОЕКТА +================================================================================ + +1. ОБЩАЯ КОНЦЕПЦИЯ +-------------------------------------------------------------------------------- +QuiC Call (QCC) — сервер для аудио/видеозвонков между людьми. +Замена телефонной связи для семьи и друзей. + +Ключевые принципы: +- Всё через сервер, без STUN/TURN/P2P +- E2EE (сервер не расшифровывает медиа) +- X.509 PKI: каждый номер +0 — это X.509 сертификат, подписанный CA сервера +- PoW (hashcash) вместо паролей +- QUIC транспорт (hysteria quic-go fork) +- Custom append-only DB engine (как в NikoGram) +- Opus + VP9, fallback H.264/H.265 + + +2. СТРУКТУРА ДИРЕКТОРИЙ +-------------------------------------------------------------------------------- +qcc/ +├── main.go # Точка входа +├── config.yaml.example # Пример конфига (в git) +├── plan.txt # Этот файл +├── .gitignore # config.yaml, data/, acme/ +├── go.mod / go.sum +├── cmd/ +│ └── qccd/ +│ └── main.go # Cobra command: qccd server [flags] +├── internal/ +│ ├── config/ +│ │ └── config.go # Viper + YAML: структура Config, load +│ ├── transport/ +│ │ ├── server.go # QUIC listener (hysteria quic-go) +│ │ └── stream.go # Thread-safe ReadFrame/WriteFrame +│ ├── protocol/ +│ │ └── frame.go # Varint binary frame encoder/decoder +│ ├── store/ # Custom DB engine (из NikoGram) +│ │ ├── engine.go # Engine: управление таблицами +│ │ ├── frame.go # Frame: формат, marshal/checksum +│ │ ├── table.go # Table: CRUD, hash index +│ │ ├── index.go # IndexFrame: персистентный индекс +│ │ └── encrypt.go # AES-256-GCM записей at-rest +│ ├── crypto/ +│ │ ├── atrest.go # EncryptRecord/DecryptRecord +│ │ ├── pow.go # Hashcash PoW +│ │ └── e2ee.go # X25519 DH + ChaCha20-Poly1305 +│ ├── server/ # Серверная логика +│ │ ├── server.go # Server struct, lifecycle (start/stop) +│ │ ├── session.go # Session: per-connection state +│ │ └── rate_limit.go # Token-bucket rate limiter +│ ├── auth/ +│ │ └── pow.go # PoW challenge/verify handler +│ ├── ca/ +│ │ └── ca.go # X.509 CA: gen root, issue cert, verify +│ ├── identity/ +│ │ ├── number.go # +0 XXX YYY ZZZ generator/parser +│ │ └── manager.go # IdentityManager: alloc, cooldown, DB +│ └── call/ +│ ├── relay.go # Datagram relay (слепая пересылка) +│ └── signaling.go # Ring/Accept/Reject/End handlers +├── pkg/ +│ └── types/ +│ ├── opcode.go # OpCode, ErrorCode константы +│ └── types.go # RecType, CodecType, MediaFlags, limits +└── data/ # Runtime data (gitignored) + + +3. ПОТОК ДАННЫХ (ПОЛНЫЙ ЦИКЛ) +================================================================================ + +3.1. Подключение и получение номера +-------------------------------------------------------------------------------- +Client Server + | | + |--- TCP :1963 (DPL) ------------------>| Сначала TCP, получает + |<-- HTTP 200 + Alt-Svc: h3=":1963" ----| указание переходить на h3 + | | + |--- QUIC connect (TLS + ACME) -------->| hysteria quic-go + certmagic + |--- Stream 0x01 open ------------------>| + | | + |=== ФАЗА AUTH (PoW) ====================| + | | + |--- OpGetChallenge -------------------->| + |<-- OpChallenge ------------------------| {bits, serverNonce, timestamp} + | bits=22, nonce=8b, ts=unix | + | | + |--- OpSolve --------------------------->| {nonce[8], clientPubKey[32]} + | ищет nonce где SHA256(challenge | + | || clientNonce) имеет 22 ведущих 0 | + | | + | | OpSolve → verifyPoW() + | | → identityManager.Alloc() + | | → ca.IssueCert(pubKey, number) + | | → store.identities.Insert() + | | + |<-- OpIdentity -------------------------| {number, certDER, caCertDER} + | "+0 123 456 7890" | + | X.509 cert для номера | + | CA root cert | + | | + | Клиент сохраняет: | + | - свой privkey (Ed25519) | + | - свой cert (X.509, CN=number) | + | - CA root cert | + + +3.2. Звонок (Signaling) +-------------------------------------------------------------------------------- +Alice Server Bob + | | | + |========== У Alice есть номер =====| ========= У Bob есть номер =| + | "+0 123 456 7890" | "+0 987 654 3210" | + | | | + |--- OpDial {target: "+0 987..."} ->| | + | | | + | |--- OpRing {from, cert} ---->| + | | Alice's cert + caller | + | |<-- OpAccept {e2eePubKey} ----| + | | | + |<-- OpPeerAccept {e2eePubKey} -----| | + | | | + | DH: X25519(AlicePriv, BobPub) | DH: X25519(BobPriv, AlicePub) + | → shared_secret → HKDF → key | → shared_secret → HKDF → key + | | | + |=========== MEDIA STREAM ==========|==============================| + | | | + | Opus/VP9 → E2EE → Datagram 0x10 | Datagram 0x10 → E2EE → play | + | | | + + +3.3. Media Relay (сервер — слепой прокси) +-------------------------------------------------------------------------------- +Alice → Datagram {CallID[8], Seq[4], Codec[1], Flags[1], TS[4], Payload[...]} + ↓ + Server: lookup CallID → target session + ↓ +Bob ← Datagram {те же байты, без изменений} + ↓ + Bob: E2EE decrypt → decode → play + +Codec types: 1=Opus, 2=VP9, 3=H.264, 4=H.265 +Flags: 0x01=Keyframe, 0x02=Silence + +Никакой обработки медиа на сервере. Только forward. + + +3.4. Перезапрос номера (cooldown) +-------------------------------------------------------------------------------- +Client Server + |--- OpReroll ------------------------>| + |<-- OpIdentity {newNumber, cert, ca} -| если прошло >= 24h + | ИЛИ | + |<-- OpError {ErrCooldown, retryAfter}-| если < 24h + + +3.5. Сервер офлайн — верификация +-------------------------------------------------------------------------------- +Alice и Bob встречаются (или звонок в mesh-режиме): + 1. Alice: "мой номер +0 123 456 7890, вот мой сертификат" + 2. Bob: verify(cert, CAroot) → OK, номер принадлежит Alice + 3. Bob: "мой номер +0 987 654 3210, вот мой сертификат" + 4. Alice: verify(cert, CAroot) → OK + 5. Alice: sign("alice-bob-session-001") с своим privkey + 6. Bob: verify(sig, Alice.pubkey) → OK → Alice жива и владеет номером + + +4. ФОРМАТЫ ПРОТОКОЛА +================================================================================ + +4.1. Varint Frame (stream 0x01 — auth/signaling) +-------------------------------------------------------------------------------- +Поверх QUIC stream: + +[OpCode(1)] [PayloadLength(varint)] [Payload...] + +PayloadLength — varint до 2^32. +OpCode — см. OpCode enum. + + +4.2. Media Datagram (datagram 0x10) +-------------------------------------------------------------------------------- +[CallID(8 BE)] [SeqNum(4 LE)] [Codec(1)] [Flags(1)] [Timestamp(4 BE)] [Payload...] + +CallID — уникальный ID звонка (назначается сервером при OpDial) +SeqNum — порядковый номер пакета (для jitter buffer) +Codec — 1=Opus, 2=VP9, 3=H.264, 4=H.265 +Flags — 0x01=Keyframe, 0x02=Silence +Timestamp — 4 байта, мс от начала звонка, для playout синхронизации +Payload — E2EE зашифрованный Opus/VP9/H264/H265 фрейм + + +5. OPCODES +================================================================================ +Stream 0x01 — Auth: + OpGetChallenge = 0x01 + OpChallenge = 0x02 {bits, serverNonce[8], timestamp[8]} + OpSolve = 0x03 {clientNonce[8], clientPubKey[32]} + OpIdentity = 0x04 {number[string], certDER, caCertDER} + OpReroll = 0x05 + OpError = 0xFF {code[2], msg[string]} + +Stream 0x02 — Call signaling: + OpDial = 0x10 {targetNumber[string]} + OpRing = 0x11 {callerNumber[string], callerCertDER} + OpAccept = 0x12 {e2eePubKey[32]} + OpPeerAccept = 0x13 {e2eePubKey[32]} + OpReject = 0x14 + OpEnd = 0x15 + OpPeerEnd = 0x16 + OpError = 0xFF {code[2], msg[string]} + +Datagram 0x10 — Media: + (binary as in §4.2) + + +6. ERROR CODES +================================================================================ +ErrNone = 0x00 +ErrInternal = 0x01 +ErrInvalidRequest = 0x02 +ErrPoWInvalid = 0x03 +ErrPoWExpired = 0x04 +ErrPoWDifficulty = 0x05 +ErrNumberTaken = 0x06 +ErrCooldown = 0x07 {retryAfter[int64]} +ErrNumberNotFound = 0x08 +ErrCallNotFound = 0x09 +ErrBusy = 0x0A +ErrSelfCall = 0x0B +ErrRateLimited = 0x0C + + +7. DB ENGINE (Из NikoGram, адаптированный) +================================================================================ + +7.1. Формат DB файла +-------------------------------------------------------------------------------- +data/qcc.db + [4] magic "QCC\0" + [2] version big-endian uint16 + [8] padding + +7.2. Frame формат +-------------------------------------------------------------------------------- +Файлы: data//.frm + +FrameHeader: + [4] Magic "QCCF" + [2] Version 1 + [8] FrameID BE + [8] PrevFrameID BE + [32] PrevHash SHA-256 + [8] Timestamp unix nano + [4] NumRecords BE + +RecordHeader: + [1] RecType 1=Identity, 2=CallLog, 6=Index + [1] Flags 0x01=encrypted + [2] TableID BE + [8] KeyHash BE (Blake2b-64) + [4] PayloadSize BE + [12] Nonce AES-GCM nonce + [16] AuthTag AES-GCM auth tag + +Padding: zero до 8-byte alignment +Checksum: SHA-256 (всё выше) — последние 32 байта + +7.3. Таблицы +-------------------------------------------------------------------------------- +identities/ — keyHash = Blake2b-64(number) → payload = serialized Identity + {number, pubKey, certDER, createdAt, lastReroll} + +calls/ — keyHash = CallID (uint64) → payload = CallLog + {caller, callee, startedAt, endedAt, codec, status} + +7.4. Индексы +-------------------------------------------------------------------------------- +data/indexes/
.idx — hashmap: keyHash → frameID + + +8. X.509 CA +================================================================================ + +8.1. CA root (генерируется при первом запуске) +-------------------------------------------------------------------------------- +Key: Ed25519 или ECDSA P-384 +Cert: Self-signed + Subject/Issuer: "QuiC Call Root CA — " + NotBefore: now + NotAfter: now + 10 years + KeyUsage: cert sign, CRL sign + BasicConstraints: CA:TRUE + Stored: data/ca.key (AES-256-GCM encrypted), data/ca.crt (PEM) + +8.2. Client certificate +-------------------------------------------------------------------------------- +Key: client генерирует Ed25519 keypair +Issuer: CA key +Subject: + CN = "+0 XXX YYY ZZZ" + O = "QuiC Call" +NotAfter: now + 10 years (перевыпуск при reroll) +KeyUsage: digitalSignature +ExtKeyUsage: clientAuth +SAN: phone number URI (tel:+0XXXXXXXXXX) + +8.3. Верификация (offline) +-------------------------------------------------------------------------------- +Bob: verify Alice's cert: + 1. verify sig(CA, cert) with CA pubkey + 2. check CN == "+0 ..." + 3. verify sig(Alice, nonce/challenge) with cert's pubkey + → Alice owns +0 XXX + + +9. CONFIG (config.yaml.example) +================================================================================ +server: + listen: ":1963" + +acme: + domains: ["n1kt.ru"] + email: "admin@n1kt.ru" + ca: "letsencrypt" + type: "dns" + dns: + name: "cloudflare" + config: + cloudflare_api_token: "" # или env: CF_API_TOKEN + +pow: + difficulty: 22 # ведущих нулей (bits) + challenge_ttl: 30s + +identity: + cooldown: 24h + prefix: "+0" + +ca: + key_type: "ed25519" + validity: "87600h" # 10 лет + +relay: + bandwidth_up: "100 mbps" + bandwidth_down: "100 mbps" + media_timeout: 30s + +data_dir: "./data" +log_level: "info" + + +10. ЗАВИСИМОСТИ (go.mod) +================================================================================ +module github.com/niko/qcc +go 1.26 + +require ( + github.com/apernet/quic-go hysteria fork + github.com/caddyserver/certmagic ACME + github.com/libdns/cloudflare DNS-01 + github.com/spf13/cobra CLI + github.com/spf13/viper config + go.uber.org/zap logging + golang.org/x/crypto hashcash, ed25519, x25519 + gopkg.in/yaml.v3 YAML +) + + +11. ОЧЕРЕДНОСТЬ РЕАЛИЗАЦИИ +================================================================================ +[1] Scaffold: go.mod, main.go, cmd/qccd/main.go, config.yaml.example, + .gitignore, базовая структура директорий + +[2] Store: копировать из NikoGram internal/db → internal/store, + адаптировать (магия QCC, RecType, таблицы identities/calls, + импорты) + +[3] Transport: QUIC server listener на hysteria quic-go, + ACME TLS через certmagic + Cloudflare, + stream.ReadFrame/WriteFrame + +[4] Types: opcode.go, types.go — все константы + +[5] Protocol: varint frame encoder/decoder, media datagram + encoder/decoder + +[6] Auth/PoW: crypto/pow.go (hashcash), + auth/pow.go (challenge/verify handler) + +[7] CA: ca.go — genCA, issueCert, verifyCert, load/store + +[8] Identity: number.go (генерация/парсинг +0), + manager.go (Alloc, Reroll, Get, cooldown) + +[9] Server core: server.go (start/stop/accept loop), + session.go (state machine), rate_limit.go + +[10] Call signaling: signaling.go — dial/ring/accept/reject/end + handlers, server-side state + +[11] Media relay: relay.go — datagram forward, Brutal CC + integration, media timeout + +[12] E2EE: crypto/e2ee.go — X25519 DH + HKDF + ChaCha20-Poly1305, + интеграция в signaling + +[13] Integration: full loop test — connect → PoW → number → + dial → ring → accept → media relay → end + +================================================================================ +КОНЕЦ ПЛАНА +================================================================================ diff --git a/qcc b/qcc new file mode 100755 index 0000000..2d6e104 Binary files /dev/null and b/qcc differ