From 99d76b798c3efaa10313218cf9fd5934c4a5f0e2 Mon Sep 17 00:00:00 2001 From: Fedor Indutny Date: Sat, 13 Mar 2021 17:54:01 -0800 Subject: [PATCH] Public release --- .eslintignore | 9 + .eslintrc.yml | 76 ++ .gitignore | 16 + LICENSE | 661 +++++++++++++++++ README.md | 27 + certs/Makefile | 33 + certs/README.md | 11 + certs/ca-cert.pem | 33 + certs/ca-cert.srl | 1 + certs/ca-key.pem | 54 ++ certs/ca.cnf | 22 + certs/cert.pem | 33 + certs/csr.pem | 28 + certs/full-cert.pem | 66 ++ certs/generate-trust-root.js | 11 + certs/generate-zk-params.js | 12 + certs/key.pem | 51 ++ certs/main.cnf | 27 + certs/trust-root.json | 4 + certs/zk-params.json | 4 + package.json | 70 ++ protos/ContactDiscovery.proto | 40 ++ protos/CrashReports.proto | 15 + protos/DeviceMessages.proto | 33 + protos/DeviceName.proto | 10 + protos/Groups.proto | 235 ++++++ protos/LibSignal-Client.proto | 107 +++ protos/README.md | 6 + protos/SignalService.proto | 562 +++++++++++++++ protos/SignalStorage.proto | 144 ++++ protos/Stickers.proto | 16 + protos/SubProtocol.proto | 34 + protos/UnidentifiedDelivery.proto | 69 ++ src/api/group.ts | 101 +++ src/api/primary-device.ts | 1098 +++++++++++++++++++++++++++++ src/api/server.ts | 487 +++++++++++++ src/api/storage-state.ts | 459 ++++++++++++ src/constants.ts | 14 + src/crypto.ts | 356 ++++++++++ src/data/attachment.ts | 24 + src/data/certificates.ts | 62 ++ src/data/contacts.ts | 45 ++ src/data/device.ts | 117 +++ src/data/group.ts | 34 + src/data/json.d.ts | 30 + src/index.ts | 21 + src/server/base.ts | 841 ++++++++++++++++++++++ src/server/group.ts | 71 ++ src/server/http.ts | 451 ++++++++++++ src/server/ws/connection.ts | 464 ++++++++++++ src/server/ws/index.ts | 4 + src/server/ws/router.ts | 106 +++ src/server/ws/service.ts | 142 ++++ src/types.ts | 8 + src/util.ts | 249 +++++++ test/crypto-test.ts | 39 + test/primary-device-test.ts | 98 +++ test/util-test.ts | 97 +++ tsconfig.json | 26 + 59 files changed, 7964 insertions(+) create mode 100644 .eslintignore create mode 100644 .eslintrc.yml create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 certs/Makefile create mode 100644 certs/README.md create mode 100644 certs/ca-cert.pem create mode 100644 certs/ca-cert.srl create mode 100644 certs/ca-key.pem create mode 100644 certs/ca.cnf create mode 100644 certs/cert.pem create mode 100644 certs/csr.pem create mode 100644 certs/full-cert.pem create mode 100644 certs/generate-trust-root.js create mode 100644 certs/generate-zk-params.js create mode 100644 certs/key.pem create mode 100644 certs/main.cnf create mode 100644 certs/trust-root.json create mode 100644 certs/zk-params.json create mode 100644 package.json create mode 100644 protos/ContactDiscovery.proto create mode 100644 protos/CrashReports.proto create mode 100644 protos/DeviceMessages.proto create mode 100644 protos/DeviceName.proto create mode 100644 protos/Groups.proto create mode 100644 protos/LibSignal-Client.proto create mode 100644 protos/README.md create mode 100644 protos/SignalService.proto create mode 100644 protos/SignalStorage.proto create mode 100644 protos/Stickers.proto create mode 100644 protos/SubProtocol.proto create mode 100644 protos/UnidentifiedDelivery.proto create mode 100644 src/api/group.ts create mode 100644 src/api/primary-device.ts create mode 100644 src/api/server.ts create mode 100644 src/api/storage-state.ts create mode 100644 src/constants.ts create mode 100644 src/crypto.ts create mode 100644 src/data/attachment.ts create mode 100644 src/data/certificates.ts create mode 100644 src/data/contacts.ts create mode 100644 src/data/device.ts create mode 100644 src/data/group.ts create mode 100644 src/data/json.d.ts create mode 100644 src/index.ts create mode 100644 src/server/base.ts create mode 100644 src/server/group.ts create mode 100644 src/server/http.ts create mode 100644 src/server/ws/connection.ts create mode 100644 src/server/ws/index.ts create mode 100644 src/server/ws/router.ts create mode 100644 src/server/ws/service.ts create mode 100644 src/types.ts create mode 100644 src/util.ts create mode 100644 test/crypto-test.ts create mode 100644 test/primary-device-test.ts create mode 100644 test/util-test.ts create mode 100644 tsconfig.json diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..9493b0b --- /dev/null +++ b/.eslintignore @@ -0,0 +1,9 @@ +src/**/*.js +src/**/*.d.ts +test/**/*.js +test/**/*.d.ts +bin/**/*.js +bin/**/*.d.ts +scripts/**/*.js +scripts/**/*.d.ts +protos/* diff --git a/.eslintrc.yml b/.eslintrc.yml new file mode 100644 index 0000000..8d98a23 --- /dev/null +++ b/.eslintrc.yml @@ -0,0 +1,76 @@ +env: + browser: true + es2021: true +extends: + - 'eslint:recommended' + - 'plugin:@typescript-eslint/recommended' +parser: '@typescript-eslint/parser' +parserOptions: + ecmaVersion: 12 + sourceType: module +plugins: + - '@typescript-eslint' +rules: + indent: + - error + - 2 + linebreak-style: + - error + - unix + quotes: + - error + - single + semi: + - error + - always + comma-dangle: + - error + - always-multiline + curly: + - error + - all + dot-location: + - error + - property + dot-notation: + - error + eqeqeq: + - error + - always + no-constructor-return: + - error + no-implicit-globals: + - error + space-infix-ops: + - error + no-duplicate-imports: + - error + no-var: + - error + prefer-const: + - error + prefer-template: + - error + sort-imports: + - error + - + ignoreDeclarationSort: true + array-bracket-spacing: + - error + - always + arrow-spacing: + - error + - + before: true + after: true + comma-spacing: + - error + - + before: false + after: true + no-multiple-empty-lines: + - error + no-trailing-spaces: + - error + no-tabs: + - error diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e91c529 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +node_modules/ +npm-debug.log +bin/**/*.js +bin/**/*.d.ts +src/**/*.js +src/**/*.d.ts +!src/data/json.d.ts +test/**/*.js +test/**/*.d.ts +protos/*.js +protos/*.d.ts +scripts/**/*.js +scripts/**/*.d.ts +*.tsbuildinfo +.eslintcache +dist/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..710ccc0 --- /dev/null +++ b/LICENSE @@ -0,0 +1,661 @@ +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, 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 +otherwise be available to you under applicable patent law. + +12. No Surrender of Others' Freedom. + +If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + +13. 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 which 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..64db243 --- /dev/null +++ b/README.md @@ -0,0 +1,27 @@ + + +# Signal Mock Server + +## Overview + +This npm module is a mock implementation of Signal Server, currently only used +in [Signal Desktop integration tests][0]. Public API surface area can be found at +[`src/api`][1]. + +## Installation + +```sh +npm install --dev @signalapp/mock-signal-server + +# Add an entry to the /etc/hosts +echo "127.0.0.1 mock.signal.org" | sudo tee -a /etc/hosts +``` + +## License + +Copyright 2022 Signal, a 501c3 nonprofit + +Licensed under the AGPLv3: https://opensource.org/licenses/agpl-3.0 + +[0]: https://github.com/signalapp/Signal-Desktop/tree/development/ts/test-mock +[1]: https://github.com/signalapp/Mock-Signal-Server/tree/main/src/api diff --git a/certs/Makefile b/certs/Makefile new file mode 100644 index 0000000..56cd1cc --- /dev/null +++ b/certs/Makefile @@ -0,0 +1,33 @@ +all: ca-cert.pem key.pem full-cert.pem trust-root.json zk-params.json + +ca-cert.pem: ca.cnf + openssl req -new -x509 -config ca.cnf -days 36500 \ + -keyout ca-key.pem -out ca-cert.pem + +key.pem: + openssl genrsa -out key.pem 4096 + +csr.pem: main.cnf key.pem + openssl req -new -config main.cnf -key key.pem -out csr.pem + +cert.pem: csr.pem ca-cert.pem ca-key.pem + openssl x509 -req \ + -extfile main.cnf \ + -in csr.pem \ + -days 36500 \ + -passin "pass:password" \ + -CA ca-cert.pem \ + -CAkey ca-key.pem \ + -CAcreateserial \ + -out cert.pem + +full-cert.pem: cert.pem ca-cert.pem + cat cert.pem ca-cert.pem > $@ + +trust-root.json: + node generate-trust-root.js $@ + +zk-params.json: + node generate-zk-params.js $@ + +.PHONY: all diff --git a/certs/README.md b/certs/README.md new file mode 100644 index 0000000..cd4efac --- /dev/null +++ b/certs/README.md @@ -0,0 +1,11 @@ +## Certificates + +This folder contains various certificates required to run the mock server. + +### Rebuilding + +There shouldn't be a reason for rebuilding certificates bcause they have very +long expiration value, however if needed it could be done by: + +- Installing node.js (16 or later), make, and openssl +- Run `make -B` in this folder diff --git a/certs/ca-cert.pem b/certs/ca-cert.pem new file mode 100644 index 0000000..4dd1332 --- /dev/null +++ b/certs/ca-cert.pem @@ -0,0 +1,33 @@ +-----BEGIN CERTIFICATE----- +MIIFtTCCA52gAwIBAgIJAN7vPX8scMU0MA0GCSqGSIb3DQEBCwUAMIGPMQswCQYD +VQQGEwJVUzELMAkGA1UECAwCQ0ExCzAJBgNVBAcMAkxBMRQwEgYDVQQKDAtTaWdu +YWwgTW9jazEUMBIGA1UECwwLU2lnbmFsIE1vY2sxFzAVBgNVBAMMDlNpZ25hbCBN +b2NrIENBMSEwHwYJKoZIhvcNAQkBFhJpbmR1dG55QHNpZ25hbC5vcmcwIBcNMjEw +MzEyMDIxMDAxWhgPMjEyMTAyMTYwMjEwMDFaMIGPMQswCQYDVQQGEwJVUzELMAkG +A1UECAwCQ0ExCzAJBgNVBAcMAkxBMRQwEgYDVQQKDAtTaWduYWwgTW9jazEUMBIG +A1UECwwLU2lnbmFsIE1vY2sxFzAVBgNVBAMMDlNpZ25hbCBNb2NrIENBMSEwHwYJ +KoZIhvcNAQkBFhJpbmR1dG55QHNpZ25hbC5vcmcwggIiMA0GCSqGSIb3DQEBAQUA +A4ICDwAwggIKAoICAQDHj/g7OmmhKkwzIKE6GMLsvl7B9pZbNOLZGKbHM2iQKuiQ +fvKQwJMquy7ef02/ArVK32Zjk+keOHwzzKmd93OslWaqV2T7qVY+XBUOUfpiRZHB +7tTltGCx3kDH8a53gNK/BySsfxddftTs/tYSfye/QzazsmtexXAEKaQ4RwKMD6nx +nxRtwriW3eVUMmOhxR9lgZnn32BhsJ23P9+o7BjkuWphzGufBTEHAiWyj8FrXVDD +4PSg4bXWhCKR/vYkctPryMJn+E9noIJB/ldUJ5wK19DphrYHfpa7L1zZO8l37XUT +FpLFsfCSIzBLuz9Nd3g0KSg0GJoV1eMltb8Ok2yNZ4uu4a4pW0haTcb2f6wZE29a +3agq0w4JOKqRSEe7dapBn9yomJbADhyq0Yreaq1WdC8JfFag6I1z7XQsNVFtPcQj +fyT6jwFxZ5AH5rVcAJlfoFRZcscUVPN2rsY1RlD+ueKH0MtnEgFdtvEyncNh36rk +IhrjjqO+LmibYbVSpXnm4vEafPjWBeAnhxUE8OqA06fQWA/CztYniTo+2sJvD9F2 +ypO4Di5MDpuZ0r7nA8AK4xy9XirJBZFSelbwCK4DongWjo+8WgZOaExb/9IlnL5A +sJb1JpHc/Ri3bQaIo3DPrM4KIbFWpcnDca1caEXB+SPbjg5jfFEgiI7YMZDY/QID +AQABoxAwDjAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEBCwUAA4ICAQBhIzTXDZWf +TZStjZpQ+5sWzBPLlLxvgBhhJCk3dZIxP2F/X3HHGzZEda+5ThgDCV98Dwn7jWtT +vAaGzWulXGAvT7v+AXFidIcYIDt66qQUY+s0vMgK1uuxvcLgVgSyNj+SbuiwjJBN +kBXsya19w2qD5U/QOm1ttRaw6PbrrzlSp3U4oGdIGFvn8FS57p6+hw2/1lV6vv9I +9kC5l75QQfSB54dvW/XoQ753sdQgGWI9sSaP6sC0SMeUtvBmj1hIrgKbzlcmUteB +XnFe4KaUpurS5N66RIsu2VSrW7vzP0nxIxwkxFlZ3mt68lE52Fp3CcrSyJCJxwAx +xlsVyJw3AG84Jn9eDvRj2apwC6kTqLNOwsTteQAc/Pr+MdPTRig7O0ERG7d/eSyz +FwPTvdgkUg4g2xVbWLoju7QNunpBhJPsNWYzc08Dx7grIutC8/ExYlYQCcOGXtpj +UluSq9r2ACI4RPD+bUVvzmJjoyzzpZwSF4pqerBMQKC1FEzuJWaH/gVoqOpIHBYq +uyKAcfd5nFRTb1Xv5g9yoM7d0E3ALe6S5cFeWxDbdVr+IWqXvxMN8KmO1yPQWc/3 +Tog1Zmytc6ApV+aRTgPpXwOe8UpsKEfGTMfC6r/tC8NoOcY/CG3xY8ZkDYAkEK9P +f+YofTwPTFQMWqBjD1ml4UWd2egkdC//OA== +-----END CERTIFICATE----- diff --git a/certs/ca-cert.srl b/certs/ca-cert.srl new file mode 100644 index 0000000..6aa7ac1 --- /dev/null +++ b/certs/ca-cert.srl @@ -0,0 +1 @@ +AB0BE03708DC8AD5 diff --git a/certs/ca-key.pem b/certs/ca-key.pem new file mode 100644 index 0000000..57683b0 --- /dev/null +++ b/certs/ca-key.pem @@ -0,0 +1,54 @@ +-----BEGIN ENCRYPTED PRIVATE KEY----- +MIIJnzBJBgkqhkiG9w0BBQ0wPDAbBgkqhkiG9w0BBQwwDgQIyaum5wfdcSYCAggA +MB0GCWCGSAFlAwQBKgQQ+8Z1kz+8kPxTChtUUt/H8wSCCVDZojwV3fs7IatO0ART +gCaN8If885LPI58wInfQ+FTR31nfCE28i+GaaxpoBbGuuk+7RSuo3BfahyvvZ9HK +nzclwEQsrUhYYndZk+EfQSrAA5BdiVYPi2ZXArp88TgG7O2f7D3UC3D1QWYgsfkV +wAPnokepQhS4PWL4TWrvlyNROcHofFZyalXv695rSjmM+LPrwuIP+aroCdOWdOU6 +MYiuPV8dWOj5L3UistE4+KZPXUZTaduZ9HXVHMzTB7nKs9V96nxHnDWJj2c6fiQw +uB2xtpYeCIEYmkj0tzq9wAYbYF4dBxPhsNk4CdumOWbqVAk/F8J10+qU6xRpdxYH +qDCuFyGtZRc+QrRuYCmuwLpqrbg/Xexb/nDfTwzyejxdtceEuHG7XHoL6h8Xk0Al +NgEH1V3qctJdOp+rk4ao89E+A0CeYr6pV+2s5x4gKB/xhqEsKuehKU3B6rUDnCez +NsugjkaRZCd+zSx5wL4+y5yS2cLiZbfcTa4+IMDE1dpRc3ZurH/kTeD9ZkdB+fRy +OpygqQ+GavYAtnpbazX5j5iGT9j9Z9/IQ9s+pVq5iHyeVlCtZZbNEB/ZrHEPG4/K +VUneidUHAPeOKzg/hLjwj8bNVtXJiHmvhHOoxrUTakAZgyX+zaHKqt4qyWJdygyg +IDtWXVCkt8ZtCpe5ujL6KbxHXG4h/ZY0eaVv9aHZKiZvIhATpByqH+Eqtdcg5V4t +p7Mb2JWq00Bz00xhPtn8KC8WMwKCG5o0Fkv8x5cbwSHCeECWf8xqN94SoZRtOTqR +v+6WK9AQxBktujtgyAIHbmwLCSR/AZU+51bU3FeRnDDZHPcEzsg7SqygEjhox43c ++R6/YhxovxONN5DcSwVqEAhOVtZFiWN31LI+v0GCyZAJQB9ic0tAjYyiB+mWtxAv +msKhqqeEG5D0K+4rxJagGPvqXbgyPHRha6cnaRYvbo5GTDOn406gVJfNGwBzXYo7 +hGyfAR8NkgRpCfFbpDCtWsuQ/c8P8acYCR5bya+butYg6XeXueE4H3r/8RhTSjIO +9uKj+WnrW7uStjyDoW+5LXOYqaYr5FUzZiNoUzl4OWdFZvrd2wcvLYQB5+J9kIdG +Hx9nMjIpI0jOq29i2UX24rRkyct4X7sw+qJSUX5cfhX0yb33tXNLDQK+u2sdJ+LN +7/srOOYYOiTtYQK2gPC+nHP71f1V4devm+rUX0kVAi1qcSXW4D9TOCTOl3Dj3eos +l3iT8eg7p1eb4Iu9+bsGiVTaNBZ0W3hyDSWtZaKl8VGwV8izo56XIBj8qbhwMHst +81gYMZMSmb6QG90kUx6oKyuv47y1j/2ExXosotUyuWoPUy/Hykjh7votouyYJoNi +zpH/vsvCl9Ls4M1kt9QNzXDromDVFjMFhZoLw+sQB9zYzrokeVyflmkglM8WAM3E +CUVkniuiuXmWrDodF4LzxZcuXOfY+/LDbFhPg+iqHDuj5Fj9sI2qPHXQ49ciZGdL +67Qt4lAOie1M3FVP8GaQXGan6CGlZoRPzz0so7L0flgbDaDE0BAxzzUPuIVhlbox +ujNB85Vy7sXxxFPPfKvWynS0sm5dr9nn4XRTCRAl+/ns54QWop0L9wYxqipFBZwo +pRG14U+ONdcTx24S5FGo8cqcDIuCOpGzrKpizd5iR+wa45mB1ItbWl8MTMNUYe6n +WE4goO2EZJ+JZ9fDktn9Lq1PN7pT7pIVHXw8l3Z03Pg6HfylZkPIpAD5VNlTLgbF +4qFm8K7TxwjpIrg1hZ7SDvvt0DOtzHiE3YdDzeVOvsdbtFzungv1QORZT9+W8is5 +Zj/Eb4t0PUw5Uhsrl2BgYVobs82wdGhKqEU3TtndKs0rVUqyHVpgOCP8qaEh99KN +5kg775c7n1tEVnHruYXxpyAOZL9E/sDqQpKK+3HS39R5R3PxXLH5Vpf2FKrvEX2F +I6My6THr6AZSlJ6F3MnyZYdzr4FpL82gnyBQSScLZq8F6/CF5WBu8m9Ls1+R8udm +Bck/OCZzqk60CgXh4hfSvKX6RjR/iZocH4pRumqESOeaHrhG2INuBeNPL7Btd6tx +MJj5YXDB05HVrP/5U3vWYap10Rtxt06fdjrwUS3lx2+P53N70rduIDtblHe8VlD+ +ZnxdI4WCCZDoRBNn5C1VAn/dQ+QHnkc0FXCDrQ3W3K3MgN14utkNDsSu+BnbyQwO +4mrPyihb65Y/QEecl69wCp7nGiDXrdRT9KzytM+oQnz7hlyix9lhJzXzGuQ27vS/ +4IdUdVwHkg1tiM0L1yeOzJY2kaGGmx0hgjuviJnS/dNetUI0+EbnvIctc0ZCXBRi +xVynwtiPqcgunq8lPxK+tZiJSxSXoiWPLr+dMUXKRl7dJ5f3AB7X52H601diycnG +hiSR8RhyOhPtII7tkhRseRCVQLEeyGZNR0sWsVd7I47phyYBD+yQMQaNHd8v7n1c +xOUT9YP5j3j48WBMdEsFTgvkOUamkvm8WGnGT2OkuLV459EXrt+rR/JH3Kg7EUYL +Quz+nlEAzT4pmc/5XYtnKZGsL4JkxpY2xIAddM17Wme2utIKj0DXWvjlQ8GlsOEW ++S+oBYRS3e0Ybq7M6HTZRCWpGY1hRupx96gJmBqrJDBCGJFhEkttL7j1bHuKanyZ +YLWSjWyhymEAe4tRuBHmO+OnFiQw7lgcNYjX33h15RHyUeCPCd9e8NqX+X/40SM3 +QwOYbqGsV/9EtzwOv56BwIHyjn+H+QA3QMNxFXybSRTR/9yYzJkfGcO3IcYwhNmO +Vg8+1oMHO2rL8r6KTT8BdEIPbtYTL8LHI+oRPqkFID1Guse+Fh8yT0wsaAJKsJOQ +KB3D/aJS/o1HA4JmIKU63ev1xKgq0TbCeDB6jrB4wUIvTLlDbs5BKQrCcAbigBoL +Cy3/kl+D8KQqQhWVkN7JNECWvWpogrKMqX1Wfygtc67ZkNIELdEx7NYuLyHTjzc6 +iY550hHGLrz00JvNJnvtwXu8RZ2HsGedsqRHkPFOnRrayS+LAxJ+/yCxcQLTfE1X +KfchFLA6z0+TfbjEdAZRrMv1tLZuLW9FSl8OrcPThzYPpBYDS1khjBwlmFivP0EY +cjqjvEnyfk7O4C0OvNA1AxLNNR1baiNSpZJIAjeSWdE4WfUhDwbFfDaDSe9MMwqv +Eng7sf9fuShxeDTuP5YZI/AcNQ== +-----END ENCRYPTED PRIVATE KEY----- diff --git a/certs/ca.cnf b/certs/ca.cnf new file mode 100644 index 0000000..6e5d096 --- /dev/null +++ b/certs/ca.cnf @@ -0,0 +1,22 @@ +[ req ] +default_bits = 4096 +days = 9999 +distinguished_name = req_distinguished_name +attributes = req_attributes +prompt = no +output_password = password +x509_extensions = v3_ca + +[ req_distinguished_name ] +C = US +ST = CA +L = LA +O = Signal Mock +OU = Signal Mock +CN = Signal Mock CA +emailAddress = indutny@signal.org + +[ req_attributes ] + +[ v3_ca ] +basicConstraints = CA:TRUE diff --git a/certs/cert.pem b/certs/cert.pem new file mode 100644 index 0000000..959332c --- /dev/null +++ b/certs/cert.pem @@ -0,0 +1,33 @@ +-----BEGIN CERTIFICATE----- +MIIFpDCCA4ygAwIBAgIJAKsL4DcI3IrVMA0GCSqGSIb3DQEBBQUAMIGPMQswCQYD +VQQGEwJVUzELMAkGA1UECAwCQ0ExCzAJBgNVBAcMAkxBMRQwEgYDVQQKDAtTaWdu +YWwgTW9jazEUMBIGA1UECwwLU2lnbmFsIE1vY2sxFzAVBgNVBAMMDlNpZ25hbCBN +b2NrIENBMSEwHwYJKoZIhvcNAQkBFhJpbmR1dG55QHNpZ25hbC5vcmcwIBcNMjEw +MzEyMDIxMDAyWhgPMjEyMTAyMTYwMjEwMDJaMIGQMQswCQYDVQQGEwJVUzELMAkG +A1UECAwCQ0ExCzAJBgNVBAcMAkxBMRQwEgYDVQQKDAtTaWduYWwgTW9jazEUMBIG +A1UECwwLU2lnbmFsIE1vY2sxGDAWBgNVBAMMD21vY2suc2lnbmFsLm9yZzEhMB8G +CSqGSIb3DQEJARYSaW5kdXRueUBzaWduYWwub3JnMIICIjANBgkqhkiG9w0BAQEF +AAOCAg8AMIICCgKCAgEAznP0WUY58/FV/oeGAXoU+Oqurj7E5IR5ECgDa2W0r9ij +R40BkaZjUGElQrXIdG5pJwZVPuhGziHtm2RpHzbHydJVoAD/K9ntesUJRwJciVE9 +s82QypaoSNXr1Tv2GcP8eUJA5Z/TAeiD1fn82vserIcg2r0XNWH0V8VfxaH8sGOA +nan8UHgd8guaUMLMHGPwclDKIYgl+VaeXYMSrrGw2hF9+MelbOJMyxHixs9/iMAb +/IH5KX5gs7EMmZWY6BII/iLVIugIdwNl4wWKfPlKA7ZbmawhG8oOWHvD8aMoHd30 +l65hvC6q5wR2llYQpgq9WiITdc50gXwGgxEinCl4dQOaBrCH3kxJaZ6EIXkz2kof +PpR0oc0bX1GGd4fV6mWDbwXxkENIv9XLDKo+z1NjgW8MmBDGNtK5fOzY3RgpC9Jr +jLJqGq6mQ3S4vFzg9fNP0OHm8qbzOk2Y5TZ4rporTFiTL+Tq8WJM7L9E9GpX89Uq +OmPeF+od2Id0Q975j1ot0We+KmBm4IC44nWFB4cAptSpeeQIT3fgYupFd7e8WOi5 +pR8sKwwILxSyJ9h0d8CHO5jlUcbjYgKIfKnrrMHpxNIjxlDP+Nn5vPQACXuo+flD +ipW9CY7G+JJSP+Pd2O/p53zuDLWWMzWFTiMgJZuHPqXQDad2xa0gMJ5JDHAS9akC +AwEAATANBgkqhkiG9w0BAQUFAAOCAgEAAgGDrGqumCo3F29v0dwVXfZQoTzL6ENn +a6Efoy4mDbX3qHwAZnyGlrbNKMGhqCccj+VGYTuVZB9CB0jlE4YK29a7NAoh6k4a +BtE/cqg92grImqc/LKwgo2SLm6qnPMxJaAw/rwjRil4gJINBm+hzD8f3SbfDu4oC +3SG0wUEQHhZu/hP0Apd0ryquIYlBODD9qIjRiMAOLMWTft5pZ/4mArDl4Xoi1rMp +eZUgd7Q7dZdHyAkvFGsXXp6hcqsmQ7AkqyFrBpCzZBGVaWtVgI+2YNZx/MKg1W6d +iq38P391rF2IXto5sVBPGA5pSwEykTAcJHWKsHuTBhmDf5S3VGFShF4OnL9sj0NR +qAFAnUNA2uhTMCSrfYCbmw8gmntpS9imR340+sz3lbrvCxmovRZpCLZeQZODTg5L +nHdac3CYPjlTP6E3YM21X3vt72KmMr3pnXpF/+tl6/uWaSJaw9AQ+qYvGSOwHWoc +0egSBCY839SiAKFTPnKu08MoppKWNGQVjGfJ6HEyrnD7ewmSGMZu95zHe6Brv6HL +mQ1HytBU+uwolI4IMhqGy6nW7FPTulcJm/VHxNZV0wvwrkWaePfExsyQdR74+HDE +tLPt3wq6QTlqbR5Wg3xzcpUvGlfCXnsR3kyFjOWYpLPjuhdaEAbaJThEJ8A0mKz2 +95xSZsZK6Ww= +-----END CERTIFICATE----- diff --git a/certs/csr.pem b/certs/csr.pem new file mode 100644 index 0000000..fc1975f --- /dev/null +++ b/certs/csr.pem @@ -0,0 +1,28 @@ +-----BEGIN CERTIFICATE REQUEST----- +MIIE1jCCAr4CAQAwgZAxCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJDQTELMAkGA1UE +BwwCTEExFDASBgNVBAoMC1NpZ25hbCBNb2NrMRQwEgYDVQQLDAtTaWduYWwgTW9j +azEYMBYGA1UEAwwPbW9jay5zaWduYWwub3JnMSEwHwYJKoZIhvcNAQkBFhJpbmR1 +dG55QHNpZ25hbC5vcmcwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDO +c/RZRjnz8VX+h4YBehT46q6uPsTkhHkQKANrZbSv2KNHjQGRpmNQYSVCtch0bmkn +BlU+6EbOIe2bZGkfNsfJ0lWgAP8r2e16xQlHAlyJUT2zzZDKlqhI1evVO/YZw/x5 +QkDln9MB6IPV+fza+x6shyDavRc1YfRXxV/FofywY4CdqfxQeB3yC5pQwswcY/By +UMohiCX5Vp5dgxKusbDaEX34x6Vs4kzLEeLGz3+IwBv8gfkpfmCzsQyZlZjoEgj+ +ItUi6Ah3A2XjBYp8+UoDtluZrCEbyg5Ye8Pxoygd3fSXrmG8LqrnBHaWVhCmCr1a +IhN1znSBfAaDESKcKXh1A5oGsIfeTElpnoQheTPaSh8+lHShzRtfUYZ3h9XqZYNv +BfGQQ0i/1csMqj7PU2OBbwyYEMY20rl87NjdGCkL0muMsmoarqZDdLi8XOD180/Q +4ebypvM6TZjlNniumitMWJMv5OrxYkzsv0T0alfz1So6Y94X6h3Yh3RD3vmPWi3R +Z74qYGbggLjidYUHhwCm1Kl55AhPd+Bi6kV3t7xY6LmlHywrDAgvFLIn2HR3wIc7 +mOVRxuNiAoh8qeuswenE0iPGUM/42fm89AAJe6j5+UOKlb0Jjsb4klI/493Y7+nn +fO4MtZYzNYVOIyAlm4c+pdANp3bFrSAwnkkMcBL1qQIDAQABoAAwDQYJKoZIhvcN +AQELBQADggIBAHGxskYi12t+uCEVwhNr3pDnwvem7HrZCyAWqMgIkh7F67yZHbDK +LY0IsY4kOR+LytOl//vSnEpDWbbnw0VykqP3i1CNQtWuoZvUsRzZKUjzazLZaker +7HU35syVOr4MgpAJTGMlnuegNI913Cb7in9fB7y0bqS99nVDEt4D8q48l3gFyuz1 +HAsoqncBphMz1aVNjOI+acl2sF7PdHWW7LhgqPk7NC8A7EXo1LRQRME7whr6y21A +cRJCs/JzkkoBM64IzudesQAGUHvvEN34cmUCFZgawVRWVkS4YIr5K0OBGgbihhl6 +hjVQwFQODnvKcKwlBMearTuIcVHbqbShCA1avuwZBXsD7x20hxQss/owz9l4AI07 +FQd8iKCvoc+/V7ATI42PZDKY9X674xTMNMRW/p2KyQ8uYPhXPIH5L+KEqSQGFWnU +5tJPC8NWgitGvA/6vgaYcb7KAfKJETolYb0vIDnvIbfqxapPmM733EhR79+WE8Xk +QlYOdoQLZ8jy6l2Epb5/LbZXqIULYhoAiuJ27xjtb8lBlvzj8XnuFfklPLoauf9l +aAYzTPLZ0f6VrULYsm2B2B7CVETzWPk0T7eJxoOoIv2+TDW8sZ0rUbEHbkN8AjN2 +C+mnKAItB/rltlGGNZvm4/Xgi5jV9RYaEli603mAOmBZ8vN/0eVCAFo/ +-----END CERTIFICATE REQUEST----- diff --git a/certs/full-cert.pem b/certs/full-cert.pem new file mode 100644 index 0000000..568c7b3 --- /dev/null +++ b/certs/full-cert.pem @@ -0,0 +1,66 @@ +-----BEGIN CERTIFICATE----- +MIIFpDCCA4ygAwIBAgIJAKsL4DcI3IrVMA0GCSqGSIb3DQEBBQUAMIGPMQswCQYD +VQQGEwJVUzELMAkGA1UECAwCQ0ExCzAJBgNVBAcMAkxBMRQwEgYDVQQKDAtTaWdu +YWwgTW9jazEUMBIGA1UECwwLU2lnbmFsIE1vY2sxFzAVBgNVBAMMDlNpZ25hbCBN +b2NrIENBMSEwHwYJKoZIhvcNAQkBFhJpbmR1dG55QHNpZ25hbC5vcmcwIBcNMjEw +MzEyMDIxMDAyWhgPMjEyMTAyMTYwMjEwMDJaMIGQMQswCQYDVQQGEwJVUzELMAkG +A1UECAwCQ0ExCzAJBgNVBAcMAkxBMRQwEgYDVQQKDAtTaWduYWwgTW9jazEUMBIG +A1UECwwLU2lnbmFsIE1vY2sxGDAWBgNVBAMMD21vY2suc2lnbmFsLm9yZzEhMB8G +CSqGSIb3DQEJARYSaW5kdXRueUBzaWduYWwub3JnMIICIjANBgkqhkiG9w0BAQEF +AAOCAg8AMIICCgKCAgEAznP0WUY58/FV/oeGAXoU+Oqurj7E5IR5ECgDa2W0r9ij +R40BkaZjUGElQrXIdG5pJwZVPuhGziHtm2RpHzbHydJVoAD/K9ntesUJRwJciVE9 +s82QypaoSNXr1Tv2GcP8eUJA5Z/TAeiD1fn82vserIcg2r0XNWH0V8VfxaH8sGOA +nan8UHgd8guaUMLMHGPwclDKIYgl+VaeXYMSrrGw2hF9+MelbOJMyxHixs9/iMAb +/IH5KX5gs7EMmZWY6BII/iLVIugIdwNl4wWKfPlKA7ZbmawhG8oOWHvD8aMoHd30 +l65hvC6q5wR2llYQpgq9WiITdc50gXwGgxEinCl4dQOaBrCH3kxJaZ6EIXkz2kof +PpR0oc0bX1GGd4fV6mWDbwXxkENIv9XLDKo+z1NjgW8MmBDGNtK5fOzY3RgpC9Jr +jLJqGq6mQ3S4vFzg9fNP0OHm8qbzOk2Y5TZ4rporTFiTL+Tq8WJM7L9E9GpX89Uq +OmPeF+od2Id0Q975j1ot0We+KmBm4IC44nWFB4cAptSpeeQIT3fgYupFd7e8WOi5 +pR8sKwwILxSyJ9h0d8CHO5jlUcbjYgKIfKnrrMHpxNIjxlDP+Nn5vPQACXuo+flD +ipW9CY7G+JJSP+Pd2O/p53zuDLWWMzWFTiMgJZuHPqXQDad2xa0gMJ5JDHAS9akC +AwEAATANBgkqhkiG9w0BAQUFAAOCAgEAAgGDrGqumCo3F29v0dwVXfZQoTzL6ENn +a6Efoy4mDbX3qHwAZnyGlrbNKMGhqCccj+VGYTuVZB9CB0jlE4YK29a7NAoh6k4a +BtE/cqg92grImqc/LKwgo2SLm6qnPMxJaAw/rwjRil4gJINBm+hzD8f3SbfDu4oC +3SG0wUEQHhZu/hP0Apd0ryquIYlBODD9qIjRiMAOLMWTft5pZ/4mArDl4Xoi1rMp +eZUgd7Q7dZdHyAkvFGsXXp6hcqsmQ7AkqyFrBpCzZBGVaWtVgI+2YNZx/MKg1W6d +iq38P391rF2IXto5sVBPGA5pSwEykTAcJHWKsHuTBhmDf5S3VGFShF4OnL9sj0NR +qAFAnUNA2uhTMCSrfYCbmw8gmntpS9imR340+sz3lbrvCxmovRZpCLZeQZODTg5L +nHdac3CYPjlTP6E3YM21X3vt72KmMr3pnXpF/+tl6/uWaSJaw9AQ+qYvGSOwHWoc +0egSBCY839SiAKFTPnKu08MoppKWNGQVjGfJ6HEyrnD7ewmSGMZu95zHe6Brv6HL +mQ1HytBU+uwolI4IMhqGy6nW7FPTulcJm/VHxNZV0wvwrkWaePfExsyQdR74+HDE +tLPt3wq6QTlqbR5Wg3xzcpUvGlfCXnsR3kyFjOWYpLPjuhdaEAbaJThEJ8A0mKz2 +95xSZsZK6Ww= +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIFtTCCA52gAwIBAgIJAN7vPX8scMU0MA0GCSqGSIb3DQEBCwUAMIGPMQswCQYD +VQQGEwJVUzELMAkGA1UECAwCQ0ExCzAJBgNVBAcMAkxBMRQwEgYDVQQKDAtTaWdu +YWwgTW9jazEUMBIGA1UECwwLU2lnbmFsIE1vY2sxFzAVBgNVBAMMDlNpZ25hbCBN +b2NrIENBMSEwHwYJKoZIhvcNAQkBFhJpbmR1dG55QHNpZ25hbC5vcmcwIBcNMjEw +MzEyMDIxMDAxWhgPMjEyMTAyMTYwMjEwMDFaMIGPMQswCQYDVQQGEwJVUzELMAkG +A1UECAwCQ0ExCzAJBgNVBAcMAkxBMRQwEgYDVQQKDAtTaWduYWwgTW9jazEUMBIG +A1UECwwLU2lnbmFsIE1vY2sxFzAVBgNVBAMMDlNpZ25hbCBNb2NrIENBMSEwHwYJ +KoZIhvcNAQkBFhJpbmR1dG55QHNpZ25hbC5vcmcwggIiMA0GCSqGSIb3DQEBAQUA +A4ICDwAwggIKAoICAQDHj/g7OmmhKkwzIKE6GMLsvl7B9pZbNOLZGKbHM2iQKuiQ +fvKQwJMquy7ef02/ArVK32Zjk+keOHwzzKmd93OslWaqV2T7qVY+XBUOUfpiRZHB +7tTltGCx3kDH8a53gNK/BySsfxddftTs/tYSfye/QzazsmtexXAEKaQ4RwKMD6nx +nxRtwriW3eVUMmOhxR9lgZnn32BhsJ23P9+o7BjkuWphzGufBTEHAiWyj8FrXVDD +4PSg4bXWhCKR/vYkctPryMJn+E9noIJB/ldUJ5wK19DphrYHfpa7L1zZO8l37XUT +FpLFsfCSIzBLuz9Nd3g0KSg0GJoV1eMltb8Ok2yNZ4uu4a4pW0haTcb2f6wZE29a +3agq0w4JOKqRSEe7dapBn9yomJbADhyq0Yreaq1WdC8JfFag6I1z7XQsNVFtPcQj +fyT6jwFxZ5AH5rVcAJlfoFRZcscUVPN2rsY1RlD+ueKH0MtnEgFdtvEyncNh36rk +IhrjjqO+LmibYbVSpXnm4vEafPjWBeAnhxUE8OqA06fQWA/CztYniTo+2sJvD9F2 +ypO4Di5MDpuZ0r7nA8AK4xy9XirJBZFSelbwCK4DongWjo+8WgZOaExb/9IlnL5A +sJb1JpHc/Ri3bQaIo3DPrM4KIbFWpcnDca1caEXB+SPbjg5jfFEgiI7YMZDY/QID +AQABoxAwDjAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEBCwUAA4ICAQBhIzTXDZWf +TZStjZpQ+5sWzBPLlLxvgBhhJCk3dZIxP2F/X3HHGzZEda+5ThgDCV98Dwn7jWtT +vAaGzWulXGAvT7v+AXFidIcYIDt66qQUY+s0vMgK1uuxvcLgVgSyNj+SbuiwjJBN +kBXsya19w2qD5U/QOm1ttRaw6PbrrzlSp3U4oGdIGFvn8FS57p6+hw2/1lV6vv9I +9kC5l75QQfSB54dvW/XoQ753sdQgGWI9sSaP6sC0SMeUtvBmj1hIrgKbzlcmUteB +XnFe4KaUpurS5N66RIsu2VSrW7vzP0nxIxwkxFlZ3mt68lE52Fp3CcrSyJCJxwAx +xlsVyJw3AG84Jn9eDvRj2apwC6kTqLNOwsTteQAc/Pr+MdPTRig7O0ERG7d/eSyz +FwPTvdgkUg4g2xVbWLoju7QNunpBhJPsNWYzc08Dx7grIutC8/ExYlYQCcOGXtpj +UluSq9r2ACI4RPD+bUVvzmJjoyzzpZwSF4pqerBMQKC1FEzuJWaH/gVoqOpIHBYq +uyKAcfd5nFRTb1Xv5g9yoM7d0E3ALe6S5cFeWxDbdVr+IWqXvxMN8KmO1yPQWc/3 +Tog1Zmytc6ApV+aRTgPpXwOe8UpsKEfGTMfC6r/tC8NoOcY/CG3xY8ZkDYAkEK9P +f+YofTwPTFQMWqBjD1ml4UWd2egkdC//OA== +-----END CERTIFICATE----- diff --git a/certs/generate-trust-root.js b/certs/generate-trust-root.js new file mode 100644 index 0000000..2f88394 --- /dev/null +++ b/certs/generate-trust-root.js @@ -0,0 +1,11 @@ +'use strict'; + +const fs = require('fs'); +const { PrivateKey } = require('signal-client'); + +const rootKey = PrivateKey.generate(); + +fs.writeFileSync(process.argv[2], JSON.stringify({ + privateKey: rootKey.serialize().toString('base64'), + publicKey: rootKey.getPublicKey().serialize().toString('base64'), +}, null, 2)); diff --git a/certs/generate-zk-params.js b/certs/generate-zk-params.js new file mode 100644 index 0000000..1fa0684 --- /dev/null +++ b/certs/generate-zk-params.js @@ -0,0 +1,12 @@ +'use strict'; + +const fs = require('fs'); +const { ServerSecretParams } = require('@signalapp/signal-client/zkgroup'); + +const secretParams = ServerSecretParams.generate(); +const publicParams = secretParams.getPublicParams(); + +fs.writeFileSync(process.argv[2], JSON.stringify({ + secretParams: secretParams.serialize().toString('base64'), + publicParams: publicParams.serialize().toString('base64'), +}, null, 2)); diff --git a/certs/key.pem b/certs/key.pem new file mode 100644 index 0000000..3248643 --- /dev/null +++ b/certs/key.pem @@ -0,0 +1,51 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIJKQIBAAKCAgEAznP0WUY58/FV/oeGAXoU+Oqurj7E5IR5ECgDa2W0r9ijR40B +kaZjUGElQrXIdG5pJwZVPuhGziHtm2RpHzbHydJVoAD/K9ntesUJRwJciVE9s82Q +ypaoSNXr1Tv2GcP8eUJA5Z/TAeiD1fn82vserIcg2r0XNWH0V8VfxaH8sGOAnan8 +UHgd8guaUMLMHGPwclDKIYgl+VaeXYMSrrGw2hF9+MelbOJMyxHixs9/iMAb/IH5 +KX5gs7EMmZWY6BII/iLVIugIdwNl4wWKfPlKA7ZbmawhG8oOWHvD8aMoHd30l65h +vC6q5wR2llYQpgq9WiITdc50gXwGgxEinCl4dQOaBrCH3kxJaZ6EIXkz2kofPpR0 +oc0bX1GGd4fV6mWDbwXxkENIv9XLDKo+z1NjgW8MmBDGNtK5fOzY3RgpC9JrjLJq +Gq6mQ3S4vFzg9fNP0OHm8qbzOk2Y5TZ4rporTFiTL+Tq8WJM7L9E9GpX89UqOmPe +F+od2Id0Q975j1ot0We+KmBm4IC44nWFB4cAptSpeeQIT3fgYupFd7e8WOi5pR8s +KwwILxSyJ9h0d8CHO5jlUcbjYgKIfKnrrMHpxNIjxlDP+Nn5vPQACXuo+flDipW9 +CY7G+JJSP+Pd2O/p53zuDLWWMzWFTiMgJZuHPqXQDad2xa0gMJ5JDHAS9akCAwEA +AQKCAgEAwZ9OU0vhnj+A/y3rKAdvE+RF33VPA2Kah+R/EIJaa3Ep8Tj1d7ws+H2j +jGUgktHaHJd763u8rCZ1yX7qeDPQPB/f3igRKPdCGhohEU/NqZtf2vm4CcgyG91F +SL7rmE6Owsq4QqMaKnf+7Pd/hYBuzDAPSBZ/Zblwh8C8iYXajzNCtJtv36hHrXHk +UEnhU98G6q+KYthlhOsPq3P6WYyc6GfvVRsosm2qx+kGXp7MZVyG4tKi859q4hvy +TThYJ46CkJXqfspO5g9xxvCzaXIDUGf+kKk2g/GEcsfghQcX4KsBciBB2VOLRQ6S +iXw+MPtxis7aoWn3Klk/Yjz30K20IZrjUONcOVRb9U22UqsSJJzx5Oj8spulEf58 +fcVu9P/Jjl+/kT8WENzrp21YhSYmqeI8FH8KPEipkxH2hXk58Dlldn3+DPoPDjbo +TjQEx/VBmREJm5GKoaQucnLwyFk4S52782VIP+PxfF5hTAIbo2g++q4mly5irLVH +R2bvocj2Q0WU/914pcD8nPNWcPJp8ug4+8vpLl+emJd4bA0ELpWQpXGGgS+eXX5G +FfjBlE1EKIJqldbnqDo5aB1ZMWGiPx8e+KJ/6dggTm7wLhk5X6hYyD/j4UobL2Yn +zm+JjFlrIuL4D9fTm6oxnKpX4jrSTO4e+KHGCkswbalFpmivKAECggEBAOsF2Ro3 +VzfmBezVLrp+dmL1fqa1S57LkPv3L5Vo8NApA/oZd+O9x3fEqiVqVSWsUfOXa5fX +1Gk8vydbHt7mdTV79U+y8vd+Bp5UqRB5ClyATd38uqbfyMWxcrEtdElESG1o5RMv +gZ8zYW9OGIDla+idOvl87/1hzpW38Pg2P25MTXlzYsQLYKDbnnjboBaD9DF+EvtH +vn7eZBccHR2Nwl3lw41XtSQm1g//t5F2NSiomkcHRJbhzHw2+JJqORx7Qn6Nb+tR +iD6tNbCDWCJUvGbTA7nQ/lQBZFclOsPeOeUsrPhI7y0CL+NWS/KsN7oy88IKuj3M +NFLgJRpmUYLRQMkCggEBAODhTIORN1d5LIrACjrfkzi8yNi3MQb+v1vMAZmKa4LE +N8LH/1Jq4ge9ZS4qAAglzwytFyrvAPAkwpaRzeKhNkUlgQvuMByZyspeUrBjRa3D +Bg2ZmQQU9oRns/S8ou74p6mxTS4MLvhOfSbbU5WU5QE7lYCQyCQ0gyFmgShO2sxL +Be5B9eHlEM+goZV851G4a0GXaONVZg2DLWoqPShkYqfAviQffJPhsWXYGy6axyVy +HXosDaXWVoIUdqK2Zywyfv0hBIW2XdjdrXw0O8F1Dq7krc7G0fXYw+gtoky25m9e +YYtL+MgbC7GOxqEfiwp2A4QPAJNJ9IrA0xBAGFSgXeECggEAcBOg3ayobiLGjpN3 +Lj5ijHyQAkYQotBm+pF9Yp3hwRLeL6V3k+aLueTSUvdrVYTgq+54w7eMNwdeDRGa +Hif+mxva9z/aOAYkd9rdqNpgA464E3WTOUMcxpIBpNaNTuc+Nta/7y9HfDkPbPns +G4PMuuhIGHPpKoc68hD+3A9anmnKxHEvF1hpoyw4XWA27qXMjNGXkbc789pwFsk0 +ZUSV/Rs17zB1VKEkkgqbasXZQeNtdxPTNLTHRDEexwva4mcxZZJjXe/KrkEBioSI +Qg7wrYRRkYpFzp+/TwOVC5TtPQnaDqkRTgBt/9bGbxqLlML5lX5yJEg+Z4s2hmlm +06CgaQKCAQBpt2B55VWH6K4Y2Ci2/o2+OXmN76i7qhyZcfE6lgjuo4G3LsAHpbl2 +fAHJzvLO4b9RLTnb9BmTyyQzFn4cUT8rCHN/AU1a1K2xrt/ejfyesDTzRcbaVWSC +YCIIJnnOL9TaAEDRKecW0gchsi+7/RAfITyqAOYlpw4SMJb9NPzE12wFUrrdpArg +IJp1pQ81qqW5Yw1q+aWNKqK56vtvNqnuRLzeTHMLLilwQESfByIhp2DWI0mTMYzf +f/E5ktgzvdVW+COhHFdH8QZygjepPXdWnqhasbrYgTuvtWw79iukJVFj46YjpBs+ +MGmOKz74/vuuJENX+odch0Nxuz/04KLhAoIBAQCtrfskal/gU61nB2zhtKWHuMkC +ztJDJSpiZiMMjVRgS+1adeWMEgAQ8EZ8VD2J5ph8KZ0QzI2CZGByaghq2FaTK6PK +W8JJU38g7jzqlFECC/60zG2NUw+HBjmE5jvmXgKCkkddDL92kMCKDZmXCUIgsi+6 +fMFMpQYoSrOT14ErR6poY4hvGyPs+P+uHn4RgdGDm88Wzl22kGBH0mbKQkHchBGI +uIqC3JcoUmdujw9o6dFxM4D006LoSBO+mznknUu9Ai0lUJnkgkyoflTY3bxRTKK2 +sRweRXTaCF624QwwqrT+tV+IqHauJMg28QqoFM3KIy2J8L1/8WuG8liRFq1x +-----END RSA PRIVATE KEY----- diff --git a/certs/main.cnf b/certs/main.cnf new file mode 100644 index 0000000..c11efaa --- /dev/null +++ b/certs/main.cnf @@ -0,0 +1,27 @@ +[ req ] +default_bits = 4096 +days = 9999 +distinguished_name = req_distinguished_name +attributes = req_attributes +prompt = no +x509_extensions = v3_ca + +[ req_distinguished_name ] +C = US +ST = CA +L = LA +O = Signal Mock +OU = Signal Mock +CN = mock.signal.org +emailAddress = indutny@signal.org + +[ req_attributes ] + +[ v3_ca ] +authorityInfoAccess = @issuer_info +basicConstraints = CA:FALSE +subjectAltName = IP:127.0.0.1 + +[ issuer_info ] +OCSP;URI.0 = http://mock.signal.org/ +caIssuers;URI.0 = http://mock.signal.org/ca.cert diff --git a/certs/trust-root.json b/certs/trust-root.json new file mode 100644 index 0000000..932166d --- /dev/null +++ b/certs/trust-root.json @@ -0,0 +1,4 @@ +{ + "privateKey": "wNgId00JnobLvJLxeIyigS0DdNtNwwgnBoa9N2/JSnQ=", + "publicKey": "BYWSmRa6qWgg25jjBrX/I97gB3+FRkSJH4+30s4YSlc4" +} \ No newline at end of file diff --git a/certs/zk-params.json b/certs/zk-params.json new file mode 100644 index 0000000..9eba3d3 --- /dev/null +++ b/certs/zk-params.json @@ -0,0 +1,4 @@ +{ + "secretParams": "AHF495d5PeSr/eBPjD9dS0EXFwpAszASNGqbqESLrUoMUuR/ZGjFUsY9NPQApoT8NqPSedTn7+mlv1K+DvxbSAiEkLXEtkJl0PPDQcB2CZzwVylkmllrQ/M107YZmMXZU6xy753JNZiG19NWqJ58QK9BRtaa+Q8qK5vM0lZg2lkNcV50jwCJwhhLLmj3ir1C8557SfU3J66y2nnhv5WkAQ+RSJRRwt5V0ELGgI+h7Y1GXsa1wG5K6n4/BT8YTB8WBUIkMNy8yL7uukRxANZmTNfdzCb3KtnhbcHTEED13YAGgQ5sV9rcrUWXnxgKq2Yja1JSYNYyhXehVZuvyvxplQQiCERyFEMxRk1DBi0cKulhp6CKr8IEW+INF2N98moIC/b+6dBgH6bkG18MCWlTTRGKv9Yn0azuU23WQ77i3EEVBn8bNGwMP7+Js1yO9nLo4//WoZNwFo26gXQbk++3XQSontddOZBsRjUmgmFoUq2/JwuQNpQGsUwtC9bkDs3QCLFFwu/UyCvwnPrMrAg8GmQ4eLkA1D8Q65VxuwoW+iIBsnx4DzywCmsLHTDt0sP8CzSR8I4iwFZHPcK9D5hC3UbedvC/oMKW72SUGcXXjhbz4Z63xxkfeRbzyY7FeSTWBCAnsI/vK9hGPfpUVF5xVN7KRgPMGGKxoJqiqYdG9eYBu0uT5dEXJxYHKlHnKCDsGc89mIuR5NgrwSE62qgp0gwmD/FpG7t8klloA7Azuw0wuaalUDBTptrlPaZQs1ajCTX3lNeffTaOeXXlUdbJOxtiFJts/D1yxth0RD+HSJcI7IMk+jB6m8PGXqqLd4OWXQs0Gd/m7s9UCP1nbqsiOArme8Np5itBYKyWTDkah/WoulWtIPM6jMkHCBrZtl/EUWazqqpEZmmGbQIdxKKD4wNpt02/5l2viJi3K+6P6dhjwckuCb90pefRQAF74tKIf11vqbM+mQVE4v9IIEYHmwCCw9MKEHe4pLXKAu2m2+XB+OKlfaGYg7V5T9BWaX5pbP3blPOgwNb8klthm1zAtGkb05bdNGtJc+1mF3uANz8LLiCMyhfYadQWGQ3c6FT1Td3LdrBfkVZx9TKWF98MPApYev3lnJNkRKndNteF0Ov6aScNAt4IE7SUXamWp/TYNZkMv0kPRZwtC2VPItekaiRSpEoMrpLR8vTehrSYEo8FaxSa9wJ3eUiLpQShwIMMsV+H0D3oQfprn3xFOTedFAjsmOSOwq2I39RJ1+nFtXYDGB722Y08TgjfRRlKBEiFBPsLKroCrp3RGJFODLvjzws4N/2iMKn+2Jwrvxs1uREOG/lDq7J42zDE9rGHF7m/UVl3+ZDCjjWw/1r9UbqppwCRoUE3mncSvFaVWR2ycaswlOXYwue+xfR81OuO2wkBCAwLSDZ1NkXIsFUUETgBNmJ4pkcZeJGyHWpSH0hnuLZfcgos0st/5FpWh/i2Awy5O+Su+EhNQexfHQHL+kaSv2K2th2sgsBYruNt3JWPDiB2fC0qdI7la+f1sKLJnUtcBHURYhRzz3+VBiBbW9DmE73eldNKe0oHuNVGG5P5iToBZH/s9KuJIv3U2cKDQ/ed3sbHCigA0Y74djcB/JdjtDwtnxQ19GacF96prQYDdk45shygOY1HOEgMJMss1vt5AcSl7riKmTJI0OOKla2LhXCl67wt/bL+RIlkyIOco2kBNJMiW5P+71JOrjnw1vpF2j3fvU/QiAuuslO+EE7eeg7IlDLJMFnRSnHoZKiZmHUNS7rzNqOnvQGmd0kYQfSlAgy8kLCQqrtHif9rDpbdB/p3KDbMwVaW8PUhG0pCpcYJ96hH7WbyGceH+UaZ3y3b9xomqR1/HNnTKL9V4u8AUwSfBhlN7JOHWw2HD138J3CBnVOSRFRN8F4McW5J+vPdCePyc+RqOsmtOCets2CK/yxlZgHI6qsYlErz2rEr+20BnqI4FQGrJi301PYwN1WwQGuCM5dyuevqXDPC5llo+GlSZZLGlmmusFbSTXbH2/veGbFwhQXSaAq+lgCGsnIbLA==", + "publicParams": "APb+6dBgH6bkG18MCWlTTRGKv9Yn0azuU23WQ77i3EEVBn8bNGwMP7+Js1yO9nLo4//WoZNwFo26gXQbk++3XQTme8Np5itBYKyWTDkah/WoulWtIPM6jMkHCBrZtl/EUWazqqpEZmmGbQIdxKKD4wNpt02/5l2viJi3K+6P6dhjgsPTChB3uKS1ygLtptvlwfjipX2hmIO1eU/QVml+aWwMC0g2dTZFyLBVFBE4ATZieKZHGXiRsh1qUh9IZ7i2X3IKLNLLf+RaVof4tgMMuTvkrvhITUHsXx0By/pGkr9inqI4FQGrJi301PYwN1WwQGuCM5dyuevqXDPC5llo+GlSZZLGlmmusFbSTXbH2/veGbFwhQXSaAq+lgCGsnIbLA==" +} \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..6df8475 --- /dev/null +++ b/package.json @@ -0,0 +1,70 @@ +{ + "name": "@signalapp/mock-server", + "private": true, + "version": "1.0.0", + "description": "Mock Signal Server for writing tests", + "main": "src/index.js", + "types": "src/index.d.ts", + "files": [ + "src/**/*.js", + "src/**/*.d.ts", + "protos/compiled.js", + "protos/compiled.d.ts", + "certs" + ], + "scripts": { + "watch": "npm run build:tsc -- -w", + "build:tsc": "tsc", + "build:protobuf": "pbjs --target static-module --force-long --wrap commonjs --out protos/compiled.js protos/*.proto", + "build:protobuf-ts": "pbts --out protos/compiled.d.ts protos/compiled.js", + "build": "npm run build:protobuf && npm run build:protobuf-ts && npm run build:tsc", + "install": "npm run build", + "mocha": "mocha test/**/*-test.js", + "lint": "eslint --cache src test", + "test": "npm run mocha && npm run lint" + }, + "repository": { + "type": "git", + "url": "git+ssh://git@github.com/signalapp/Mock-Signal-Server.git" + }, + "keywords": [ + "mock", + "signal", + "server" + ], + "author": { + "name": "Open Whisper Systems", + "email": "support@signal.org" + }, + "license": "AGPL-3.0-only", + "bugs": { + "url": "https://github.com/signalapp/Mock-Signal-Server/issues" + }, + "homepage": "https://github.com/signalapp/Mock-Signal-Server#readme", + "dependencies": { + "@signalapp/signal-client": "0.12.1", + "debug": "^4.3.2", + "long": "^4.0.0", + "micro": "^9.3.4", + "microrouter": "^3.1.3", + "protobufjs": "^6.10.2", + "typescript": "^4.5.5", + "url-pattern": "^1.0.3", + "uuid": "^8.3.2", + "ws": "^8.4.2" + }, + "devDependencies": { + "@types/debug": "^4.1.7", + "@types/long": "^4.0.1", + "@types/micro": "^7.3.6", + "@types/microrouter": "^3.1.1", + "@types/mocha": "^9.1.0", + "@types/node": "^17.0.13", + "@types/uuid": "^8.3.0", + "@types/ws": "^8.2.2", + "@typescript-eslint/eslint-plugin": "^5.10.1", + "@typescript-eslint/parser": "^5.10.1", + "eslint": "^8.7.0", + "mocha": "^9.2.0" + } +} diff --git a/protos/ContactDiscovery.proto b/protos/ContactDiscovery.proto new file mode 100644 index 0000000..340355d --- /dev/null +++ b/protos/ContactDiscovery.proto @@ -0,0 +1,40 @@ +// Copyright 2021-2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +package signalservice; + +message CDSClientRequest { + // From Signal /v2/directory/auth + optional bytes username = 1; + optional bytes password = 2; + + // Each e164 is a big-endian uint64 (8 bytes). + repeated bytes e164 = 3; + + // Each ACI/UAK pair is a 32-byte buffer, containing the 16-byte ACI followed + // by its 16-byte UAK. + repeated bytes aci_uak_pair = 4; +} + +message CDSClientResponse { + // Each triple is an 8-byte e164, a 16-byte PNI, and a 16-byte ACI. + // If the e164 was not found, PNI and ACI are all zeros. If the PNI + // was found but the ACI was not, the PNI will be non-zero and the ACI + // will be all zeros. ACI will be returned if one of the returned + // PNIs has an ACI/UAK pair that matches. + // + // Should the request be successful (IE: a successful status returned), + // |e164_pni_aci_triple| will always equal |e164| of the request, + // so the entire marshalled size of the response will be (2+32)*|e164|, + // where the additional 2 bytes are the id/type/length additions of the + // protobuf marshaling added to each byte array. This avoids any data + // leakage based on the size of the encrypted output. + repeated bytes e164_pni_aci_triple = 1; + + // If the user has run out of quota for lookups, they will receive + // a response with just the following field set, followed by a websocket + // closure of type 4008 (RESOURCE_EXHAUSTED). Should they retry exactly + // the same request after the provided number of seconds has passed, + // we expect it should work. + optional int32 retry_after_secs = 2; +} diff --git a/protos/CrashReports.proto b/protos/CrashReports.proto new file mode 100644 index 0000000..9542f53 --- /dev/null +++ b/protos/CrashReports.proto @@ -0,0 +1,15 @@ +syntax = "proto3"; + +// Copyright 2020-2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +package signalservice; + +message CrashReport { + string filename = 1; + bytes content = 2; +} + +message CrashReportList { + repeated CrashReport reports = 1; +} diff --git a/protos/DeviceMessages.proto b/protos/DeviceMessages.proto new file mode 100644 index 0000000..d926f23 --- /dev/null +++ b/protos/DeviceMessages.proto @@ -0,0 +1,33 @@ +// Copyright 2014-2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +package signalservice; + +message ProvisioningUuid { + optional string uuid = 1; +} + + +message ProvisionEnvelope { + optional bytes publicKey = 1; + optional bytes body = 2; // Encrypted ProvisionMessage +} + +message ProvisionMessage { + optional bytes identityKeyPrivate = 2; + optional string number = 3; + optional string uuid = 8; + optional string provisioningCode = 4; + optional string userAgent = 5; + optional bytes profileKey = 6; + optional bool readReceipts = 7; + optional uint32 ProvisioningVersion = 9; +} + +enum ProvisioningVersion { + option allow_alias = true; + + INITIAL = 0; + TABLET_SUPPORT = 1; + CURRENT = 1; +} diff --git a/protos/DeviceName.proto b/protos/DeviceName.proto new file mode 100644 index 0000000..512d765 --- /dev/null +++ b/protos/DeviceName.proto @@ -0,0 +1,10 @@ +// Copyright 2018-2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +package signalservice; + +message DeviceName { + optional bytes ephemeralPublic = 1; + optional bytes syntheticIv = 2; + optional bytes ciphertext = 3; +} diff --git a/protos/Groups.proto b/protos/Groups.proto new file mode 100644 index 0000000..f3d04c3 --- /dev/null +++ b/protos/Groups.proto @@ -0,0 +1,235 @@ +syntax = "proto3"; + +// Copyright 2020-2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +package signalservice; + +option java_package = "org.whispersystems.signalservice.protos.groups"; +option java_multiple_files = true; + +message AvatarUploadAttributes { + string key = 1; + string credential = 2; + string acl = 3; + string algorithm = 4; + string date = 5; + string policy = 6; + string signature = 7; +} + +message Member { + enum Role { + UNKNOWN = 0; + DEFAULT = 1; // Normal member + ADMINISTRATOR = 2; // Group admin + } + + bytes userId = 1; // The UuidCiphertext + Role role = 2; + bytes profileKey = 3; // The ProfileKeyCiphertext + bytes presentation = 4; // ProfileKeyCredentialPresentation + uint32 joinedAtVersion = 5; // The Group.version this member joined at +} + +message MemberPendingProfileKey { + Member member = 1; // The “invited” member + bytes addedByUserId = 2; // The UID who invited this member + uint64 timestamp = 3; // The time the invitation occurred +} + +message MemberPendingAdminApproval { + bytes userId = 1; + bytes profileKey = 2; + bytes presentation = 3; + uint64 timestamp = 4; +} + +message AccessControl { + enum AccessRequired { + UNKNOWN = 0; + ANY = 1; + MEMBER = 2; // Any group member can make the modification + ADMINISTRATOR = 3; // Only administrators can make the modification + UNSATISFIABLE = 4; + } + + AccessRequired attributes = 1; // Who can modify the group title, avatar, disappearing messages timer + AccessRequired members = 2; // Who can add people to the group + AccessRequired addFromInviteLink = 3; +} + +message Group { + bytes publicKey = 1; // GroupPublicParams + bytes title = 2; // Encrypted title + string avatar = 3; // Pointer to encrypted avatar (‘key’ from AvatarUploadAttributes) + bytes disappearingMessagesTimer = 4; // Encrypted timer + AccessControl accessControl = 5; + uint32 version = 6; // Current group version number + repeated Member members = 7; + repeated MemberPendingProfileKey membersPendingProfileKey = 8; + repeated MemberPendingAdminApproval membersPendingAdminApproval = 9; + bytes inviteLinkPassword = 10; + bytes descriptionBytes = 11; + bool announcementsOnly = 12; +} + +message GroupChange { + + message Actions { + + message AddMemberAction { + Member added = 1; + bool joinFromInviteLink = 2; + } + + message DeleteMemberAction { + bytes deletedUserId = 1; + } + + message ModifyMemberRoleAction { + bytes userId = 1; + Member.Role role = 2; + } + + message ModifyMemberProfileKeyAction { + bytes presentation = 1; + } + + message AddMemberPendingProfileKeyAction { + MemberPendingProfileKey added = 1; + } + + message DeleteMemberPendingProfileKeyAction { + bytes deletedUserId = 1; + } + + message PromoteMemberPendingProfileKeyAction { + bytes presentation = 1; + } + + message AddMemberPendingAdminApprovalAction { + MemberPendingAdminApproval added = 1; + } + + message DeleteMemberPendingAdminApprovalAction { + bytes deletedUserId = 1; + } + + message PromoteMemberPendingAdminApprovalAction { + bytes userId = 1; + Member.Role role = 2; + } + + message ModifyTitleAction { + bytes title = 1; + } + + message ModifyAvatarAction { + string avatar = 1; + } + + message ModifyDisappearingMessagesTimerAction { + bytes timer = 1; + } + + message ModifyAttributesAccessControlAction { + AccessControl.AccessRequired attributesAccess = 1; + } + + message ModifyAvatarAccessControlAction { + AccessControl.AccessRequired avatarAccess = 1; + } + + message ModifyMembersAccessControlAction { + AccessControl.AccessRequired membersAccess = 1; + } + + message ModifyAddFromInviteLinkAccessControlAction { + AccessControl.AccessRequired addFromInviteLinkAccess = 1; + } + + message ModifyInviteLinkPasswordAction { + bytes inviteLinkPassword = 1; + } + + message ModifyDescriptionAction { + bytes descriptionBytes = 1; + } + + message ModifyAnnouncementsOnlyAction { + bool announcementsOnly = 1; + } + + + bytes sourceUuid = 1; // Who made the change + uint32 version = 2; // The change version number + repeated AddMemberAction addMembers = 3; // Members added + repeated DeleteMemberAction deleteMembers = 4; // Members deleted + repeated ModifyMemberRoleAction modifyMemberRoles = 5; // Modified member roles + repeated ModifyMemberProfileKeyAction modifyMemberProfileKeys = 6; // Modified member profile keys + repeated AddMemberPendingProfileKeyAction addPendingMembers = 7; // Pending members added + repeated DeleteMemberPendingProfileKeyAction deletePendingMembers = 8; // Pending members deleted + repeated PromoteMemberPendingProfileKeyAction promotePendingMembers = 9; // Pending invitations accepted + ModifyTitleAction modifyTitle = 10; // Changed title + ModifyAvatarAction modifyAvatar = 11; // Changed avatar + ModifyDisappearingMessagesTimerAction modifyDisappearingMessagesTimer = 12; // Changed timer + ModifyAttributesAccessControlAction modifyAttributesAccess = 13; // Changed attributes access control + ModifyMembersAccessControlAction modifyMemberAccess = 14; // Changed membership access control + ModifyAddFromInviteLinkAccessControlAction modifyAddFromInviteLinkAccess = 15; // change epoch = 1 + repeated AddMemberPendingAdminApprovalAction addMemberPendingAdminApprovals = 16; // change epoch = 1 + repeated DeleteMemberPendingAdminApprovalAction deleteMemberPendingAdminApprovals = 17; // change epoch = 1 + repeated PromoteMemberPendingAdminApprovalAction promoteMemberPendingAdminApprovals = 18; // change epoch = 1 + ModifyInviteLinkPasswordAction modifyInviteLinkPassword = 19; // change epoch = 1 + ModifyDescriptionAction modifyDescription = 20; // change epoch = 2 + ModifyAnnouncementsOnlyAction modifyAnnouncementsOnly = 21; // change epoch = 3 + } + + bytes actions = 1; // The serialized actions + bytes serverSignature = 2; // Server’s signature over serialized actions + uint32 changeEpoch = 3; // Allows clients to decide whether their change logic can successfully apply this diff +} + +message GroupChanges { + message GroupChangeState { + GroupChange groupChange = 1; + Group groupState = 2; + } + + repeated GroupChangeState groupChanges = 1; +} + +message GroupAttributeBlob { + oneof content { + string title = 1; + bytes avatar = 2; + uint32 disappearingMessagesDuration = 3; + string descriptionText = 4; + } +} + +message GroupExternalCredential { + string token = 1; +} + +message GroupInviteLink { + message GroupInviteLinkContentsV1 { + bytes groupMasterKey = 1; + bytes inviteLinkPassword = 2; + } + + oneof contents { + GroupInviteLinkContentsV1 v1Contents = 1; + } +} + +message GroupJoinInfo { + bytes publicKey = 1; + bytes title = 2; + string avatar = 3; + uint32 memberCount = 4; + AccessControl.AccessRequired addFromInviteLink = 5; + uint32 version = 6; + bool pendingAdminApproval = 7; + bytes descriptionBytes = 8; +} diff --git a/protos/LibSignal-Client.proto b/protos/LibSignal-Client.proto new file mode 100644 index 0000000..634a29e --- /dev/null +++ b/protos/LibSignal-Client.proto @@ -0,0 +1,107 @@ +syntax = "proto3"; + +// +// Copyright 2020-2021 Signal Messenger, LLC. +// SPDX-License-Identifier: AGPL-3.0-only +// + +package signal.proto.storage; + +message SessionStructure { + message Chain { + bytes sender_ratchet_key = 1; + bytes sender_ratchet_key_private = 2; + + message ChainKey { + uint32 index = 1; + bytes key = 2; + } + + ChainKey chain_key = 3; + + message MessageKey { + uint32 index = 1; + bytes cipher_key = 2; + bytes mac_key = 3; + bytes iv = 4; + } + + repeated MessageKey message_keys = 4; + } + + message PendingPreKey { + uint32 pre_key_id = 1; + int32 signed_pre_key_id = 3; + bytes base_key = 2; + } + + uint32 session_version = 1; + bytes local_identity_public = 2; + bytes remote_identity_public = 3; + + bytes root_key = 4; + uint32 previous_counter = 5; + + Chain sender_chain = 6; + // The order is significant; keys at the end are "older" and will get trimmed. + repeated Chain receiver_chains = 7; + + PendingPreKey pending_pre_key = 9; + + uint32 remote_registration_id = 10; + uint32 local_registration_id = 11; + + bool needs_refresh = 12; + bytes alice_base_key = 13; +} + +message RecordStructure { + SessionStructure current_session = 1; + // The order is significant; sessions at the end are "older" and will get trimmed. + repeated SessionStructure previous_sessions = 2; +} + +message PreKeyRecordStructure { + uint32 id = 1; + bytes public_key = 2; + bytes private_key = 3; +} + +message SignedPreKeyRecordStructure { + uint32 id = 1; + bytes public_key = 2; + bytes private_key = 3; + bytes signature = 4; + fixed64 timestamp = 5; +} + +message IdentityKeyPairStructure { + bytes public_key = 1; + bytes private_key = 2; +} + +message SenderKeyStateStructure { + message SenderChainKey { + uint32 iteration = 1; + bytes seed = 2; + } + + message SenderMessageKey { + uint32 iteration = 1; + bytes seed = 2; + } + + message SenderSigningKey { + bytes public = 1; + bytes private = 2; + } + + uint32 sender_key_id = 1; + SenderChainKey sender_chain_key = 2; + SenderSigningKey sender_signing_key = 3; + repeated SenderMessageKey sender_message_keys = 4; +} + +message SenderKeyRecordStructure { + repeated SenderKeyStateStructure sender_key_states = 1; +} \ No newline at end of file diff --git a/protos/README.md b/protos/README.md new file mode 100644 index 0000000..5bcd52e --- /dev/null +++ b/protos/README.md @@ -0,0 +1,6 @@ +# Protobufs + +Files in this directory are a copy of `protos` folder in [Signal-Desktop][0] +repository. + +[0]: https://github.com/signalapp/Signal-Desktop/tree/development/protos diff --git a/protos/SignalService.proto b/protos/SignalService.proto new file mode 100644 index 0000000..811a2b4 --- /dev/null +++ b/protos/SignalService.proto @@ -0,0 +1,562 @@ +// Copyright 2014-2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +// Source: https://github.com/signalapp/libsignal-service-java/blob/4684a49b2ed8f32be619e0d0eea423626b6cb2cb/protobuf/SignalService.proto +package signalservice; + +option java_package = "org.whispersystems.signalservice.internal.push"; +option java_outer_classname = "SignalServiceProtos"; + +message Envelope { + enum Type { + UNKNOWN = 0; + CIPHERTEXT = 1; + KEY_EXCHANGE = 2; + PREKEY_BUNDLE = 3; + RECEIPT = 5; + UNIDENTIFIED_SENDER = 6; + + // Our parser does not handle reserved in enums: DESKTOP-1569 + // reserved 7; + + PLAINTEXT_CONTENT = 8; + } + + optional Type type = 1; + optional string source = 2; + optional string sourceUuid = 11; + optional uint32 sourceDevice = 7; + optional string relay = 3; + optional uint64 timestamp = 5; + optional bytes legacyMessage = 6; // Contains an encrypted DataMessage + optional bytes content = 8; // Contains an encrypted Content + optional string serverGuid = 9; + optional uint64 serverTimestamp = 10; + optional string destinationUuid = 13; +} + +message Content { + optional DataMessage dataMessage = 1; + optional SyncMessage syncMessage = 2; + optional CallingMessage callingMessage = 3; + optional NullMessage nullMessage = 4; + optional ReceiptMessage receiptMessage = 5; + optional TypingMessage typingMessage = 6; + optional bytes senderKeyDistributionMessage = 7; + optional bytes decryptionErrorMessage = 8; + optional StoryMessage storyMessage = 9; +} + +// Everything in CallingMessage must be kept in sync with RingRTC (ringrtc-node). +// Whenever you change this, make sure you change textsecure.d.ts and RingRTC. +message CallingMessage { + message Offer { + enum Type { + OFFER_AUDIO_CALL = 0; + OFFER_VIDEO_CALL = 1; + } + + optional uint64 callId = 1; + // Legacy/deprecated; replaced by 'opaque' + optional string sdp = 2; + optional Type type = 3; + optional bytes opaque = 4; + } + + message Answer { + optional uint64 callId = 1; + // Legacy/deprecated; replaced by 'opaque' + optional string sdp = 2; + optional bytes opaque = 3; + } + + message IceCandidate { + optional uint64 callId = 1; + // Legacy/deprecated; remove when old clients are gone. + optional string mid = 2; + // Legacy/deprecated; remove when old clients are gone. + optional uint32 line = 3; + // Legacy/deprecated; replaced by 'opaque' + optional string sdp = 4; + optional bytes opaque = 5; + } + + message Busy { + optional uint64 callId = 1; + } + + message Hangup { + enum Type { + HANGUP_NORMAL = 0; + HANGUP_ACCEPTED = 1; + HANGUP_DECLINED = 2; + HANGUP_BUSY = 3; + HANGUP_NEED_PERMISSION = 4; + } + + optional uint64 callId = 1; + optional Type type = 2; + optional uint32 deviceId = 3; + } + + message Opaque { + enum Urgency { + DROPPABLE = 0; + HANDLE_IMMEDIATELY = 1; + } + + optional bytes data = 1; + optional Urgency urgency = 2; + } + + optional Offer offer = 1; + optional Answer answer = 2; + repeated IceCandidate iceCandidates = 3; + optional Hangup legacyHangup = 4; + optional Busy busy = 5; + optional Hangup hangup = 7; + optional bool supportsMultiRing = 8; + optional uint32 destinationDeviceId = 9; + optional Opaque opaque = 10; +} + +message DataMessage { + enum Flags { + END_SESSION = 1; + EXPIRATION_TIMER_UPDATE = 2; + PROFILE_KEY_UPDATE = 4; + } + + message Quote { + message QuotedAttachment { + optional string contentType = 1; + optional string fileName = 2; + optional AttachmentPointer thumbnail = 3; + } + + optional uint64 id = 1; + reserved /* author */ 2; // removed + optional string authorUuid = 5; + optional string text = 3; + repeated QuotedAttachment attachments = 4; + repeated BodyRange bodyRanges = 6; + } + + message Contact { + message Name { + optional string givenName = 1; + optional string familyName = 2; + optional string prefix = 3; + optional string suffix = 4; + optional string middleName = 5; + optional string displayName = 6; + } + + message Phone { + enum Type { + HOME = 1; + MOBILE = 2; + WORK = 3; + CUSTOM = 4; + } + + optional string value = 1; + optional Type type = 2; + optional string label = 3; + } + + message Email { + enum Type { + HOME = 1; + MOBILE = 2; + WORK = 3; + CUSTOM = 4; + } + + optional string value = 1; + optional Type type = 2; + optional string label = 3; + } + + message PostalAddress { + enum Type { + HOME = 1; + WORK = 2; + CUSTOM = 3; + } + + optional Type type = 1; + optional string label = 2; + optional string street = 3; + optional string pobox = 4; + optional string neighborhood = 5; + optional string city = 6; + optional string region = 7; + optional string postcode = 8; + optional string country = 9; + } + + message Avatar { + optional AttachmentPointer avatar = 1; + optional bool isProfile = 2; + } + + optional Name name = 1; + repeated Phone number = 3; + repeated Email email = 4; + repeated PostalAddress address = 5; + optional Avatar avatar = 6; + optional string organization = 7; + } + + message Preview { + optional string url = 1; + optional string title = 2; + optional AttachmentPointer image = 3; + optional string description = 4; + optional uint64 date = 5; + } + + message Sticker { + optional bytes packId = 1; + optional bytes packKey = 2; + optional uint32 stickerId = 3; + optional AttachmentPointer data = 4; + optional string emoji = 5; + } + + message Reaction { + optional string emoji = 1; + optional bool remove = 2; + reserved /* targetAuthorE164 */ 3; // removed + optional string targetAuthorUuid = 4; + optional uint64 targetTimestamp = 5; + } + + message Delete { + optional uint64 targetSentTimestamp = 1; + } + + message BodyRange { + optional uint32 start = 1; + optional uint32 length = 2; + + // oneof associatedValue { + optional string mentionUuid = 3; + //} + } + + message GroupCallUpdate { + optional string eraId = 1; + } + + message StoryContext { + optional string authorUuid = 1; + optional uint64 sentTimestamp = 2; + } + + enum ProtocolVersion { + option allow_alias = true; + + INITIAL = 0; + MESSAGE_TIMERS = 1; + VIEW_ONCE = 2; + VIEW_ONCE_VIDEO = 3; + REACTIONS = 4; + CDN_SELECTOR_ATTACHMENTS = 5; + MENTIONS = 6; + PAYMENTS = 7; + CURRENT = 7; + } + + optional string body = 1; + repeated AttachmentPointer attachments = 2; + optional GroupContext group = 3; + optional GroupContextV2 groupV2 = 15; + optional uint32 flags = 4; + optional uint32 expireTimer = 5; + optional bytes profileKey = 6; + optional uint64 timestamp = 7; + optional Quote quote = 8; + repeated Contact contact = 9; + repeated Preview preview = 10; + optional Sticker sticker = 11; + optional uint32 requiredProtocolVersion = 12; + optional bool isViewOnce = 14; + optional Reaction reaction = 16; + optional Delete delete = 17; + repeated BodyRange bodyRanges = 18; + optional GroupCallUpdate groupCallUpdate = 19; + reserved /* Payment payment */ 20; + optional StoryContext storyContext = 21; +} + +message NullMessage { + optional bytes padding = 1; +} + +message ReceiptMessage { + enum Type { + DELIVERY = 0; + READ = 1; + VIEWED = 2; + } + + optional Type type = 1; + repeated uint64 timestamp = 2; +} + +message TypingMessage { + enum Action { + STARTED = 0; + STOPPED = 1; + } + + optional uint64 timestamp = 1; + optional Action action = 2; + optional bytes groupId = 3; +} + +message StoryMessage { + optional bytes profileKey = 1; + optional GroupContextV2 group = 2; + optional AttachmentPointer attachment = 3; +} + +message Verified { + enum State { + DEFAULT = 0; + VERIFIED = 1; + UNVERIFIED = 2; + } + + optional string destination = 1; + optional string destinationUuid = 5; + optional bytes identityKey = 2; + optional State state = 3; + optional bytes nullMessage = 4; +} + +message SyncMessage { + message Sent { + message UnidentifiedDeliveryStatus { + optional string destination = 1; + optional string destinationUuid = 3; + optional bool unidentified = 2; + } + + optional string destination = 1; + optional string destinationUuid = 7; + optional uint64 timestamp = 2; + optional DataMessage message = 3; + optional uint64 expirationStartTimestamp = 4; + repeated UnidentifiedDeliveryStatus unidentifiedStatus = 5; + optional bool isRecipientUpdate = 6 [default = false]; + } + + message Contacts { + optional AttachmentPointer blob = 1; + optional bool complete = 2 [default = false]; + } + + message Groups { + optional AttachmentPointer blob = 1; + } + + message Blocked { + repeated string numbers = 1; + repeated string uuids = 3; + repeated bytes groupIds = 2; + } + + message Request { + enum Type { + UNKNOWN = 0; + CONTACTS = 1; + GROUPS = 2; + BLOCKED = 3; + CONFIGURATION = 4; + KEYS = 5; + } + + optional Type type = 1; + } + + message Keys { + optional bytes storageService = 1; + } + + message Read { + optional string sender = 1; + optional string senderUuid = 3; + optional uint64 timestamp = 2; + } + + message Viewed { + optional string senderE164 = 1; + optional string senderUuid = 3; + optional uint64 timestamp = 2; + } + + message Configuration { + optional bool readReceipts = 1; + optional bool unidentifiedDeliveryIndicators = 2; + optional bool typingIndicators = 3; + reserved 4; + optional uint32 provisioningVersion = 5; + optional bool linkPreviews = 6; + } + + message StickerPackOperation { + enum Type { + INSTALL = 0; + REMOVE = 1; + } + + optional bytes packId = 1; + optional bytes packKey = 2; + optional Type type = 3; + } + + message ViewOnceOpen { + optional string sender = 1; + optional string senderUuid = 3; + optional uint64 timestamp = 2; + } + + message MessageRequestResponse { + enum Type { + UNKNOWN = 0; + ACCEPT = 1; + DELETE = 2; + BLOCK = 3; + BLOCK_AND_DELETE = 4; + } + + optional string threadE164 = 1; + optional string threadUuid = 2; + optional bytes groupId = 3; + optional Type type = 4; + } + + message FetchLatest { + enum Type { + UNKNOWN = 0; + LOCAL_PROFILE = 1; + STORAGE_MANIFEST = 2; + SUBSCRIPTION_STATUS = 3; + } + + optional Type type = 1; + } + + optional Sent sent = 1; + optional Contacts contacts = 2; + optional Groups groups = 3; + optional Request request = 4; + repeated Read read = 5; + optional Blocked blocked = 6; + optional Verified verified = 7; + optional Configuration configuration = 9; + optional bytes padding = 8; + repeated StickerPackOperation stickerPackOperation = 10; + optional ViewOnceOpen viewOnceOpen = 11; + optional FetchLatest fetchLatest = 12; + optional Keys keys = 13; + optional MessageRequestResponse messageRequestResponse = 14; + reserved 15; // not yet added + repeated Viewed viewed = 16; +} + +message AttachmentPointer { + enum Flags { + VOICE_MESSAGE = 1; + BORDERLESS = 2; + // Our parser does not handle reserved in enums: DESKTOP-1569 + // reserved 4; + GIF = 8; + } + + oneof attachment_identifier { + fixed64 cdnId = 1; + string cdnKey = 15; + } + optional string contentType = 2; + optional bytes key = 3; + optional uint32 size = 4; + optional bytes thumbnail = 5; + optional bytes digest = 6; + optional string fileName = 7; + optional uint32 flags = 8; + optional uint32 width = 9; + optional uint32 height = 10; + optional string caption = 11; + optional string blurHash = 12; + optional uint64 uploadTimestamp = 13; + optional uint32 cdnNumber = 14; + // Next ID: 16 +} + +message GroupContext { + enum Type { + UNKNOWN = 0; + UPDATE = 1; + DELIVER = 2; + QUIT = 3; + REQUEST_INFO = 4; + } + + optional bytes id = 1; + optional Type type = 2; + optional string name = 3; + repeated string membersE164 = 4; + // field 6 was removed; do not use + optional AttachmentPointer avatar = 5; +} + +message GroupContextV2 { + optional bytes masterKey = 1; + optional uint32 revision = 2; + optional bytes groupChange = 3; +} + +message ContactDetails { + message Avatar { + optional string contentType = 1; + optional uint32 length = 2; + } + + optional string number = 1; + optional string uuid = 9; + optional string name = 2; + optional Avatar avatar = 3; + optional string color = 4; + optional Verified verified = 5; + optional bytes profileKey = 6; + optional bool blocked = 7; + optional uint32 expireTimer = 8; + optional uint32 inboxPosition = 10; +} + +message GroupDetails { + message Avatar { + optional string contentType = 1; + optional uint32 length = 2; + } + + message Member { + optional string uuid = 1; + optional string e164 = 2; + } + + optional bytes id = 1; + optional string name = 2; + repeated string membersE164 = 3; + repeated Member members = 9; + optional Avatar avatar = 4; + optional bool active = 5 [default = true]; + optional uint32 expireTimer = 6; + optional string color = 7; + optional bool blocked = 8; + optional uint32 inboxPosition = 10; +} diff --git a/protos/SignalStorage.proto b/protos/SignalStorage.proto new file mode 100644 index 0000000..e270b28 --- /dev/null +++ b/protos/SignalStorage.proto @@ -0,0 +1,144 @@ +// Copyright 2020-2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +package signalservice; + +option java_package = "org.whispersystems.signalservice.internal.storage"; +option java_outer_classname = "SignalStorageProtos"; + +message StorageManifest { + optional uint64 version = 1; + optional bytes value = 2; +} + +message StorageItem { + optional bytes key = 1; + optional bytes value = 2; +} + +message StorageItems { + repeated StorageItem items = 1; +} + +message ReadOperation { + repeated bytes readKey = 1; +} + +message WriteOperation { + optional StorageManifest manifest = 1; + repeated StorageItem insertItem = 2; + repeated bytes deleteKey = 3; + optional bool clearAll = 4; +} + +message ManifestRecord { + message Identifier { + enum Type { + UNKNOWN = 0; + CONTACT = 1; + GROUPV1 = 2; + GROUPV2 = 3; + ACCOUNT = 4; + } + + optional bytes raw = 1; + optional Type type = 2; + } + + optional uint64 version = 1; + repeated Identifier keys = 2; +} + +message StorageRecord { + oneof record { + ContactRecord contact = 1; + GroupV1Record groupV1 = 2; + GroupV2Record groupV2 = 3; + AccountRecord account = 4; + } +} + +message ContactRecord { + enum IdentityState { + DEFAULT = 0; + VERIFIED = 1; + UNVERIFIED = 2; + } + + optional string serviceUuid = 1; + optional string serviceE164 = 2; + optional bytes profileKey = 3; + optional bytes identityKey = 4; + optional IdentityState identityState = 5; + optional string givenName = 6; + optional string familyName = 7; + optional string username = 8; + optional bool blocked = 9; + optional bool whitelisted = 10; + optional bool archived = 11; + optional bool markedUnread = 12; + optional uint64 mutedUntilTimestamp = 13; +} + +message GroupV1Record { + optional bytes id = 1; + optional bool blocked = 2; + optional bool whitelisted = 3; + optional bool archived = 4; + optional bool markedUnread = 5; + optional uint64 mutedUntilTimestamp = 6; +} + +message GroupV2Record { + optional bytes masterKey = 1; + optional bool blocked = 2; + optional bool whitelisted = 3; + optional bool archived = 4; + optional bool markedUnread = 5; + optional uint64 mutedUntilTimestamp = 6; + optional bool dontNotifyForMentionsIfMuted = 7; +} + +message AccountRecord { + enum PhoneNumberSharingMode { + EVERYBODY = 0; + CONTACTS_ONLY = 1; + NOBODY = 2; + } + + message PinnedConversation { + message Contact { + optional string uuid = 1; + optional string e164 = 2; + } + + oneof identifier { + Contact contact = 1; + bytes legacyGroupId = 3; + bytes groupMasterKey = 4; + } + } + + optional bytes profileKey = 1; + optional string givenName = 2; + optional string familyName = 3; + optional string avatarUrl = 4; + optional bool noteToSelfArchived = 5; + optional bool readReceipts = 6; + optional bool sealedSenderIndicators = 7; + optional bool typingIndicators = 8; + optional bool proxiedLinkPreviews = 9; + optional bool noteToSelfMarkedUnread = 10; + optional bool linkPreviews = 11; + optional PhoneNumberSharingMode phoneNumberSharingMode = 12; + optional bool notDiscoverableByPhoneNumber = 13; + repeated PinnedConversation pinnedConversations = 14; + optional bool preferContactAvatars = 15; + optional uint32 universalExpireTimer = 17; + optional bool primarySendsSms = 18; + optional string e164 = 19; + repeated string preferredReactionEmoji = 20; + optional bytes subscriberId = 21; + optional string subscriberCurrencyCode = 22; + optional bool displayBadgesOnProfile = 23; +} diff --git a/protos/Stickers.proto b/protos/Stickers.proto new file mode 100644 index 0000000..3c66bd0 --- /dev/null +++ b/protos/Stickers.proto @@ -0,0 +1,16 @@ +// Copyright 2019-2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +package signalservice; + +message StickerPack { + message Sticker { + optional uint32 id = 1; + optional string emoji = 2; + } + + optional string title = 1; + optional string author = 2; + optional Sticker cover = 3; + repeated Sticker stickers = 4; +} diff --git a/protos/SubProtocol.proto b/protos/SubProtocol.proto new file mode 100644 index 0000000..98641ee --- /dev/null +++ b/protos/SubProtocol.proto @@ -0,0 +1,34 @@ +// Copyright 2014-2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +package signalservice; + +option java_package = "org.whispersystems.websocket.messages.protobuf"; + +message WebSocketRequestMessage { + optional string verb = 1; + optional string path = 2; + optional bytes body = 3; + repeated string headers = 5; + optional uint64 id = 4; +} + +message WebSocketResponseMessage { + optional uint64 id = 1; + optional uint32 status = 2; + optional string message = 3; + repeated string headers = 5; + optional bytes body = 4; +} + +message WebSocketMessage { + enum Type { + UNKNOWN = 0; + REQUEST = 1; + RESPONSE = 2; + } + + optional Type type = 1; + optional WebSocketRequestMessage request = 2; + optional WebSocketResponseMessage response = 3; +} diff --git a/protos/UnidentifiedDelivery.proto b/protos/UnidentifiedDelivery.proto new file mode 100644 index 0000000..1364eee --- /dev/null +++ b/protos/UnidentifiedDelivery.proto @@ -0,0 +1,69 @@ +// Copyright 2018-2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +package signalservice; + +option java_package = "org.whispersystems.libsignal.protocol"; +option java_outer_classname = "WhisperProtos"; + +message ServerCertificate { + message Certificate { + optional uint32 id = 1; + optional bytes key = 2; + } + + optional bytes certificate = 1; + optional bytes signature = 2; +} + +message SenderCertificate { + message Certificate { + optional string senderE164 = 1; + optional string senderUuid = 6; + optional uint32 senderDevice = 2; + optional fixed64 expires = 3; + optional bytes identityKey = 4; + optional ServerCertificate signer = 5; + } + + optional bytes certificate = 1; + optional bytes signature = 2; +} + +message UnidentifiedSenderMessage { + + message Message { + enum Type { + PREKEY_MESSAGE = 1; + MESSAGE = 2; + // Further cases should line up with Envelope.Type, even though old cases don't. + + // Our parser does not handle reserved in enums: DESKTOP-1569 + // reserved 3 to 6; + + SENDERKEY_MESSAGE = 7; + PLAINTEXT_CONTENT = 8; + } + + enum ContentHint { + // Show an error immediately; it was important but we can't retry. + DEFAULT = 0; + + // Sender will try to resend; delay any error UI if possible + RESENDABLE = 1; + + // Don't show any error UI at all; this is something sent implicitly like a typing message or a receipt + IMPLICIT = 2; + } + + optional Type type = 1; + optional SenderCertificate senderCertificate = 2; + optional bytes content = 3; + optional ContentHint contentHint = 4; + optional bytes groupId = 5; + } + + optional bytes ephemeralPublic = 1; + optional bytes encryptedStatic = 2; + optional bytes encryptedMessage = 3; +} diff --git a/src/api/group.ts b/src/api/group.ts new file mode 100644 index 0000000..1162acd --- /dev/null +++ b/src/api/group.ts @@ -0,0 +1,101 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { + ClientZkGroupCipher, + GroupSecretParams, + ProfileKey, + ProfileKeyCredentialPresentation, +} from '@signalapp/signal-client/zkgroup'; + +import { signalservice as Proto } from '../../protos/compiled'; +import { UUID } from '../types'; +import { Group as GroupData } from '../data/group'; + +const AccessRequired = Proto.AccessControl.AccessRequired; + +export type GroupMember = Readonly<{ + presentation: ProfileKeyCredentialPresentation; + profileKey: ProfileKey; + uuid: UUID; +}>; + +export type GroupOptions = Readonly<{ + secretParams: GroupSecretParams; + title: string; + members: ReadonlyArray; +}>; + +export class Group extends GroupData { + private privRevision = 0; + private readonly secretParams: GroupSecretParams; + private readonly cipher: ClientZkGroupCipher; + + public readonly title: string; + + constructor({ secretParams, title, members }: GroupOptions) { + super(); + + this.secretParams = secretParams; + this.cipher = new ClientZkGroupCipher(this.secretParams); + this.title = title; + + this.privPublicParams = this.secretParams.getPublicParams(); + + // Build group log + + this.privChanges = { + groupChanges: [ { + groupState: { + publicKey: this.publicParams.serialize(), + version: this.revision, + title: this.encryptBlob({ title }), + + // TODO(indutny): make it configurable + accessControl: { + attributes: AccessRequired.MEMBER, + members: AccessRequired.MEMBER, + addFromInviteLink: AccessRequired.UNSATISFIABLE, + }, + + members: members.map(({ uuid, profileKey, presentation }) => { + return { + role: Proto.Member.Role.ADMINISTRATOR, + userId: this.cipher.encryptUuid(uuid).serialize(), + profileKey: this.cipher.encryptProfileKey(profileKey, uuid) + .serialize(), + presentation: presentation.serialize(), + }; + }), + }, + } ], + }; + } + + public get revision(): number { + return this.privRevision; + } + + public get masterKey(): Buffer { + return this.secretParams.getMasterKey().serialize(); + } + + public toContext(): Proto.IGroupContextV2 { + const masterKey = this.masterKey; + return { + masterKey, + revision: this.revision, + }; + } + + // + // Private + // + + private encryptBlob( + proto: Proto.IGroupAttributeBlob, + ): Buffer { + const plaintext = Proto.GroupAttributeBlob.encode(proto).finish(); + return this.cipher.encryptBlob(Buffer.from(plaintext)); + } +} diff --git a/src/api/primary-device.ts b/src/api/primary-device.ts new file mode 100644 index 0000000..60b50cd --- /dev/null +++ b/src/api/primary-device.ts @@ -0,0 +1,1098 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import assert from 'assert'; +import crypto from 'crypto'; +import Long from 'long'; +import { + CiphertextMessageType, + IdentityKeyStore, + PreKeyBundle, + PreKeyRecord, + PreKeySignalMessage, + PreKeyStore as PreKeyStoreBase, + PrivateKey, + ProtocolAddress, + PublicKey, + SenderCertificate, + SenderKeyDistributionMessage, + SenderKeyRecord, + SenderKeyStore as SenderKeyStoreBase, + SessionRecord, + SessionStore as SessionStoreBase, + SignalMessage, + SignedPreKeyRecord, + SignedPreKeyStore as SignedPreKeyStoreBase, + Uuid, +} from '@signalapp/signal-client'; +import * as SignalClient from '@signalapp/signal-client'; +import createDebug from 'debug'; +import { + ClientZkProfileOperations, + GroupSecretParams, + ProfileKey, + ProfileKeyCredentialRequest, + ProfileKeyCredentialResponse, + ServerPublicParams, +} from '@signalapp/signal-client/zkgroup'; + +import { signalservice as Proto } from '../../protos/compiled'; +import { INITIAL_PREKEY_COUNT } from '../constants'; +import { DeviceId, UUID } from '../types'; +import { Contact } from '../data/contacts'; +import { + decryptStorageItem, + decryptStorageManifest, + deriveAccessKey, + encryptProfileName, +} from '../crypto'; +import { EnvelopeType, StorageWriteResult } from '../server/base'; +import { ServerGroup } from '../server/group'; +import { Device, DeviceKeys, SingleUseKey } from '../data/device'; +import { PromiseQueue, addressToString } from '../util'; +import { Group } from './group'; +import { StorageState } from './storage-state'; + +const debug = createDebug('mock:primary-device'); + +export type Config = Readonly<{ + profileName: string; + contacts: Proto.IAttachmentPointer; + groups: Proto.IAttachmentPointer; + trustRoot: PublicKey; + serverPublicParams: ServerPublicParams; + + // Server callbacks + send(device: Device, message: Buffer): Promise; + getSenderCertificate(): Promise; + getDeviceByUUID( + uuid: UUID, + deviceId?: DeviceId, + ): Promise; + issueProfileKeyCredential( + device: Device, + request: ProfileKeyCredentialRequest, + ): Promise; + + createGroup(group: Proto.IGroup): Promise; + + getStorageManifest(): Promise; + getStorageItem(key: Buffer): Promise; + waitForStorageManifest(afterVersion?: number): Promise; + applyStorageWrite( + operation: Proto.IWriteOperation, + shouldNotify?: boolean, + ): Promise; +}>; + +export type EncryptOptions = Readonly<{ + timestamp?: number; + sealed?: boolean; +}>; + +export type EncryptTextOptions = EncryptOptions & Readonly<{ + group?: Group; + withProfileKey?: boolean; +}>; + +export type CreateGroupOptions = Readonly<{ + title: string; + members: ReadonlyArray; +}>; + +export type SyncSentOptions = Readonly<{ + timestamp: number; + destinationUUID: UUID; +}>; + +export type FetchStorageOptions = Readonly<{ + timestamp: number; +}>; + +export type SyncReadMessage = Readonly<{ + senderUUID: UUID; + timestamp: number; +}>; + +export type SyncReadOptions = Readonly<{ + timestamp?: number; + messages: ReadonlyArray; +}>; + +export enum ReceiptType { + Delivery = 'Delivery', + Read = 'Read', +} + +export type ReceiptOptions = Readonly<{ + timestamp?: number; + + type: ReceiptType; + messageTimestamps: ReadonlyArray; +}>; + +export type MessageQueueEntry = Readonly<{ + source: Device; + envelopeType: EnvelopeType; + body: string; + dataMessage: Proto.IDataMessage; +}>; + +enum SyncState { + Empty = 0, + Contacts = 1 << 0, + Groups = 1 << 1, + Blocked = 1 << 2, + Configuration = 1 << 3, + Keys = 1 << 4, + + Complete = Contacts | Groups | Blocked | Configuration | Keys, +} + +type SyncEntry = { + state: SyncState; + onComplete: Promise; + complete(): void; +}; + +type DecryptResult = Readonly<{ + unsealedSource: Device; + content: Proto.IContent; + envelopeType: EnvelopeType; +}>; + +class SignedPreKeyStore extends SignedPreKeyStoreBase { + private readonly records = new Map(); + + async saveSignedPreKey( + id: number, + record: SignedPreKeyRecord, + ): Promise { + this.records.set(id, record); + } + + async getSignedPreKey(id: number): Promise { + const result = this.records.get(id); + if (!result) { + throw new Error(`Signed pre key not found: ${id}`); + } + return result; + } +} + +class PreKeyStore extends PreKeyStoreBase { + private readonly records = new Map(); + + async savePreKey(id: number, record: PreKeyRecord): Promise { + this.records.set(id, record); + } + + async getPreKey(id: number): Promise { + const record = this.records.get(id); + if (!record) { + throw new Error(`Pre key not found: ${id}`); + } + return record; + } + + async removePreKey(id: number): Promise { + this.records.delete(id); + } +} + +class IdentityStore extends IdentityKeyStore { + private knownIdentities = new Map(); + + constructor( + private readonly privateKey: PrivateKey, + private readonly registrationId: number, + ) { + super(); + + this.privateKey = privateKey; + this.registrationId = registrationId; + } + + async getIdentityKey(): Promise { + return this.privateKey; + } + + async getLocalRegistrationId(): Promise { + if (this.registrationId === undefined) { + throw new Error('Registration id not yet set'); + } + + return this.registrationId; + } + + async saveIdentity( + name: ProtocolAddress, + key: PublicKey, + ): Promise { + this.knownIdentities.set(addressToString(name), key); + return true; + } + + async isTrustedIdentity(): Promise { + // We trust everyone + return true; + } + + async getIdentity(name: ProtocolAddress): Promise { + return this.knownIdentities.get(addressToString(name)) || + null; + } +} + +export class SessionStore extends SessionStoreBase { + private readonly sessions: Map = new Map(); + + async saveSession( + name: ProtocolAddress, + record: SessionRecord, + ): Promise { + this.sessions.set(addressToString(name), record); + } + + async getSession(name: ProtocolAddress): Promise { + return this.sessions.get(addressToString(name)) || null; + } + + async getExistingSessions( + addresses: ProtocolAddress[], + ): Promise { + return addresses.map((name) => { + const existing = this.sessions.get(addressToString(name)); + if (!existing) { + throw new Error('Existing session not found'); + } + return existing; + }); + } +} + +export class SenderKeyStore extends SenderKeyStoreBase { + private readonly keys: Map = new Map(); + + async saveSenderKey( + sender: ProtocolAddress, + distributionId: Uuid, + record: SenderKeyRecord, + ): Promise { + this.keys.set(`${addressToString(sender)}.${distributionId}`, record); + } + async getSenderKey( + sender: ProtocolAddress, + distributionId: Uuid, + ): Promise { + const key = this.keys.get(`${addressToString(sender)}.${distributionId}`); + return key || null; + } +} + +export class PrimaryDevice { + private isInitialized = false; + private lockPromise: Promise | undefined; + + private readonly syncStates = new WeakMap(); + private readonly storageKey = crypto.randomBytes(16); + private readonly privateKey = PrivateKey.generate(); + private readonly contactsBlob: Proto.IAttachmentPointer; + private readonly groupsBlob: Proto.IAttachmentPointer; + private privSenderCertificate: SenderCertificate | undefined; + private readonly messageQueue = new PromiseQueue(); + + // Various stores + private readonly signedPreKeys = new SignedPreKeyStore(); + private readonly preKeys = new PreKeyStore(); + private readonly sessions = new SessionStore(); + private readonly senderKeys = new SenderKeyStore(); + private readonly identity: IdentityStore; + + public readonly signedPreKeyId: number = 1; + public readonly publicKey = this.privateKey.getPublicKey(); + public readonly profileKey: ProfileKey; + public readonly profileName: string; + public readonly secondaryDevices = new Array(); + + // TODO(indutny): make primary device type configurable + public readonly userAgent = 'OWI'; + + constructor( + public readonly device: Device, + private readonly config: Config, + ) { + this.identity = new IdentityStore( + this.privateKey, this.device.registrationId); + + this.contactsBlob = this.config.contacts; + this.groupsBlob = this.config.groups; + this.profileName = config.profileName; + + this.profileKey = new ProfileKey(crypto.randomBytes(32)); + + this.device.profileName = encryptProfileName( + this.profileKey.serialize(), + this.profileName, + ); + } + + public async init(preKeyCount?: number): Promise { + if (this.isInitialized) { + throw new Error('Already initialized'); + } + + await this.identity.saveIdentity(this.device.address, this.publicKey); + + await this.device.setKeys(await this.generateKeys(this.device, preKeyCount)); + + this.privSenderCertificate = await this.config.getSenderCertificate(); + + this.device.profileKeyCommitment = this.profileKey.getCommitment( + this.device.uuid, + ); + this.device.accessKey = deriveAccessKey(this.profileKey.serialize()); + + this.isInitialized = true; + } + + public toContact(): Contact { + return { + uuid: this.device.uuid, + number: this.device.number, + profileName: this.profileName, + profileKey: this.profileKey.serialize(), + }; + } + + public addSecondaryDevice(device: Device): void { + this.secondaryDevices.push(device); + + device.profileName = this.device.profileName; + device.profileKeyCommitment = this.device.profileKeyCommitment; + device.accessKey = this.device.accessKey; + } + + // + // Keys + // + + public async generateKeys( + device: Device, + preKeyCount = INITIAL_PREKEY_COUNT, + ): Promise { + const signedPreKey = PrivateKey.generate(); + const signedPreKeySig = this.privateKey.sign( + signedPreKey.getPublicKey().serialize()); + + const shouldSave = device === this.device; + + const record = SignedPreKeyRecord.new( + this.signedPreKeyId, + Date.now(), + signedPreKey.getPublicKey(), + signedPreKey, + signedPreKeySig); + + if (shouldSave) { + await this.signedPreKeys.saveSignedPreKey( + this.signedPreKeyId, + record); + } + + // NOTE: it is important to start with `1` here + const preKeys: Array<{ keyId: number, publicKey: PublicKey }> = []; + for (let i = 1; i <= preKeyCount; i++) { + const preKey = PrivateKey.generate(); + const publicKey = preKey.getPublicKey(); + + const record = PreKeyRecord.new(i, publicKey, preKey); + if (shouldSave) { + await this.preKeys.savePreKey(i, record); + } + + preKeys.push({ keyId: i, publicKey }); + } + + return { + identityKey: this.publicKey, + signedPreKey: { + keyId: this.signedPreKeyId, + publicKey: signedPreKey.getPublicKey(), + signature: signedPreKeySig, + }, + preKeys, + }; + } + + public async getIdentityKey(): Promise { + return await this.identity.getIdentityKey(); + } + + public async addSingleUseKey( + target: Device, + key: SingleUseKey, + ): Promise { + assert.ok(this.isInitialized, 'Not initialized'); + debug('adding singleUseKey for', target.debugId); + + await this.identity.saveIdentity(target.address, key.identityKey); + + const bundle = PreKeyBundle.new( + target.registrationId, + target.deviceId, + key.preKey === undefined ? null : key.preKey.keyId, + key.preKey === undefined ? null : key.preKey.publicKey, + key.signedPreKey.keyId, + key.signedPreKey.publicKey, + key.signedPreKey.signature, + key.identityKey, + ); + await SignalClient.processPreKeyBundle( + bundle, + target.address, + this.sessions, + this.identity); + } + + // + // Groups + // + + public async createGroup( + { title, members: memberDevices }: CreateGroupOptions, + ): Promise { + const ops = new ClientZkProfileOperations( + this.config.serverPublicParams, + ); + + const groupParams = GroupSecretParams.generate(); + + const members = await Promise.all(memberDevices.map(async (member) => { + const { device, profileKey } = member; + const ctx = ops.createProfileKeyCredentialRequestContext( + device.uuid, + profileKey, + ); + const response = await this.config.issueProfileKeyCredential( + member.device, + ctx.getRequest(), + ); + assert.ok( + response, + `Member device ${device.uuid} not initialized`, + ); + + const credential = ops.receiveProfileKeyCredential(ctx, response); + + const presentation = ops.createProfileKeyCredentialPresentation( + groupParams, + credential, + ); + + return { + uuid: device.uuid, + profileKey, + presentation, + }; + })); + + const clientGroup = new Group({ + secretParams: groupParams, + + title, + members, + }); + + await this.config.createGroup(clientGroup.getState()); + + return clientGroup; + } + + // + // Storage Service + // + + public async waitForStorageState({ after }: { + after?: StorageState, + } = {}): Promise { + debug('waiting for storage manifest', this.device.debugId); + await this.config.waitForStorageManifest(after?.version); + + debug('got storage manifest', this.device.debugId); + + const state = await this.getStorageState(); + assert(state, 'Missing storage state'); + + return state; + } + + public async getStorageState(): Promise { + const manifest = await this.config.getStorageManifest(); + if (!manifest) { + return undefined; + } + + const decryptedManifest = decryptStorageManifest(this.storageKey, manifest); + assert(decryptedManifest.version, 'Consistency check'); + + const version = decryptedManifest.version.toNumber(); + const items = await Promise.all((decryptedManifest.keys || []).map( + async ({ type, raw: key }) => { + assert( + type !== null && type !== undefined, + 'Missing manifestRecord.keys.type', + ); + assert(key, 'Missing manifestRecord.keys.raw'); + + const keyBuffer = Buffer.from(key); + const item = await this.config.getStorageItem(keyBuffer); + if (!item) { + throw new Error(`Missing item ${keyBuffer.toString('base64')}`); + } + + return { + type, + key: keyBuffer, + record: decryptStorageItem(this.storageKey, { + key, + value: item, + }), + }; + }, + )); + + return new StorageState(version, items); + } + + public async expectStorageState(reason: string): Promise { + const state = await this.getStorageState(); + if (!state) { + throw new Error(`expectStorageState: no storage state, ${reason}`); + } + + return state; + } + + public async setStorageState(state: StorageState): Promise { + const writeOperation = state.createWriteOperation(this.storageKey); + await this.config.applyStorageWrite(writeOperation, false); + } + + // + // Sync + // + + // TODO(indutny): timeout + public async waitForSync(secondaryDevice: Device): Promise { + debug('waiting for sync with %s', secondaryDevice.debugId); + const { onComplete } = this.getSyncState(secondaryDevice); + + await onComplete; + } + + public resetSyncState(secondaryDevice: Device): void { + this.syncStates.delete(secondaryDevice); + } + + // + // Receive/Send + // + + public async handleEnvelope( + source: Device | undefined, + envelopeType: EnvelopeType, + encrypted: Buffer, + ): Promise { + const { unsealedSource, content, envelopeType: unsealedType } = + await this.lock(async () => { + return await this.decrypt(source, envelopeType, encrypted); + }); + + let handled = true; + if (content.syncMessage) { + await this.handleSync(unsealedSource, content.syncMessage); + } else if (content.dataMessage) { + await this.handleDataMessage( + unsealedSource, + unsealedType, + content.dataMessage, + ); + } else { + handled = false; + } + + const { senderKeyDistributionMessage } = content; + if (senderKeyDistributionMessage && + senderKeyDistributionMessage.length > 0) { + handled = true; + await this.processSenderKeyDistribution( + unsealedSource, + senderKeyDistributionMessage, + ); + } + + + if (!handled) { + debug('unsupported message', content); + } + } + + public async encryptText( + target: Device, + text: string, + options: EncryptTextOptions = {}, + ): Promise { + const encryptOptions = { + timestamp: Date.now(), + ...options, + }; + const content = { + dataMessage: { + groupV2: options.group?.toContext(), + body: text, + profileKey: options.withProfileKey ? + this.profileKey.serialize() : + undefined, + timestamp: Long.fromNumber(encryptOptions.timestamp), + }, + }; + return await this.encryptContent(target, content, encryptOptions); + } + + public async encryptSyncSent( + target: Device, + text: string, + options: SyncSentOptions, + ): Promise { + const dataMessage = { + body: text, + timestamp: Long.fromNumber(options.timestamp), + }; + + const content = { + syncMessage: { + sent: { + destinationUuid: options.destinationUUID, + timestamp: Long.fromNumber(options.timestamp), + message: dataMessage, + }, + }, + }; + return await this.encryptContent(target, content, options); + } + + public async encryptSyncRead( + target: Device, + options: SyncReadOptions, + ): Promise { + const content = { + syncMessage: { + read: options.messages.map(({ senderUUID, timestamp }) => { + return { + senderUuid: senderUUID, + timestamp: Long.fromNumber(timestamp), + }; + }), + }, + }; + return await this.encryptContent(target, content, options); + } + + public async sendFetchStorage( + options: FetchStorageOptions, + ): Promise { + const content = { + syncMessage: { + fetchLatest: { + type: Proto.SyncMessage.FetchLatest.Type.STORAGE_MANIFEST, + }, + }, + }; + + debug( + 'sending fetch storage to %d linked devices', + this.secondaryDevices.length, + ); + + await Promise.all( + this.secondaryDevices.map(async (device) => { + const envelope = await this.encryptContent(device, content, options); + + await this.config.send(device, envelope); + }), + ); + } + + public async encryptReceipt( + target: Device, + options: ReceiptOptions, + ): Promise { + let type: Proto.ReceiptMessage.Type; + if (options.type === ReceiptType.Delivery) { + type = Proto.ReceiptMessage.Type.DELIVERY; + } else { + assert.strictEqual(options.type, ReceiptType.Read); + type = Proto.ReceiptMessage.Type.READ; + } + + const content = { + receiptMessage: { + type, + timestamp: options.messageTimestamps.map( + (timestamp) => Long.fromNumber(timestamp), + ), + }, + }; + return await this.encryptContent(target, content, options); + } + + public async sendText( + target: Device, + text: string, + options?: EncryptTextOptions, + ): Promise { + await this.config.send( + target, + await this.encryptText(target, text, options)); + } + + public async receive(source: Device, encrypted: Buffer): Promise { + const envelope = Proto.Envelope.decode(encrypted); + + if (source.uuid !== envelope.sourceUuid) { + throw new Error(`Invalid envelope source. Expected: ${source.uuid}, ` + + `Got: ${envelope.sourceUuid}`); + } + + let envelopeType: EnvelopeType; + if (envelope.type === Proto.Envelope.Type.CIPHERTEXT) { + envelopeType = EnvelopeType.CipherText; + } else if (envelope.type === Proto.Envelope.Type.PREKEY_BUNDLE) { + envelopeType = EnvelopeType.PreKey; + } else if (envelope.type === Proto.Envelope.Type.UNIDENTIFIED_SENDER) { + envelopeType = EnvelopeType.SealedSender; + } else { + throw new Error('Unsupported envelope type'); + } + + return await this.handleEnvelope( + source, envelopeType, Buffer.from(envelope.content)); + } + + public async waitForMessage(): Promise { + return this.messageQueue.shift(); + } + + // + // Private + // + + private async encryptContent( + target: Device, + content: Proto.IContent, + options?: EncryptOptions, + ): Promise { + const encoded = Buffer.from(Proto.Content.encode(content).finish()); + + return await this.lock(async () => { + return await this.encrypt(target, encoded, options); + }); + } + + private getSyncState(secondaryDevice: Device): SyncEntry { + const existing = this.syncStates.get(secondaryDevice); + if (existing) { + return existing; + } + + let complete: (() => void) | undefined; + const onComplete = new Promise((resolve) => { + complete = resolve; + }); + + if (!complete) { + throw new Error('Failed to obtain resolve callback'); + } + + const entry = { + state: SyncState.Empty, + onComplete, + complete, + }; + this.syncStates.set(secondaryDevice, entry); + + return entry; + } + + private async handleSync( + source: Device, + sync: Proto.ISyncMessage, + ): Promise { + const { request } = sync; + if (!request) { + debug('ignoring sync responses'); + return; + } + + let stateChange: SyncState; + let response: Proto.ISyncMessage; + if (request.type === Proto.SyncMessage.Request.Type.CONTACTS) { + debug('got sync contacts request'); + response = { + contacts: { + blob: this.contactsBlob, + complete: true, + }, + }; + stateChange = SyncState.Contacts; + } else if (request.type === Proto.SyncMessage.Request.Type.GROUPS) { + debug('got sync groups request'); + response = { + groups: { + blob: this.groupsBlob, + }, + }; + stateChange = SyncState.Groups; + } else if (request.type === Proto.SyncMessage.Request.Type.BLOCKED) { + debug('got sync blocked request'); + response = { + blocked: {}, + }; + stateChange = SyncState.Blocked; + } else if (request.type === Proto.SyncMessage.Request.Type.CONFIGURATION) { + debug('got sync configuration request'); + response = { + configuration: { + readReceipts: true, + unidentifiedDeliveryIndicators: false, + typingIndicators: false, + linkPreviews: false, + }, + }; + stateChange = SyncState.Configuration; + } else if (request.type === Proto.SyncMessage.Request.Type.KEYS) { + debug('got sync keys request'); + response = { + keys: { storageService: this.storageKey }, + }; + stateChange = SyncState.Keys; + } else { + debug('Unsupported sync request', request); + return; + } + + const encrypted = await this.encryptContent(source, { + syncMessage: response, + }); + await this.config.send(source, encrypted); + + const syncEntry = this.getSyncState(source); + syncEntry.state |= stateChange; + + if (syncEntry.state === SyncState.Complete) { + debug('sync with %s complete', source.debugId); + syncEntry.complete(); + } + } + + private async handleDataMessage( + source: Device, + envelopeType: EnvelopeType, + dataMessage: Proto.IDataMessage, + ): Promise { + const { body } = dataMessage; + this.messageQueue.push({ + source, + body: body ?? '', + envelopeType, + dataMessage, + }); + } + + private async encrypt( + target: Device, + message: Buffer, + { timestamp = Date.now(), sealed = false }: EncryptOptions = {}, + ): Promise { + assert.ok(this.isInitialized, 'Not initialized'); + + // "Pad" + const paddedMessage = Buffer.concat([ + message, + Buffer.from([ 0x80 ]), + ]); + + let envelopeType: Proto.Envelope.Type; + let content: Buffer; + + if (sealed) { + content = await SignalClient.sealedSenderEncryptMessage( + paddedMessage, + target.address, + this.senderCertificate, + this.sessions, + this.identity); + + envelopeType = Proto.Envelope.Type.UNIDENTIFIED_SENDER; + } else { + const ciphertext = await SignalClient.signalEncrypt( + paddedMessage, + target.address, + this.sessions, + this.identity); + content = ciphertext.serialize(); + + if (ciphertext.type() === CiphertextMessageType.Whisper) { + envelopeType = Proto.Envelope.Type.CIPHERTEXT; + debug('encrypting ciphertext envelope'); + } else { + assert.strictEqual(ciphertext.type(), CiphertextMessageType.PreKey); + envelopeType = Proto.Envelope.Type.PREKEY_BUNDLE; + debug('encrypting prekeyBundle envelope'); + } + } + + const envelope = Buffer.from(Proto.Envelope.encode({ + type: envelopeType, + sourceUuid: this.device.uuid, + sourceDevice: this.device.deviceId, + serverTimestamp: Long.fromNumber(timestamp), + destinationUuid: target.uuid, + timestamp: Long.fromNumber(timestamp), + content, + }).finish()); + + debug('encrypting envelope finish'); + + return envelope; + } + + private async decrypt( + source: Device | undefined, + envelopeType: EnvelopeType, + encrypted: Buffer, + ): Promise { + debug('decrypting envelope type=%s start', envelopeType); + + let decrypted: Buffer; + if (envelopeType === EnvelopeType.CipherText) { + assert(source !== undefined, 'CipherText must have source'); + + decrypted = await SignalClient.signalDecrypt( + SignalMessage.deserialize(encrypted), + source.address, + this.sessions, + this.identity); + } else if (envelopeType === EnvelopeType.PreKey) { + assert(source !== undefined, 'PreKey must have source'); + + decrypted = await SignalClient.signalDecryptPreKey( + PreKeySignalMessage.deserialize(encrypted), + source.address, + this.sessions, + this.identity, + this.preKeys, + this.signedPreKeys); + } else if (envelopeType === EnvelopeType.SenderKey) { + assert(source !== undefined, 'SenderKey must have source'); + + decrypted = await SignalClient.groupDecrypt( + source.address, + this.senderKeys, + encrypted, + ); + } else if (envelopeType === EnvelopeType.SealedSender) { + assert(source === undefined, 'Sealed sender must have no source'); + + const usmc = + await SignalClient.sealedSenderDecryptToUsmc(encrypted, this.identity); + + const unsealedType = usmc.msgType(); + const certificate = usmc.senderCertificate(); + + const sender = await this.config.getDeviceByUUID( + certificate.senderUuid(), + certificate.senderDeviceId()); + assert(sender !== undefined, 'Unsealed sender not found'); + + let subType: EnvelopeType; + switch (unsealedType) { + case CiphertextMessageType.PreKey: + subType = EnvelopeType.PreKey; + break; + case CiphertextMessageType.Whisper: + subType = EnvelopeType.CipherText; + break; + case CiphertextMessageType.SenderKey: + subType = EnvelopeType.SenderKey; + break; + default: + throw new Error(`Unsupported usmc type: ${unsealedType}`); + } + + // TODO(indutny): use sealedSenderDecryptMessage once it will support + // sender key. + return this.decrypt( + sender, + subType, + usmc.contents(), + ); + } else { + throw new Error(`Unsupported envelope type: ${envelopeType}`); + } + + // Remove padding + let padding = 1; + while (decrypted[decrypted.length - padding] !== 0x80) { + assert.strictEqual(decrypted[decrypted.length - padding], 0); + padding++; + } + + const content = Proto.Content.decode(decrypted.slice(0, -padding)); + debug('decrypting envelope type=%s finish', envelopeType); + return { unsealedSource: source, content, envelopeType }; + } + + private async lock(callback: () => Promise): Promise { + while (this.lockPromise) { + await this.lockPromise; + } + + let unlock: (() => void) | undefined; + this.lockPromise = new Promise((resolve) => { + unlock = resolve; + }); + + try { + return await callback(); + } finally { + this.lockPromise = undefined; + assert.ok(unlock); + unlock(); + } + } + + private get senderCertificate(): SenderCertificate { + if (!this.privSenderCertificate) { + throw new Error('Sender certificate not set'); + } + return this.privSenderCertificate; + } + + private async processSenderKeyDistribution( + source: Device, + rawMessage: Uint8Array, + ): Promise { + const message = SenderKeyDistributionMessage.deserialize( + Buffer.from(rawMessage), + ); + + debug('received SKDM from', source.debugId); + await SignalClient.processSenderKeyDistributionMessage( + source.address, + message, + this.senderKeys, + ); + } +} diff --git a/src/api/server.ts b/src/api/server.ts new file mode 100644 index 0000000..308769c --- /dev/null +++ b/src/api/server.ts @@ -0,0 +1,487 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import assert from 'assert'; +import fs from 'fs'; +import Long from 'long'; +import path from 'path'; +import https, { ServerOptions } from 'https'; +import { AddressInfo } from 'net'; +import { parse as parseURL } from 'url'; +import { + PrivateKey, + PublicKey, +} from '@signalapp/signal-client'; +import { ServerSecretParams } from '@signalapp/signal-client/zkgroup'; +import createDebug from 'debug'; +import WebSocket from 'ws'; +import { run } from 'micro'; + +import { + attachmentToPointer, +} from '../data/attachment'; +import { + PRIMARY_DEVICE_ID, +} from '../constants'; +import { + ProvisioningCode, + RegistrationId, + UUID, +} from '../types'; +import { + serializeContacts, +} from '../data/contacts'; +import { + encryptAttachment, + encryptProvisionMessage, + generateServerCertificate, +} from '../crypto'; +import { signalservice as Proto } from '../../protos/compiled'; +import { + Server as BaseServer, + EnvelopeType, + ProvisioningResponse, +} from '../server/base'; +import { Device, DeviceKeys } from '../data/device'; +import { PromiseQueue, generateRandomE164 } from '../util'; + +import { createHandler as createHTTPHandler } from '../server/http'; +import { Connection as WSConnection } from '../server/ws'; + +import { PrimaryDevice } from './primary-device'; + +type TrustRoot = { + readonly privateKey: string; + readonly publicKey: string; +}; + +type ZKParams = { + readonly secretParams: string; + readonly publicParams: string; +}; + +interface StrictConfig { + readonly trustRoot: TrustRoot; + readonly zkParams: ZKParams; + readonly https: ServerOptions; + readonly timeout: number; +} + +export interface Config { + readonly trustRoot?: TrustRoot; + readonly zkParams?: ZKParams; + readonly https?: ServerOptions; + readonly timeout?: number; +} + +export interface CreatePrimaryDeviceOptions { + readonly profileName: string; + readonly contacts?: ReadonlyArray; +} + +export type PendingProvision = { + complete(response: PendingProvisionResponse): Promise; +} + +export type PendingProvisionResponse = { + readonly provisionURL: string; + readonly primaryDevice: PrimaryDevice; +} + +const debug = createDebug('mock:server:mock'); + +const CERTS_DIR = path.join(__dirname, '..', '..', 'certs'); + +const CERT = fs.readFileSync(path.join(CERTS_DIR, 'full-cert.pem')); +const KEY = fs.readFileSync(path.join(CERTS_DIR, 'key.pem')); +const TRUST_ROOT: TrustRoot = JSON.parse( + fs.readFileSync(path.join(CERTS_DIR, 'trust-root.json')).toString(), +); +const ZK_PARAMS: ZKParams = JSON.parse( + fs.readFileSync(path.join(CERTS_DIR, 'zk-params.json')).toString(), +); + +const DEFAULT_API_TIMEOUT = 60000; + +export class Server extends BaseServer { + private readonly config: StrictConfig; + + private readonly trustRoot: PrivateKey; + private readonly primaryDevices = new Map(); + private readonly knownNumbers = new Set(); + private https: https.Server | undefined; + private emptyAttachment: Proto.IAttachmentPointer | undefined; + + private provisionQueue: PromiseQueue; + private provisionResultQueueByCode = + new Map>(); + private provisionResultQueueByKey = new Map>(); + private manifestQueueByUuid = new Map>(); + + constructor(config: Config = {}) { + super(); + + this.config = { + timeout: DEFAULT_API_TIMEOUT, + trustRoot: TRUST_ROOT, + zkParams: ZK_PARAMS, + ...config, + + https: { + key: KEY, + cert: CERT, + ...(config.https || {}), + }, + }; + + const trustPrivate = Buffer.from( + this.config.trustRoot.privateKey, 'base64'); + this.trustRoot = PrivateKey.deserialize(trustPrivate); + + const zkSecret = Buffer.from( + this.config.zkParams.secretParams, 'base64'); + this.zkSecret = new ServerSecretParams(zkSecret); + + this.certificate = generateServerCertificate(this.trustRoot); + + this.provisionQueue = this.createQueue(); + } + + public async listen(port: number, host?: string): Promise { + if (this.https) { + throw new Error('Already listening'); + } + + const emptyData = encryptAttachment(Buffer.alloc(0)); + const emptyCDNKey = await this.storeAttachment(emptyData.blob); + + this.emptyAttachment = attachmentToPointer( + emptyCDNKey, + emptyData); + + const httpHandler = createHTTPHandler(this); + + const server = https.createServer(this.config.https || {}, (req, res) => { + run(req, res, httpHandler); + }); + + const wss = new WebSocket.Server({ server }); + + wss.on('connection', (ws, request) => { + const conn = new WSConnection(request, ws, this); + + conn.start().catch((error) => { + ws.close(); + debug('Websocket handling error', error.stack); + }); + }); + + this.https = server; + + return await new Promise((resolve) => { + server.listen(port, host, () => resolve()); + }); + } + + public async close(): Promise { + const https = this.https; + if (!https) { + throw new Error('Not listening'); + } + + debug('closing server'); + + await new Promise((resolve) => https.close(resolve)); + } + + public address(): AddressInfo { + if (!this.https) { + throw new Error('Not listening'); + } + + const result = this.https.address(); + if (!result || typeof result !== 'object' ){ + throw new Error('Invalid .address() result'); + } + return result; + } + + // + // Various queues + // + + public async waitForProvision(): Promise { + return await this.provisionQueue.shift(); + } + + private async waitForStorageManifest( + device: Device, + afterVersion?: number, + ): Promise { + let queue = this.manifestQueueByUuid.get(device.uuid); + if (!queue) { + queue = this.createQueue(); + this.manifestQueueByUuid.set(device.uuid, queue); + } + + let version: number; + do { + version = await queue.shift(); + } while (afterVersion !== undefined && version <= afterVersion); + } + + // + // Helper methods + // + + public async createPrimaryDevice({ + profileName, + contacts = [], + }: CreatePrimaryDeviceOptions): Promise { + const number = await this.generateNumber(); + + const uuid = await this.generateUUID(); + const pni = await this.generateUUID(); + const registrationId = await this.generateRegistrationId(); + const device = await this.registerDevice({ + uuid, + pni, + number, + registrationId, + }); + + debug('creating primary device with uuid=%s registrationId=%d', + uuid, registrationId); + + if (!this.emptyAttachment) { + throw new Error('Mock#init must be called before starting the server'); + } + + const contactsAttachment = encryptAttachment( + serializeContacts(contacts.map((device: PrimaryDevice) => { + return device.toContact(); + }))); + const contactsCDNKey = await this.storeAttachment(contactsAttachment.blob); + debug('contacts cdn key', contactsCDNKey); + debug('groups cdn key', this.emptyAttachment.cdnKey); + + const primary = new PrimaryDevice(device, { + profileName: profileName, + contacts: attachmentToPointer(contactsCDNKey, contactsAttachment), + groups: this.emptyAttachment, + trustRoot: this.trustRoot.getPublicKey(), + serverPublicParams: this.zkSecret.getPublicParams(), + + send: this.send.bind(this), + getSenderCertificate: this.getSenderCertificate.bind(this, device), + getDeviceByUUID: this.getDeviceByUUID.bind(this), + issueProfileKeyCredential: this.issueProfileKeyCredential.bind(this), + createGroup: this.createGroup.bind(this), + getStorageManifest: this.getStorageManifest.bind(this, device), + getStorageItem: this.getStorageItem.bind(this, device), + waitForStorageManifest: this.waitForStorageManifest.bind(this, device), + applyStorageWrite: this.applyStorageWrite.bind(this, device), + }); + await primary.init(); + + this.primaryDevices.set(number, primary); + this.primaryDevices.set(uuid, primary); + + debug('created primary device number=%s uuid=%s', number, uuid); + + return primary; + } + + public async createSecondaryDevice(primary: PrimaryDevice): Promise { + const registrationId = await this.generateRegistrationId(); + + const device = await this.registerDevice({ + uuid: primary.device.uuid, + pni: primary.device.pni, + number: primary.device.number, + registrationId, + }); + + await this.updateDeviceKeys(device, await primary.generateKeys(device)); + + primary.addSecondaryDevice(device); + + return device; + } + + // + // Implement Server's abstract methods + // + + public async getProvisioningResponse( + uuid: UUID, + ): Promise { + const responseQueue = this.createQueue(); + const resultQueue = this.createQueue(); + + await this.provisionQueue.pushAndWait({ + complete: async (response) => { + await responseQueue.pushAndWait(response); + return await resultQueue.shift(); + }, + }); + + const { + // tsdevice:/?uuid=&pub_key= + provisionURL, + primaryDevice, + } = await responseQueue.shift(); + + const query = parseURL(provisionURL, true).query || {}; + + assert.strictEqual(query.uuid, uuid, 'UUID mismatch'); + if (!query.pub_key || Array.isArray(query.pub_key)) { + throw new Error('Expected `pub_key` in provision URL'); + } + + const publicKey = PublicKey.deserialize( + Buffer.from(query.pub_key, 'base64')); + + const identityKey = await primaryDevice.getIdentityKey(); + const provisioningCode = await this.getProvisioningCode( + uuid, primaryDevice.device.number); + + this.provisionResultQueueByCode.set(provisioningCode, resultQueue); + + const envelopeData = Proto.ProvisionMessage.encode({ + identityKeyPrivate: identityKey.serialize(), + number: primaryDevice.device.number, + uuid: primaryDevice.device.uuid, + provisioningCode, + profileKey: primaryDevice.profileKey.serialize(), + userAgent: primaryDevice.userAgent, + readReceipts: true, + // TODO(indutny): is it correct? + ProvisioningVersion: Proto.ProvisioningVersion.CURRENT, + }).finish(); + + const { body, ephemeralKey } = encryptProvisionMessage( + Buffer.from(envelopeData), publicKey); + + const envelope = Proto.ProvisionEnvelope.encode({ + publicKey: ephemeralKey, + body, + }).finish(); + + return { envelope: Buffer.from(envelope) }; + } + + public async handleMessage( + source: Device | undefined, + envelopeType: EnvelopeType, + target: Device, + encrypted: Buffer, + ): Promise { + assert( + source || envelopeType === EnvelopeType.SealedSender, + 'No source for non-sealed sender envelope', + ); + + debug('got message for %s.%d', target.uuid, target.deviceId); + + if (target.deviceId !== PRIMARY_DEVICE_ID) { + debug('ignoring message, not primary'); + return; + } + + const primary = this.primaryDevices.get(target.uuid); + if (!primary) { + debug('ignoring message, primary device not found'); + return; + } + + await primary.handleEnvelope(source, envelopeType, encrypted); + } + + // + // Override `Server`'s methods to automatically pass keys to primary + // devices. + // + // TODO(indutny): use popSingleUseKey() perhaps? + // + + public override async updateDeviceKeys( + device: Device, + keys: DeviceKeys, + ): Promise { + await super.updateDeviceKeys(device, keys); + + const key = `${device.uuid}.${device.registrationId}`; + + // Device is marked as provisioned only once we have its keys + const resultQueue = this.provisionResultQueueByKey.get(key); + if (!resultQueue) { + return; + } + this.provisionResultQueueByKey.delete(key); + await resultQueue.pushAndWait(device); + } + + public override async provisionDevice( + number: string, + password: string, + provisioningCode: ProvisioningCode, + registrationId: RegistrationId, + ): Promise { + const queue = this.provisionResultQueueByCode.get(provisioningCode); + assert( + queue !== undefined, + `Missing provision result queue for code: ${provisioningCode}`); + this.provisionResultQueueByCode.delete(provisioningCode); + + const device = await super.provisionDevice( + number, + password, + provisioningCode, + registrationId); + + const key = `${device.uuid}.${device.registrationId}`; + + this.provisionResultQueueByKey.set(key, queue); + + const primary = this.primaryDevices.get(device.uuid); + primary?.addSecondaryDevice(device); + + return device; + } + + protected async onStorageManifestUpdate( + device: Device, + version: Long, + ): Promise { + debug('onStorageManifestUpdate', device.debugId); + + let queue = this.manifestQueueByUuid.get(device.uuid); + if (!queue) { + queue = this.createQueue(); + this.manifestQueueByUuid.set(device.uuid, queue); + } + + queue.push(version.toNumber()); + } + + // + // Private + // + + private createQueue(): PromiseQueue { + return new PromiseQueue({ + timeout: this.config.timeout, + }); + } + + private async generateNumber(): Promise { + let number: string; + do { + number = generateRandomE164(); + } while (this.knownNumbers.has(number)); + this.knownNumbers.add(number); + + return number; + } +} diff --git a/src/api/storage-state.ts b/src/api/storage-state.ts new file mode 100644 index 0000000..9ac75a1 --- /dev/null +++ b/src/api/storage-state.ts @@ -0,0 +1,459 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import assert from 'assert'; +import crypto from 'crypto'; +import Long from 'long'; + +import { signalservice as Proto } from '../../protos/compiled'; +import { + encryptStorageItem, + encryptStorageManifest, +} from '../crypto'; +import { Device } from '../data/device'; +import { Group } from './group'; +import { PrimaryDevice } from './primary-device'; + +export type StorageStateItemOptions = Readonly<{ + type: Proto.ManifestRecord.Identifier.Type; + key: Buffer; + record: Proto.IStorageRecord; +}>; + +export type DiffResult = Readonly<{ + added: ReadonlyArray; + removed: ReadonlyArray; +}>; + +const KEY_SIZE = 16; + +const IdentifierType = Proto.ManifestRecord.Identifier.Type; +type IdentifierType = Proto.ManifestRecord.Identifier.Type; + +export class StorageStateItem { + public readonly type: IdentifierType; + public readonly key: Buffer; + public readonly record: Proto.IStorageRecord; + + constructor({ + type, + key, + record, + }: StorageStateItemOptions) { + this.type = type; + this.key = key; + this.record = record; + } + + public getKeyString(): string { + return this.key.toString('base64'); + } + + public toStorageItem(storageKey: Buffer): Proto.IStorageItem { + return encryptStorageItem(storageKey, this.key, this.record); + } + + public toIdentifier(): Proto.ManifestRecord.IIdentifier { + return { + type: this.type, + raw: this.key, + }; + } + + public isAccount(): boolean { + return this.type === IdentifierType.ACCOUNT && Boolean(this.record.account); + } + + public isGroup(group: Group): boolean { + if (this.type !== IdentifierType.GROUPV2) { + return false; + } + + const masterKey = this.record?.groupV2?.masterKey; + if (!masterKey) { + return false; + } + + return group.masterKey.equals(masterKey); + } + + public isContact(device: Device): boolean { + if (this.type !== IdentifierType.CONTACT) { + return false; + } + + const serviceUuid = this.record?.contact?.serviceUuid; + if (!serviceUuid) { + return false; + } + + return serviceUuid === device.uuid; + } + + public inspect(): string { + return [ + `type: ${this.type}`, + `key: ${this.key.toString('base64')}`, + ...JSON.stringify(this.record, null, 2).split(/\n/g), + ].map((line) => ` ${line}`).join('\n'); + } +} + +export class StorageState { + private readonly items: ReadonlyArray; + + constructor( + public readonly version: number, + items: ReadonlyArray, + ) { + this.items = items.map((options) => new StorageStateItem(options)); + } + + public static getEmpty(): StorageState { + return new StorageState(0, [ + new StorageStateItem({ + key: StorageState.createStorageID(), + type: IdentifierType.ACCOUNT, + record: { + account: {}, + }, + }), + ]); + } + + // + // Account + // + + public getAccountRecord(): Proto.IAccountRecord | undefined { + const item = this.items.find((item) => item.isAccount()); + if (!item) { + return undefined; + } + + const { account } = item.record; + assert(account, 'consistency check'); + + return account; + } + + public updateAccount(diff: Proto.IAccountRecord): StorageState { + return this.updateItem( + (item) => item.isAccount(), + ({ account }) => ({ + account: { + ...account, + ...diff, + }, + }), + ); + } + + // + // Group + // + + public getGroup(group: Group): Proto.IGroupV2Record | undefined { + const item = this.items.find((item) => item.isGroup(group)); + if (!item) { + return undefined; + } + + const { groupV2 } = item.record; + assert(groupV2, 'consistency check'); + + return groupV2; + } + + public addGroup( + group: Group, + diff: Proto.IGroupV2Record = {}, + ): StorageState { + return this.addItem(IdentifierType.GROUPV2, { + groupV2: { + ...diff, + masterKey: group.masterKey, + }, + }); + } + + public updateGroup( + group: Group, + diff: Proto.IGroupV2Record, + ): StorageState { + return this.updateItem( + (item) => item.isGroup(group), + ({ groupV2 }) => ({ + groupV2: { + ...groupV2, + ...diff, + }, + }), + ); + } + public pinGroup(group: Group): StorageState { + return this.changeGroupPin(group, true); + } + + public unpinGroup(group: Group): StorageState { + return this.changeGroupPin(group, false); + } + + public isGroupPinned(group: Group): boolean { + const account = this.getAccountRecord(); + assert(account, 'No account record found'); + + return (account.pinnedConversations || []).some((convo) => { + if (!convo.groupMasterKey) { + return false; + } + return group.masterKey.equals(convo.groupMasterKey); + }); + } + + // + // Contacts + // + + public addContact( + { device }: PrimaryDevice, + diff: Proto.IContactRecord = {}, + ): StorageState { + return this.addItem(IdentifierType.CONTACT, { + contact: { + serviceUuid: device.uuid, + serviceE164: device.number, + ...diff, + }, + }); + } + + public updateContact( + { device }: PrimaryDevice, + diff: Proto.IContactRecord, + ): StorageState { + return this.updateItem( + (item) => item.isContact(device), + ({ contact }) => ({ + contact: { + ...contact, + ...diff, + }, + }), + ); + } + + public getContact( + { device }: PrimaryDevice, + ): Proto.IContactRecord | undefined { + const item = this.items.find((item) => item.isContact(device)); + if (!item) { + return undefined; + } + + const { contact } = item.record; + assert(contact, 'consistency check'); + + return contact; + + } + + public pin(primary: PrimaryDevice): StorageState { + return this.changePin(primary, true); + } + + public unpin(primary: PrimaryDevice): StorageState { + return this.changePin(primary, false); + } + + public isPinned({ device }: PrimaryDevice): boolean { + const account = this.getAccountRecord(); + assert(account, 'No account record found'); + + return (account.pinnedConversations || []).some((convo) => { + return convo?.contact?.uuid === device.uuid; + }); + } + + // + // General + // + + public createWriteOperation( + storageKey: Buffer, + previous?: StorageState, + ): Proto.IWriteOperation { + const newVersion = Long.fromNumber( + previous ? previous.version + 1 : this.version + 1, + ); + + const keysToDelete = new Set((previous?.items ?? []).map((item) => { + return item.getKeyString(); + })); + const insertItem = new Array(); + + for (const item of this.items) { + if (!keysToDelete.delete(item.getKeyString())) { + insertItem.push(item.toStorageItem(storageKey)); + } + } + + const manifest = encryptStorageManifest(storageKey, { + version: newVersion, + keys: this.items.map((item) => item.toIdentifier()), + }); + + return { + manifest, + insertItem, + deleteKey: Array.from(keysToDelete).map((key) => { + return Buffer.from(key, 'base64'); + }), + }; + } + + public inspect(): string { + return [ + `version: ${this.version}`, + ...this.items.map((item) => item.inspect()), + ].join('\n'); + } + + public diff(oldState: StorageState): DiffResult { + const addedIds = new Map(); + const removedIds = new Map(); + + for (const item of this.items) { + addedIds.set(item.key.toString('base64'), item.record); + } + + for (const item of oldState.items) { + const keyString = item.key.toString('base64'); + if (!addedIds.delete(keyString)) { + removedIds.set(keyString, item.record); + } + } + + return { + added: Array.from(addedIds.values()), + removed: Array.from(removedIds.values()), + }; + } + + // + // Private + // + + private updateItem( + find: (item: StorageStateItem, index: number) => boolean, + map: (record: Proto.IStorageRecord) => Proto.IStorageRecord, + ): StorageState { + const itemIndex = this.items.findIndex(find); + if (itemIndex === -1) { + throw new Error('Item not found'); + } + + const item = this.items[itemIndex]; + assert(item, 'consistency check'); + + return this.replaceItem(itemIndex, item.type, map(item.record)); + } + + private addItem( + type: IdentifierType, + record: Proto.IStorageRecord, + ): StorageState { + return this.replaceItem(this.items.length, type, record); + } + + private replaceItem( + index: number, + type: IdentifierType, + record: Proto.IStorageRecord, + ): StorageState { + const newKey = StorageState.createStorageID(); + + const newItems = [ + ...this.items.slice(0, index), + new StorageStateItem({ type, key: newKey, record }), + ...this.items.slice(index + 1), + ]; + + return new StorageState(this.version, newItems); + } + + private changePin( + { device }: PrimaryDevice, + isPinned: boolean, + ): StorageState { + return this.updateItem( + (item) => item.isAccount(), + ({ account }) => { + assert(account, 'consistency check'); + + const { pinnedConversations } = account; + + const newPinnedConversations = pinnedConversations?.slice() || []; + + const existingIndex = newPinnedConversations.findIndex((convo) => { + return convo?.contact?.uuid === device.uuid; + }); + + if (isPinned && existingIndex === -1) { + newPinnedConversations.push({ + contact: { uuid: device.uuid }, + }); + } else if (!isPinned && existingIndex !== -1) { + newPinnedConversations.splice(existingIndex, 1); + } + + return { + account: { + ...account, + pinnedConversations: newPinnedConversations, + }, + }; + }, + ); + } + + private changeGroupPin(group: Group, isPinned: boolean): StorageState { + return this.updateItem( + (item) => item.isAccount(), + ({ account }) => { + assert(account, 'consistency check'); + + const { pinnedConversations } = account; + + const newPinnedConversations = pinnedConversations?.slice() || []; + + const existingIndex = newPinnedConversations.findIndex((convo) => { + if (!convo.groupMasterKey) { + return false; + } + return group.masterKey.equals(convo.groupMasterKey); + }); + + if (isPinned && existingIndex === -1) { + newPinnedConversations.push({ + groupMasterKey: group.masterKey, + }); + } else if (!isPinned && existingIndex !== -1) { + newPinnedConversations.splice(existingIndex, 1); + } + + return { + account: { + ...account, + pinnedConversations: newPinnedConversations, + }, + }; + }, + ); + + } + + private static createStorageID(): Buffer { + return crypto.randomBytes(KEY_SIZE); + } +} diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 0000000..3d520cd --- /dev/null +++ b/src/constants.ts @@ -0,0 +1,14 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +export const INITIAL_PREKEY_COUNT = 100; +export const ATTACHMENT_PREFIX = 'af/'; + +export const PRIMARY_DEVICE_ID = 1; +export const PRIMARY_SIGNED_PREKEY_ID = 1; + +export const SERVER_CERTIFICATE_ID = 1; + +export const NEVER_EXPIRES = Number.MAX_SAFE_INTEGER; + +export const MAX_GROUP_CREDENTIALS_DAYS = 7; diff --git a/src/crypto.ts b/src/crypto.ts new file mode 100644 index 0000000..e4a5c27 --- /dev/null +++ b/src/crypto.ts @@ -0,0 +1,356 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import crypto from 'crypto'; +import { Buffer } from 'buffer'; +import Long from 'long'; +import { + HKDF, + PrivateKey, + PublicKey, + SenderCertificate, +} from '@signalapp/signal-client'; + +import { signalservice as Proto } from '../protos/compiled'; + +import { Attachment } from './data/attachment'; +import { + NEVER_EXPIRES, + SERVER_CERTIFICATE_ID, +} from './constants'; +import { + DeviceId, + UUID, +} from './types'; + +const AES_KEY_SIZE = 32; +const MAC_KEY_SIZE = 32; +const AESGCM_IV_SIZE = 12; +const AUTH_TAG_SIZE = 16; + +export type EncryptedProvisionMessage = { + body: Buffer; + ephemeralKey: Buffer; +}; + +export type ServerCertificate = { + privateKey: PrivateKey; + certificate: Proto.IServerCertificate; +}; + +export type Sender = { + readonly uuid: UUID; + readonly number?: string; + readonly deviceId: DeviceId; + readonly identityKey: PublicKey; + readonly expires?: number; +} + +export function encryptProvisionMessage( + data: Buffer, + remotePubKey: PublicKey, +): EncryptedProvisionMessage { + const privateKey = PrivateKey.generate(); + const publicKey = privateKey.getPublicKey(); + + const agreement = privateKey.agree(remotePubKey); + + const hkdf = HKDF.new(3); + + const secrets = hkdf.deriveSecrets( + AES_KEY_SIZE + MAC_KEY_SIZE, + agreement, + Buffer.from('TextSecure Provisioning Message'), + null, + ); + + const aesKey = secrets.slice(0, AES_KEY_SIZE); + const macKey = secrets.slice(AES_KEY_SIZE); + + const iv = crypto.randomBytes(16); + + const cipher = crypto.createCipheriv('aes-256-cbc', aesKey, iv); + const encrypted = Buffer.concat([ + cipher.update(data), + cipher.final(), + ]); + + const version = Buffer.from([ 1 ]); + + const ciphertext = Buffer.concat([ + version, + iv, + encrypted, + ]); + + const mac = crypto.createHmac('sha256', macKey).update(ciphertext).digest(); + + const body = Buffer.concat([ + ciphertext, + mac, + ]); + + return { + body, + ephemeralKey: publicKey.serialize(), + }; +} + +export function encryptAttachment(cleartext: Buffer): Attachment { + const aesKey = crypto.randomBytes(32); + const macKey = crypto.randomBytes(32); + const iv = crypto.randomBytes(16); + + const cipher = crypto.createCipheriv('aes-256-cbc', aesKey, iv); + const ciphertext = Buffer.concat([ + cipher.update(cleartext), + cipher.final(), + ]); + + const mac = crypto.createHmac('sha256', macKey) + .update(iv) + .update(ciphertext) + .digest(); + + const key = Buffer.concat([ aesKey, macKey ]); + + const blob = Buffer.concat([ + iv, + ciphertext, + mac, + ]); + + const digest = crypto.createHash('sha256').update(blob).digest(); + + return { + key, + blob, + digest, + size: cleartext.length, + }; +} + +export function generateServerCertificate( + rootKey: PrivateKey, +): ServerCertificate { + const privateKey = PrivateKey.generate(); + + const data = Buffer.from(Proto.ServerCertificate.Certificate.encode({ + id: SERVER_CERTIFICATE_ID, + key: privateKey.getPublicKey().serialize(), + }).finish()); + + const signature = rootKey.sign(data); + + const certificate = { + certificate: data, + signature, + }; + + return { + privateKey, + certificate, + }; +} + +export function generateSenderCertificate( + serverCert: ServerCertificate, + sender: Sender, +): SenderCertificate { + const data = Buffer.from(Proto.SenderCertificate.Certificate.encode({ + senderE164: sender.number, + senderUuid: sender.uuid, + senderDevice: sender.deviceId, + expires: Long.fromNumber(sender.expires || NEVER_EXPIRES), + identityKey: sender.identityKey.serialize(), + signer: serverCert.certificate, + }).finish()); + + const signature = serverCert.privateKey.sign(data); + + const certificate = Buffer.from(Proto.SenderCertificate.encode({ + certificate: data, + signature, + }).finish()); + + return SenderCertificate.deserialize(certificate); +} + +export function deriveAccessKey( + profileKey: Buffer, +): Buffer { + const cipher = crypto.createCipheriv( + 'aes-256-gcm', + profileKey, + Buffer.alloc(12), + ); + + return Buffer.concat([ + cipher.update(Buffer.alloc(16)), + cipher.final(), + ]); +} + +function deriveStorageManifestKey( + storageKey: Buffer, + version: Long, +): Buffer { + const hash = crypto.createHmac('sha256', storageKey); + hash.update(`Manifest_${version}`); + return hash.digest(); +} + +function deriveStorageItemKey( + storageKey: Buffer, + itemKey: Buffer, +): Buffer { + const hash = crypto.createHmac('sha256', storageKey); + hash.update(`Item_${itemKey.toString('base64')}`); + return hash.digest(); +} + +function decryptAESGCM( + ciphertext: Buffer, + key: Buffer, +): Buffer { + const iv = ciphertext.slice(0, AESGCM_IV_SIZE); + const tag = ciphertext.slice(ciphertext.length - AUTH_TAG_SIZE); + const rest = ciphertext.slice(iv.length, ciphertext.length - tag.length); + + const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv); + + decipher.setAuthTag(tag); + + return Buffer.concat([ + decipher.update(rest), + decipher.final(), + ]); +} + +function encryptAESGCM( + plaintext: Buffer, + key: Buffer, +): Buffer { + const iv = crypto.randomBytes(AESGCM_IV_SIZE); + const cipher = crypto.createCipheriv('aes-256-gcm', key, iv); + + const ciphertext = [ + cipher.update(plaintext), + cipher.final(), + ]; + + const tag = cipher.getAuthTag(); + + return Buffer.concat([ + iv, + ...ciphertext, + tag, + ]); +} + +export function decryptStorageManifest( + storageKey: Buffer, + manifest: Proto.IStorageManifest, +): Proto.IManifestRecord { + if (!manifest.version) { + throw new Error('Missing manifest.version'); + } + if (!manifest.value) { + throw new Error('Missing manifest.value'); + } + + const manifestKey = deriveStorageManifestKey(storageKey, manifest.version); + + const decoded = Proto.ManifestRecord.decode(decryptAESGCM( + Buffer.from(manifest.value), + manifestKey, + )); + + if (!decoded.version) { + throw new Error('Missing manifestRecord.version'); + } + if (!decoded.version.eq(manifest.version)) { + throw new Error('manifestRecord.version != manifest.version'); + } + + return decoded; +} + +export function encryptStorageManifest( + storageKey: Buffer, + manifestRecord: Proto.IManifestRecord, +): Proto.IStorageManifest { + if (!manifestRecord.version) { + throw new Error('Missing manifest.version'); + } + + const manifestKey = deriveStorageManifestKey( + storageKey, + manifestRecord.version, + ); + + const encrypted = encryptAESGCM( + Buffer.from(Proto.ManifestRecord.encode(manifestRecord).finish()), + manifestKey, + ); + + return { + version: manifestRecord.version, + value: encrypted, + }; +} + +export function decryptStorageItem( + storageKey: Buffer, + item: Proto.IStorageItem, +): Proto.IStorageRecord { + if (!item.key) { + throw new Error('Missing item.key'); + } + if (!item.value) { + throw new Error('Missing item.value'); + } + + const itemKey = deriveStorageItemKey(storageKey, Buffer.from(item.key)); + + return Proto.StorageRecord.decode(decryptAESGCM( + Buffer.from(item.value), + itemKey, + )); +} + +export function encryptStorageItem( + storageKey: Buffer, + key: Buffer, + record: Proto.IStorageRecord, +): Proto.IStorageItem { + const itemKey = deriveStorageItemKey(storageKey, key); + + const encrypted = encryptAESGCM( + Buffer.from(Proto.StorageRecord.encode(record).finish()), + itemKey, + ); + + return { + key, + value: encrypted, + }; +} + +export function encryptProfileName( + profileKey: Buffer, + name: string, +): Buffer { + const encrypted = encryptAESGCM( + Buffer.from(name), + profileKey, + ); + + return encrypted; +} + +export function generateAccessKeyVerifier(accessKey: Buffer): Buffer { + const zeroes = Buffer.alloc(32); + + return crypto.createHmac('sha256', accessKey).update(zeroes).digest(); +} diff --git a/src/data/attachment.ts b/src/data/attachment.ts new file mode 100644 index 0000000..45fbf54 --- /dev/null +++ b/src/data/attachment.ts @@ -0,0 +1,24 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { signalservice as Proto } from '../../protos/compiled'; + +export type Attachment = { + key: Buffer; + blob: Buffer; + digest: Buffer; + size: number; +} + +export function attachmentToPointer( + cdnKey: string, + attachment: Attachment, +): Proto.IAttachmentPointer { + return { + contentType: 'application/octet-stream', + cdnKey, + key: attachment.key, + size: attachment.size, + digest: attachment.digest, + }; +} diff --git a/src/data/certificates.ts b/src/data/certificates.ts new file mode 100644 index 0000000..c9489a6 --- /dev/null +++ b/src/data/certificates.ts @@ -0,0 +1,62 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import assert from 'assert'; +import fs from 'fs/promises'; +import path from 'path'; + +export type Certificates = Readonly<{ + certificateAuthority: string; + directoryTrustAnchor: string; + serverPublicParams: string; + serverTrustRoot: string; +}>; + +const CERTS_DIR = path.join( + __dirname, + '..', + '..', + 'certs', +); + +async function loadString(file: string): Promise { + const raw = await fs.readFile(path.join(CERTS_DIR, file)); + return raw.toString(); +} + +async function loadJSONProperty( + file: string, + property: string, +): Promise { + const raw = await fs.readFile(path.join(CERTS_DIR, file)); + const obj = JSON.parse(raw.toString()); + const value = obj[property]; + + assert( + typeof value === 'string', + `Expected string at: ${file}/${property}`, + ); + return value; +} + +export async function load(): Promise { + const [ + certificateAuthority, + serverPublicParams, + serverTrustRoot, + ] = await Promise.all([ + loadString('ca-cert.pem'), + loadJSONProperty( + 'zk-params.json', + 'publicParams', + ), + loadJSONProperty('trust-root.json', 'publicKey'), + ]); + + return { + certificateAuthority, + directoryTrustAnchor: certificateAuthority, + serverPublicParams, + serverTrustRoot, + }; +} diff --git a/src/data/contacts.ts b/src/data/contacts.ts new file mode 100644 index 0000000..18ec197 --- /dev/null +++ b/src/data/contacts.ts @@ -0,0 +1,45 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { UUID } from '../types'; + +import { signalservice as Proto } from '../../protos/compiled'; + +export interface Contact { + readonly uuid: UUID; + readonly number: string; + readonly profileName: string; + readonly profileKey: Buffer; +} + +export function serializeContacts(contacts: ReadonlyArray): Buffer { + const chunks = contacts.map((contact) => { + const { uuid, number, profileName: name, profileKey } = contact; + return Buffer.from(Proto.ContactDetails.encode({ + uuid, + number, + name, + profileKey, + }).finish()); + }).map((chunk) => { + const size: Array = []; + + let remaining = chunk.length; + do { + let element = remaining & 0x7f; + remaining >>>= 7; + + if (remaining !== 0) { + element |= 0x80; + } + size.push(element); + } while (remaining !== 0); + + return [ + Buffer.from(size), + chunk, + ]; + }); + + return Buffer.concat(chunks.flat()); +} diff --git a/src/data/device.ts b/src/data/device.ts new file mode 100644 index 0000000..005548e --- /dev/null +++ b/src/data/device.ts @@ -0,0 +1,117 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import createDebug from 'debug'; +import { ProtocolAddress, PublicKey } from '@signalapp/signal-client'; +import { ProfileKeyCommitment } from '@signalapp/signal-client/zkgroup'; + +import { DeviceId, RegistrationId, UUID } from '../types'; + +const debug = createDebug('mock:device'); + +export interface DeviceOptions { + readonly uuid: UUID; + readonly pni: UUID; + readonly number: string; + readonly deviceId: DeviceId; + readonly registrationId: RegistrationId; +} + +export interface SignedPreKey { + readonly keyId: number; + readonly publicKey: PublicKey; + readonly signature: Buffer; +} + +export interface PreKey { + readonly keyId: number; + readonly publicKey: PublicKey; +} + +export interface DeviceKeys { + readonly identityKey: PublicKey; + readonly signedPreKey: SignedPreKey; + readonly preKeys: ReadonlyArray; +} + +export interface SingleUseKey { + readonly identityKey: PublicKey; + readonly signedPreKey: SignedPreKey; + readonly preKey: PreKey | undefined; +} + +interface InternalDeviceKeys { + readonly identityKey: PublicKey; + readonly signedPreKey: SignedPreKey; + readonly preKeys: Array; +} + +export class Device { + public readonly uuid: UUID; + public readonly pni: UUID; + public readonly number: string; + public readonly deviceId: DeviceId; + public readonly registrationId: RegistrationId; + public readonly address: ProtocolAddress; + + public accessKey?: Buffer; + public profileKeyCommitment?: ProfileKeyCommitment; + public profileName?: Buffer; + + private keys: InternalDeviceKeys | undefined; + + constructor(options: DeviceOptions) { + this.uuid = options.uuid; + this.pni = options.pni; + this.number = options.number; + this.deviceId = options.deviceId; + this.registrationId = options.registrationId; + + this.address = ProtocolAddress.new(this.uuid, this.deviceId); + } + + public get debugId(): string { + return `${this.uuid}.${this.deviceId}`; + } + + public async setKeys(keys: DeviceKeys): Promise { + debug('setting keys for %s', this.debugId); + + // TODO(indutny): concat old preKeys with new ones? + this.keys = { + identityKey: keys.identityKey, + signedPreKey: keys.signedPreKey, + preKeys: keys.preKeys.slice(), + }; + } + + public async getIdentityKey(): Promise { + if (!this.keys) { + throw new Error('No keys available for device'); + } + return this.keys.identityKey; + } + + public async popSingleUseKey(): Promise { + if (!this.keys) { + throw new Error('No keys available for device'); + } + + debug('popping single use key for %s', this.debugId); + + const preKey = this.keys.preKeys.shift(); + + return { + identityKey: this.keys.identityKey, + signedPreKey: this.keys.signedPreKey, + preKey, + }; + } + + public async getSingleUseKeyCount(): Promise { + if (!this.keys) { + throw new Error('No keys available for device'); + } + return this.keys.preKeys.length; + } +} diff --git a/src/data/group.ts b/src/data/group.ts new file mode 100644 index 0000000..6e65dab --- /dev/null +++ b/src/data/group.ts @@ -0,0 +1,34 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import assert from 'assert'; +import { GroupPublicParams } from '@signalapp/signal-client/zkgroup'; + +import { signalservice as Proto } from '../../protos/compiled'; + +export abstract class Group { + protected privChanges?: Proto.IGroupChanges; + protected privPublicParams?: GroupPublicParams; + + public get changes(): Proto.IGroupChanges { + assert(this.privChanges !== undefined, 'Group not initialized'); + return this.privChanges; + } + + public get publicParams(): GroupPublicParams { + assert(this.privPublicParams !== undefined, 'Group not initialized'); + return this.privPublicParams; + } + + public getState(): Proto.IGroup { + const state = this.changes.groupChanges?.[0].groupState; + assert(state, 'Group must have initial state'); + return state; + } + + public getChangesSince(since: number): Proto.IGroupChanges { + return { + groupChanges: this.changes.groupChanges?.slice(since), + }; + } +} diff --git a/src/data/json.d.ts b/src/data/json.d.ts new file mode 100644 index 0000000..1188f19 --- /dev/null +++ b/src/data/json.d.ts @@ -0,0 +1,30 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { DeviceId, RegistrationId } from '../types'; + +export type JSONDeviceKeys = Readonly<{ + identityKey: string; + signedPreKey: Readonly<{ + keyId: number; + publicKey: string; + signature: string; + }>; + preKeys: ReadonlyArray<{ + keyId: number; + publicKey: string; + }>; +}>; + +export type JSONMessage = Readonly<{ + // NOTE: Envelope.Type + type: number; + destinationDeviceId: DeviceId, + destinationRegistrationId: RegistrationId, + content: string; +}>; + +export type JSONMessageList = Readonly<{ + messages: ReadonlyArray; + timestamp: number; +}>; diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..8ca2541 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,21 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +export { Group } from './api/group'; +export { StorageState } from './api/storage-state'; +import { Server } from './api/server'; +export { + EncryptOptions, + PrimaryDevice, + ReceiptOptions, + ReceiptType, + SyncReadMessage, + SyncReadOptions, + SyncSentOptions, +} from './api/primary-device'; +export { Device, SingleUseKey } from './data/device'; +export { EnvelopeType } from './server/base'; +export { signalservice as Proto } from '../protos/compiled'; +export { load as loadCertificates, Certificates } from './data/certificates'; + +export { Server }; diff --git a/src/server/base.ts b/src/server/base.ts new file mode 100644 index 0000000..dc2830c --- /dev/null +++ b/src/server/base.ts @@ -0,0 +1,841 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import assert from 'assert'; +import crypto from 'crypto'; +import Long from 'long'; +import { v4 as uuidv4 } from 'uuid'; +import createDebug from 'debug'; +import { SenderCertificate } from '@signalapp/signal-client'; +import { + GroupPublicParams, + ProfileKeyCredentialRequest, + ProfileKeyCredentialResponse, + ServerSecretParams, + ServerZkAuthOperations, + ServerZkProfileOperations, +} from '@signalapp/signal-client/zkgroup'; + +import { signalservice as Proto } from '../../protos/compiled'; +import { + ServerCertificate, + generateSenderCertificate, +} from '../crypto'; +import { Device, DeviceKeys } from '../data/device'; +import { + ATTACHMENT_PREFIX, + MAX_GROUP_CREDENTIALS_DAYS, + PRIMARY_DEVICE_ID, +} from '../constants'; +import { + AttachmentId, + DeviceId, + ProvisioningCode, + RegistrationId, + UUID, +} from '../types'; +import { getEpochDay } from '../util'; +import { JSONMessage } from '../data/json.d'; +import { ServerGroup } from './group'; + +export enum EnvelopeType { + CipherText = 'CipherText', + PreKey = 'PreKey', + SealedSender = 'SealedSender', + SenderKey = 'SenderKey', +} + +export type ProvisioningResponse = Readonly<{ + envelope: Buffer; +}>; + +export type GroupCredentialsRange = Readonly<{ + from: number; + to: number; +}>; + +export type StorageCredentials = Readonly<{ + username: string; + password: string; +}>; + +export type GroupCredentials = Array<{ + credential: string; + redemptionTime: number; +}>; + +export type PreparedMultiDeviceMessage = ReadonlyArray<[ Device, JSONMessage ]>; + +export type RegisterDeviceOptions = Readonly<{ + uuid: UUID; + pni: UUID; + number: string; + registrationId: RegistrationId; +}> + +export type PrepareMultiDeviceMessageResult = Readonly<{ + status: 'stale'; + staleDevices: ReadonlyArray; +} | { + status: 'incomplete'; + missingDevices: ReadonlyArray; + extraDevices: ReadonlyArray; +} | { + status: 'unknown'; +} | { + status: 'ok'; + result: PreparedMultiDeviceMessage; +}>; + +export type StorageWriteResult = Readonly<{ + updated: false; + manifest: Proto.IStorageManifest; + error?: void; +} | { + updated: true; + manifest?: void; + error?: void; +} | { + updated?: void; + error: string; +}>; + +interface WebSocket { + sendMessage(message: Buffer | 'empty'): Promise; +} + +type AuthEntry = Readonly<{ + readonly password: string; + readonly device: Device; +}>; + +type StorageAuthEntry = Readonly<{ + username: string; + password: string; + device: Device; +}>; + +type MessageQueueEntry = { + readonly message: Buffer; + resolve(): void; + reject(error: Error): void; +}; + +const debug = createDebug('mock:server:base'); + +// NOTE: This class is currently extended only by src/api/server.ts +export abstract class Server { + private readonly devices = new Map>(); + private readonly devicesByUUID = new Map(); + private readonly usedUUIDs = new Set(); + private readonly devicesByAuth = new Map(); + private readonly storageAuthByUsername = new Map(); + private readonly storageAuthByDevice = new Map(); + private readonly storageManifestByUuid = + new Map(); + private readonly storageItemsByUuid = + new Map>(); + private readonly provisioningCodes = + new Map>(); + private readonly attachments = new Map(); + private readonly webSockets = new Map>(); + private readonly messageQueue = + new WeakMap>(); + private readonly groups = new Map(); + protected privCertificate: ServerCertificate | undefined; + protected privZKSecret: ServerSecretParams | undefined; + + // + // Provisioning + // + + public async generateUUID(): Promise { + let result: UUID; + do { + result = uuidv4(); + } while (this.usedUUIDs.has(result) || this.devicesByUUID.has(result)); + this.usedUUIDs.add(result); + return result; + } + + public async releaseUUID(uuid: UUID): Promise { + if (this.devicesByUUID.has(uuid)) { + assert.ok(!this.usedUUIDs.has(uuid)); + throw new Error('Can\'t release UUID'); + } + this.usedUUIDs.delete(uuid); + } + + public async generateRegistrationId(): Promise { + return Math.max(1, (Math.random() * 0x4000) | 0); + } + + public abstract getProvisioningResponse( + uuid: UUID + ): Promise; + + public async registerDevice({ + uuid, + pni, + number, + registrationId, + }: RegisterDeviceOptions): Promise { + if (!this.usedUUIDs.has(uuid)) { + throw new Error('Use generateUUID() to create new UUID'); + } + + let list = this.devices.get(number); + if (!list) { + list = []; + this.devices.set(number, list); + } + const deviceId = list.length + 1; + const isPrimary = deviceId === PRIMARY_DEVICE_ID; + + const device = new Device({ + uuid, + pni, + number, + deviceId, + registrationId, + }); + + if (isPrimary) { + assert(!this.devicesByUUID.has(uuid), 'Duplicate primary device'); + this.devicesByUUID.set(uuid, device); + } + list.push(device); + + debug('registered device number=%j uuid=%s', number, uuid); + return device; + } + + // Called from primary device + public async getProvisioningCode( + uuid: UUID, + number: string, + ): Promise { + let entry = this.provisioningCodes.get(number); + if (!entry) { + entry = new Map(); + this.provisioningCodes.set(number, entry); + } + let code: ProvisioningCode; + do { + code = crypto.randomBytes(8).toString('hex'); + } while (entry.has(code)); + entry.set(code, uuid); + return code; + } + + // Called from secondary device + public async provisionDevice( + number: string, + password: string, + provisioningCode: ProvisioningCode, + registrationId: RegistrationId, + ): Promise { + const entry = this.provisioningCodes.get(number); + if (!entry) { + throw new Error('Invalid number for provisioning'); + } + + const uuid = entry.get(provisioningCode); + if (!uuid) { + throw new Error('Invalid provisioning code'); + } + entry.delete(provisioningCode); + + const [ primary ] = this.devices.get(number) || []; + assert(primary !== undefined, 'Missing primary device when provisioning'); + + const device = await this.registerDevice({ + uuid: primary.uuid, + pni: primary.pni, + number, + registrationId, + }); + + const username = `${number}.${device.deviceId}`; + + // This is awkward, but WebSockets use it. + const secondUsername = `${device.uuid}.${device.deviceId}`; + + // Add auth only after successfully registering the device + assert( + !this.devicesByAuth.has(username) && + !this.devicesByAuth.has(secondUsername), + 'Duplicate username in `provisionDevice`'); + const authEntry = { + password, + device, + }; + this.devicesByAuth.set(username, authEntry); + this.devicesByAuth.set(secondUsername, authEntry); + + debug('provisioned device number=%j uuid=%j', number, uuid); + return device; + } + + public async updateDeviceKeys( + device: Device, + keys: DeviceKeys, + ): Promise { + debug('setting device=%s keys', device.debugId); + await device.setKeys(keys); + } + + // + // Auth + // + + async auth(username: string, password: string): Promise { + const entry = this.devicesByAuth.get(username); + if (!entry) { + debug('auth failed, username=%j is unknown', username); + return; + } + if (entry.password !== password) { + debug('auth failed, invalid login/password %j:%j', username, password); + } + return entry.device; + } + + // + // CDN + // + + async storeAttachment(attachment: Buffer): Promise { + const id = ATTACHMENT_PREFIX + + crypto.createHash('sha256').update(attachment).digest('hex'); + this.attachments.set(id, attachment); + return id; + } + + async fetchAttachment(id: AttachmentId): Promise { + return this.attachments.get(id); + } + + // + // Messages + // + + public async prepareMultiDeviceMessage( + source: Device | undefined, + targetUUID: UUID, + messages: ReadonlyArray, + ): Promise { + const devices = await this.getAllDevicesByUUID(targetUUID); + if (devices.length === 0) { + return { status: 'unknown' }; + } + + const deviceById = new Map(); + for (const device of devices) { + deviceById.set(device.deviceId, device); + } + + const result = new Array<[ Device, JSONMessage ]>(); + + const extraDevices = new Set(); + const staleDevices = new Set(); + for (const message of messages) { + const { + destinationDeviceId, + destinationRegistrationId, + } = message; + + const target = deviceById.get(destinationDeviceId); + if (!target) { + extraDevices.add(destinationDeviceId); + continue; + } + + deviceById.delete(destinationDeviceId); + + if (target.registrationId !== destinationRegistrationId) { + staleDevices.add(destinationDeviceId); + continue; + } + + result.push([ target, message ]); + } + + if (source && source.uuid === targetUUID) { + deviceById.delete(source.deviceId); + } + + if (staleDevices.size !== 0) { + return { status: 'stale', staleDevices: Array.from(staleDevices) }; + } + + if (extraDevices.size !== 0 || deviceById.size !== 0) { + return { + status: 'incomplete', + missingDevices: Array.from(deviceById.keys()), + extraDevices: Array.from(extraDevices), + }; + } + + return { status: 'ok', result }; + } + + public async handlePreparedMultiDeviceMessage( + source: Device | undefined, + prepared: PreparedMultiDeviceMessage, + ): Promise { + for (const [ target, message ] of prepared) { + let envelopeType: EnvelopeType; + if (message.type === Proto.Envelope.Type.CIPHERTEXT) { + envelopeType = EnvelopeType.CipherText; + } else if (message.type === Proto.Envelope.Type.PREKEY_BUNDLE) { + envelopeType = EnvelopeType.PreKey; + } else if (message.type === Proto.Envelope.Type.UNIDENTIFIED_SENDER) { + envelopeType = EnvelopeType.SealedSender; + } else { + throw new Error(`Unsupported envelope type: ${message.type}`); + } + + await this.handleMessage( + source, + envelopeType, + target, + Buffer.from(message.content, 'base64'), + ); + } + } + + public abstract handleMessage( + source: Device | undefined, + envelopeType: EnvelopeType, + target: Device, + encrypted: Buffer, + ): Promise; + + public async addWebSocket(device: Device, socket: WebSocket): Promise { + debug('adding websocket for device=%s', device.debugId); + let sockets = this.webSockets.get(device); + if (!sockets) { + sockets = new Set(); + this.webSockets.set(device, sockets); + } + sockets.add(socket); + + await this.sendQueue(device, socket); + } + + public removeWebSocket(device: Device, socket: WebSocket): void { + debug('removing websocket for device=%s', device.debugId); + const sockets = this.webSockets.get(device); + if (!sockets) { + return; + } + sockets.delete(socket); + if (sockets.size === 0) { + this.webSockets.delete(device); + } + } + + // TODO(indutny): timeout + public async send( + target: Device, + message: Buffer, + ): Promise { + const sockets = this.webSockets.get(target); + if (sockets) { + debug( + 'sending message to %d sockets of %s', + sockets.size, + target.debugId); + let success = false; + await Promise.all(Array.from(sockets).map(async (socket) => { + try { + await socket.sendMessage(message); + success = true; + } catch (error) { + assert(error instanceof Error); + debug('failed to send message to socket of %s, error %s', + target.debugId, error.message); + } + })); + + // At least one send should succeed, if not - queue + if (success) { + return; + } + + debug( + 'message couldn\'t be sent to %s', + sockets.size, + target.debugId); + } + + debug('queueing message for device=%s', target.debugId); + + await new Promise((resolve, reject) => { + // NOTE: set and push have to happen in the same tick, otherwise a race + // condition is possible in `removeWebSocket`. + let queue = this.messageQueue.get(target); + if (!queue) { + queue = []; + this.messageQueue.set(target, queue); + } + + queue.push({ + message, + resolve, + reject, + }); + }); + + debug('queued message sent to device=%s', target.debugId); + } + + // + // Groups + // + + public async createGroup(group: Proto.IGroup): Promise { + const result = new ServerGroup({ + authOps: new ServerZkAuthOperations(this.zkSecret), + profileOps: new ServerZkProfileOperations(this.zkSecret), + state: group, + }); + + const key = result.publicParams.serialize().toString('base64'); + + if (this.groups.get(key)) { + throw new Error('Duplicate group'); + } + + this.groups.set(key, result); + + return result; + } + + public async getGroup( + publicParams: GroupPublicParams, + ): Promise { + return this.groups.get(publicParams.serialize().toString('base64')); + } + + // + // Storage + // + + public async getStorageAuth(device: Device): Promise { + let auth = this.storageAuthByDevice.get(device); + if (!auth) { + do { + auth = { + username: crypto.randomBytes(8).toString('hex'), + password: crypto.randomBytes(8).toString('hex'), + device, + }; + } while (this.storageAuthByUsername.has(auth.username)); + + this.storageAuthByDevice.set(device, auth); + this.storageAuthByUsername.set(auth.username, auth); + + debug('register new storage username=%j', auth.username); + } + + return { + username: auth.username, + password: auth.password, + }; + } + + public async storageAuth( + username: string, + password: string, + ): Promise { + const auth = this.storageAuthByUsername.get(username); + if (!auth) { + debug('auth failed, username=%j is unknown', username); + return; + } + if (auth.password !== password) { + debug('auth failed, invalid login/password %j:%j', username, password); + } + + return auth.device; + } + + public async getStorageManifest( + device: Device, + ): Promise { + return this.storageManifestByUuid.get(device.uuid); + } + + public async applyStorageWrite( + device: Device, + { + manifest, + clearAll, + insertItem, + deleteKey, + }: Proto.IWriteOperation, + shouldNotify = true, + ): Promise { + if (!manifest) { + return { error: 'missing `writeOperation.manifest`' }; + } + + if (!manifest.version) { + return { + error: 'not updating storage manifest, ' + + 'missing `writeOperation.manifest.version`', + }; + } + + const existing = await this.getStorageManifest(device); + if (existing) { + // Atomicity + assert(existing.version, 'consistency check'); + if (!manifest.version.eq(existing.version.add(1))) { + debug( + 'not updating storage manifest, current version=%j new version=%j', + existing.version.toNumber(), + manifest.version.toNumber(), + ); + return { updated: false, manifest: existing }; + } + } + + if (clearAll) { + debug('clearing storage items for=%j', device.debugId); + await this.clearStorageItems(device); + } + + const inserts = (insertItem || []).map(async (item) => { + assert(item.key instanceof Uint8Array, 'insertItem.key must be a Buffer'); + assert( + item.value instanceof Uint8Array, + 'insertItem.value must be a Buffer', + ); + return this.setStorageItem( + device, + Buffer.from(item.key), + Buffer.from(item.value), + ); + }); + await Promise.all(inserts); + + const deletes = (deleteKey || []).map(async (key) => { + return this.deleteStorageItem(device, Buffer.from(key)); + }); + await Promise.all(deletes); + + debug( + 'updating storage manifest to version=%j for=%j', + manifest.version.toNumber(), + device.debugId, + ); + this.storageManifestByUuid.set(device.uuid, manifest); + + if (shouldNotify) { + await this.onStorageManifestUpdate(device, manifest.version); + } + + return { updated: true }; + } + + private async clearStorageItems(device: Device): Promise { + this.storageItemsByUuid.get(device.uuid)?.clear(); + } + + private async setStorageItem( + device: Device, + key: Buffer, + value: Buffer, + ): Promise { + let map = this.storageItemsByUuid.get(device.uuid); + if (!map) { + map = new Map(); + this.storageItemsByUuid.set(device.uuid, map); + } + + map.set(key.toString('hex'), value); + } + + public async getStorageItem( + device: Device, + key: Buffer, + ): Promise { + const map = this.storageItemsByUuid.get(device.uuid); + if (!map) { + return undefined; + } + + return map.get(key.toString('hex')); + } + + public async deleteStorageItem( + device: Device, + key: Buffer, + ): Promise { + const map = this.storageItemsByUuid.get(device.uuid); + if (!map) { + return; + } + + map.delete(key.toString('hex')); + } + + protected abstract onStorageManifestUpdate( + device: Device, + version: Long, + ): Promise; + + // + // Utils + // + + public async getDevice( + number: string, + deviceId: DeviceId, + ): Promise { + const list = this.devices.get(number); + if (!list) { + return; + } + if (deviceId < 1 || deviceId > list.length) { + return; + } + + return list[deviceId - 1]; + } + + public async getDeviceByUUID( + uuid: UUID, + deviceId?: DeviceId, + ): Promise { + const primary = this.devicesByUUID.get(uuid); + if (deviceId === undefined || !primary || primary.deviceId === deviceId) { + return primary; + } + if (primary.deviceId !== PRIMARY_DEVICE_ID) { + return undefined; + } + return await this.getDevice(primary.number, deviceId); + } + + public async getAllDevicesByUUID( + uuid: UUID, + ): Promise> { + const primary = this.devicesByUUID.get(uuid); + if (!primary) { + return []; + } + + return this.devices.get(primary.number) || []; + } + + public async getSenderCertificate( + device: Device, + ): Promise { + return generateSenderCertificate(this.certificate, { + number: device.number, + uuid: device.uuid, + deviceId: device.deviceId, + identityKey: await device.getIdentityKey(), + }); + } + + public async getGroupCredentials( + { uuid }: Device, + { from, to }: GroupCredentialsRange, + ): Promise { + const today = getEpochDay(); + if (from > to || from < today || to > today + MAX_GROUP_CREDENTIALS_DAYS) { + throw new Error('Invalid redemption range'); + } + + const auth = new ServerZkAuthOperations(this.zkSecret); + const result: GroupCredentials = []; + for (let redemptionTime = from; redemptionTime <= to; redemptionTime++) { + result.push({ + credential: auth.issueAuthCredential(uuid, redemptionTime) + .serialize().toString('base64'), + redemptionTime, + }); + } + return result; + } + + public async issueProfileKeyCredential( + { uuid, profileKeyCommitment }: Device, + request: ProfileKeyCredentialRequest, + ): Promise { + if (!profileKeyCommitment) { + return undefined; + } + + const profile = new ServerZkProfileOperations(this.zkSecret); + return profile.issueProfileKeyCredential( + request, + uuid, + profileKeyCommitment, + ); + } + + // + // Private + // + + protected set certificate(value: ServerCertificate) { + if (this.privCertificate) { + throw new Error('Certificate already set'); + } + this.privCertificate = value; + } + + protected get certificate(): ServerCertificate { + if (!this.privCertificate) { + throw new Error('Certificate not set'); + } + return this.privCertificate; + } + + protected set zkSecret(value: ServerSecretParams) { + if (this.privZKSecret) { + throw new Error('zkgroup secret already set'); + } + this.privZKSecret = value; + } + + protected get zkSecret(): ServerSecretParams { + if (!this.privZKSecret) { + throw new Error('zkgroup secret not set'); + } + return this.privZKSecret; + } + + private async sendQueue(device: Device, socket: WebSocket): Promise { + let queue = this.messageQueue.get(device); + if (queue) { + this.messageQueue.delete(device); + } else { + queue = []; + } + + debug('sending queued %d messages to %s', queue.length, device.debugId); + await Promise.all(queue.map(async (entry) => { + const { message, resolve, reject } = entry; + + try { + await socket.sendMessage(message); + } catch (error) { + assert(error instanceof Error); + reject(error); + return; + } + + resolve(); + })); + + debug('queue for %s is empty', device.debugId); + await socket.sendMessage('empty'); + } +} diff --git a/src/server/group.ts b/src/server/group.ts new file mode 100644 index 0000000..6c14f49 --- /dev/null +++ b/src/server/group.ts @@ -0,0 +1,71 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import assert from 'assert'; +import { + GroupPublicParams, + ProfileKeyCredentialPresentation, + ServerZkAuthOperations, + ServerZkProfileOperations, +} from '@signalapp/signal-client/zkgroup'; + +import { signalservice as Proto } from '../../protos/compiled'; +import { Group } from '../data/group'; + +export type ServerGroupOptions = Readonly<{ + authOps: ServerZkAuthOperations; + profileOps: ServerZkProfileOperations; + state: Proto.IGroup, +}>; + +export class ServerGroup extends Group { + private readonly profileOps: ServerZkProfileOperations; + + constructor({ profileOps, state }: ServerGroupOptions) { + super(); + + // TODO(indutny): use zod or something + assert.ok(state.publicKey, 'Group public key must be present'); + assert.strictEqual(state.version, 0, 'Initial group version must be zero'); + assert.ok(state.accessControl, 'Group access control must be present'); + assert.ok( + typeof state.accessControl.attributes === 'number' && + typeof state.accessControl.members === 'number' && + typeof state.accessControl.addFromInviteLink === 'number', + 'Group access control must be configured', + ); + assert.ok( + state.members && state.members.length > 0, + 'Group members must be present', + ); + + this.privPublicParams = new GroupPublicParams(Buffer.from(state.publicKey)); + this.profileOps = profileOps; + + for (const { role, presentation } of state.members) { + assert.strictEqual( + typeof role, + 'number', + 'Group member role is undefined', + ); + assert.ok( + presentation, + 'Group member presentation is undefined', + ); + + const presentationFFI = new ProfileKeyCredentialPresentation( + Buffer.from(presentation), + ); + this.profileOps.verifyProfileKeyCredentialPresentation( + this.publicParams, + presentationFFI, + ); + } + + this.privChanges = { + groupChanges: [ { + groupState: state, + } ], + }; + } +} diff --git a/src/server/http.ts b/src/server/http.ts new file mode 100644 index 0000000..2c7b522 --- /dev/null +++ b/src/server/http.ts @@ -0,0 +1,451 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import assert from 'assert'; +import Long from 'long'; +import { Buffer } from 'buffer'; +import { RequestHandler, buffer, json, send } from 'micro'; +import { + AugmentedRequestHandler as RouteHandler, + ServerRequest, + ServerResponse, + get, + put, + router, +} from 'microrouter'; +import { PublicKey } from '@signalapp/signal-client'; +import { GroupPublicParams } from '@signalapp/signal-client/zkgroup'; +import createDebug from 'debug'; + +import { Server } from './base'; +import { ServerGroup } from './group'; +import { Device } from '../data/device'; +import { ParseAuthHeaderResult, parseAuthHeader } from '../util'; +import { JSONDeviceKeys } from '../data/json.d'; +import { signalservice as Proto } from '../../protos/compiled'; + +const debug = createDebug('mock:http'); + +const parsePassword = (req: ServerRequest): ParseAuthHeaderResult => { + return parseAuthHeader(req.headers.authorization); +}; + +const sendDevicesKeys = async ( + res: ServerResponse, + devices: ReadonlyArray, +): Promise => { + const [ primary ] = devices; + assert(primary !== undefined, 'Empty device list'); + + const identityKey = await primary.getIdentityKey(); + + send(res, 200, { + identityKey: identityKey.serialize().toString('base64'), + devices: await Promise.all(devices.map(async (device) => { + const { signedPreKey, preKey } = + await device.popSingleUseKey(); + return { + deviceId: device.deviceId, + registrationId: device.registrationId, + signedPreKey: { + keyId: signedPreKey.keyId, + publicKey: signedPreKey.publicKey.serialize().toString('base64'), + signature: signedPreKey.signature.toString('base64'), + }, + preKey: preKey && { + keyId: preKey.keyId, + publicKey: preKey.publicKey.serialize().toString('base64'), + }, + }; + })), + }); +}; + +export const createHandler = (server: Server): RequestHandler => { + // + // Unauthorized requests + // + + const provisionDevice = put('/v1/devices/:code', async (req, res) => { + const { error, username, password } = parsePassword(req); + if (error) { + return send(res, 400, { error }); + } + if (!username || !password) { + return send(res, 400, { error: 'Invalid authorization header' }); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const body = await json(req) as any; + if (typeof body.registrationId !== 'number') { + return send(res, 400, { error: 'Invalid registration id' }); + } + + const device = await server.provisionDevice( + username, + password, + req.params.code, + body.registrationId); + + return { deviceId: device.deviceId, uuid: device.uuid, pni: device.pni }; + }); + + // TODO(indutny): add a route for /v2/keys/:uuid + const getDeviceKeys = get('/v2/keys/:uuid/:deviceId', async (req, res) => { + const uuid = req.params.uuid; + const deviceId = parseInt(req.params.deviceId || '', 10); + if (!uuid || deviceId.toString() !== req.params.deviceId) { + return send(res, 400, { error: 'Invalid request parameters' }); + } + + const device = await server.getDeviceByUUID(uuid, deviceId); + if (!device) { + return send(res, 404, { error: 'Device not found' }); + } + + return await sendDevicesKeys(res, [ device ]); + }); + + const getAllDeviceKeys = get('/v2/keys/:uuid(/\\*)', async (req, res) => { + const uuid = req.params.uuid; + if (!uuid) { + return send(res, 400, { error: 'Invalid request parameters' }); + } + + const devices = await server.getAllDevicesByUUID(uuid); + if (devices.length === 0) { + return send(res, 404, { error: 'Account not found' }); + } + + return await sendDevicesKeys(res, devices); + }); + + // + // CDN + // + + const getAttachment = get('/attachments/:key/:subkey', async (req) => { + const { key, subkey } = req.params; + return await server.fetchAttachment(`${key}/${subkey}`); + }); + + const notFound: RouteHandler = async (req, res) => { + debug('Unsupported request %s %s', req.method, req.url); + return send(res, 404, { error: 'Not supported yet' }); + }; + + // + // Authorized requests + // + + async function auth( + req: ServerRequest, + res: ServerResponse, + ): Promise { + const { username, password, error } = parsePassword(req); + if (error) { + debug('%s %s auth failed, error %j', req.method, req.url, error); + send(res, 401, { error }); + return; + } + + const device = await server.auth(username ?? '', password ?? ''); + if (!device) { + debug('%s %s auth failed, need re-provisioning', req.method, req.url); + send(res, 401, { error: 'Need re-provisioning' }); + return; + } + + return device; + } + + async function groupAuth( + req: ServerRequest, + res: ServerResponse, + ): Promise { + const { error, username, password } = parsePassword(req); + + if (error) { + send(res, 400, { error }); + return undefined; + } + if (!username || !password) { + send(res, 400, { error: 'Invalid authorization header' }); + return undefined; + } + + const publicParams = new GroupPublicParams(Buffer.from(username, 'hex')); + + // TODO(indutny): validate password + + const group = await server.getGroup(publicParams); + if (!group) { + send(res, 404, { error: 'Group not found' }); + return undefined; + } + + return group; + } + + async function storageAuth( + req: ServerRequest, + res: ServerResponse, + ): Promise { + const { error, username, password } = parsePassword(req); + + if (error) { + send(res, 400, { error }); + return undefined; + } + if (!username || !password) { + send(res, 400, { error: 'Invalid authorization header' }); + return undefined; + } + + const device = await server.storageAuth(username, password); + if (!device) { + debug('%s %s storage auth failed', req.method, req.url); + send(res, 403, { error: 'Invalid authorization' }); + return undefined; + } + + return device; + } + + const putKeys = put('/v2/keys', async (req, res) => { + const device = await auth(req, res); + if (!device) { + return; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const body: JSONDeviceKeys = await json(req) as any; + try { + const parseKey = (base64: string): PublicKey => { + return PublicKey.deserialize(Buffer.from(base64, 'base64')); + }; + + await server.updateDeviceKeys(device, { + identityKey: parseKey(body.identityKey), + signedPreKey: { + keyId: body.signedPreKey.keyId, + publicKey: parseKey(body.signedPreKey.publicKey), + signature: Buffer.from(body.signedPreKey.signature, 'base64'), + }, + preKeys: body.preKeys.map((preKey) => { + return { + keyId: preKey.keyId, + publicKey: parseKey(preKey.publicKey), + }; + }), + }); + } catch (error) { + assert(error instanceof Error); + debug('updateDeviceKeys error', error.stack); + return send(res, 400, { error: error.message }); + } + + return { ok: true }; + }); + + const getKeys = get('/v2/keys', async (req, res) => { + const device = await auth(req, res); + if (!device) { + return; + } + + return { count: await device.getSingleUseKeyCount() }; + }); + + const whoami = get('/v1/accounts/whoami', async (req, res) => { + const device = await auth(req, res); + if (!device) { + return; + } + + return { uuid: device.uuid, pni: device.pni, number: device.number }; + }); + + // + // GV2 + // + + const getGroup = get('/v1/groups', async (req, res) => { + const group = await groupAuth(req, res); + if (!group) { + return; + } + + return send(res, 200, Proto.Group.encode(group.getState()).finish()); + }); + + const getGroupVersion = get('/v1/groups/joined_at_version', async (req, res) => { + const group = await groupAuth(req, res); + if (!group) { + return; + } + + // TODO(indutny): support this for real? + return send(res, 200, Proto.Member.encode({ + joinedAtVersion: 0, + }).finish()); + }); + + const getGroupLogs = get('/v1/groups/logs/:since', async (req, res) => { + const group = await groupAuth(req, res); + if (!group) { + return; + } + + const since = parseInt(req.params.since, 10); + + return send( + res, + 200, + Proto.GroupChanges.encode(group.getChangesSince(since)).finish(), + ); + }); + + // + // Storage Service + // + + const getStorageManifest = get('/v1/storage/manifest', async (req, res) => { + const device = await storageAuth(req, res); + if (!device) { + return; + } + + const manifest = await server.getStorageManifest(device); + if (!manifest) { + return send(res, 404, { error: 'Manifest not found' }); + } + + return send(res, 200, Proto.StorageManifest.encode(manifest).finish()); + }); + + const getStorageManifestByVersion = get( + '/v1/storage/manifest/version/:after', + async (req, res) => { + const device = await storageAuth(req, res); + if (!device) { + return; + } + + const after = Long.fromString(req.params.after); + const manifest = await server.getStorageManifest(device); + if (!manifest?.version?.gt(after)) { + return send(res, 204); + } + + return send(res, 200, Proto.StorageManifest.encode(manifest).finish()); + }, + ); + + const putStorage = put('/v1/storage/', async (req, res) => { + const device = await storageAuth(req, res); + if (!device) { + return; + } + + const writeOperation = Proto.WriteOperation.decode( + Buffer.from(await buffer(req)), + ); + + const result = await server.applyStorageWrite(device, writeOperation); + if ('error' in result) { + return send(res, 400, { error: result.error }); + } + + if (!result.updated) { + return send( + res, + 409, + Proto.StorageManifest.encode(result.manifest).finish(), + ); + } + + return send(res, 200); + }); + + const putStorageRead = put('/v1/storage/read', async (req, res) => { + const device = await storageAuth(req, res); + if (!device) { + return; + } + + const readOperation = Proto.ReadOperation.decode( + Buffer.from(await buffer(req)), + ); + + const items = (readOperation.readKey || []).map(async (key) => { + return { + key, + value: await server.getStorageItem(device, Buffer.from(key)), + }; + }); + + return send(res, 200, Proto.StorageItems.encode({ + items: await Promise.all(items), + }).finish()); + }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const dummyAuth = (response: any): RouteHandler => { + return async (req, res) => { + const device = await auth(req, res); + if (!device) { + return; + } + + return response; + }; + }; + + const notFoundAfterAuth: RouteHandler = async (req, res) => { + const device = await auth(req, res); + if (!device) { + return; + } + + debug('Unsupported request %s %s', req.method, req.url); + return send(res, 404, { error: 'Not supported yet' }); + }; + + const routes = router( + // Sure, why not + get('/v1/config', dummyAuth({ config: [] })), + put('/v1/devices/unauthenticated_delivery', dummyAuth({ ok: true })), + put('/v1/devices/capabilities', dummyAuth({ ok: true })), + + // TODO(indutny): support nameless devices? They use different route + provisionDevice, + getDeviceKeys, + getAllDeviceKeys, + getAttachment, + + putKeys, + getKeys, + + whoami, + + // Technically these should live on a separate server, but who cares + getGroup, + getGroupVersion, + getGroupLogs, + + getStorageManifest, + getStorageManifestByVersion, + putStorage, + putStorageRead, + + get('/stickers/', notFound), + get('/*', notFoundAfterAuth), + put('/*', notFoundAfterAuth), + ); + + return (req, res) => { + debug('got request %s %s', req.method, req.url); + return routes(req, res); + }; +}; diff --git a/src/server/ws/connection.ts b/src/server/ws/connection.ts new file mode 100644 index 0000000..c3c55e0 --- /dev/null +++ b/src/server/ws/connection.ts @@ -0,0 +1,464 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import assert from 'assert'; +import { Buffer } from 'buffer'; +import { IncomingMessage } from 'http'; +import { parse as parseURL } from 'url'; +import { timingSafeEqual } from 'crypto'; +import createDebug from 'debug'; +import { + ProfileKeyCredentialRequest, +} from '@signalapp/signal-client/zkgroup'; + +import WebSocket from 'ws'; + +import { signalservice as Proto } from '../../../protos/compiled'; +import { Device } from '../../data/device'; +import { JSONMessage, JSONMessageList } from '../../data/json.d'; +import { UUID } from '../../types'; +import { generateAccessKeyVerifier } from '../../crypto'; +import { Server } from '../base'; +import { + combineMultiRecipientMessage, + parseAuthHeader, + parseMultiRecipientMessage, +} from '../../util'; + +import { Service, WSRequest, WSResponse } from './service'; +import { Handler, Router } from './router'; + +const debug = createDebug('mock:ws:connection'); + +export class Connection extends Service { + private device: Device | undefined; + private readonly router = new Router(); + + constructor( + private readonly request: IncomingMessage, + ws: WebSocket, + private readonly server: Server, + ) { + super(ws); + + const getProfile: Handler = async (params, _, headers) => { + const uuid = params.uuid as string; + + const device = await this.server.getDeviceByUUID(uuid); + if (!device) { + return [ 404, { error: 'Device not found' } ]; + } + + if (this.device) { + // Authenticated + } else if (!device.accessKey || !headers['unidentified-access-key']) { + return [ 401, { error: 'Not authenticated' } ]; + } else { + const accessKey = Buffer.from( + headers['unidentified-access-key'], + 'base64', + ); + if (!timingSafeEqual(accessKey, device.accessKey)) { + return [ 401, { error: 'Invalid access key' } ]; + } + } + + let credential: Buffer | undefined; + if (params.request) { + const request = new ProfileKeyCredentialRequest( + Buffer.from(params.request as string, 'hex'), + ); + const response = await this.server.issueProfileKeyCredential( + device, + request, + ); + credential = response?.serialize(); + } + + const identityKey = await device.getIdentityKey(); + + return [ 200, { + name: device.profileName, + identityKey: identityKey.serialize().toString('base64'), + unrestrictedUnidentifiedAccess: false, + unidentifiedAccess: device.accessKey ? + generateAccessKeyVerifier(device.accessKey) : undefined, + capabilities: { + announcementGroup: true, + 'gv2-3': true, + 'gv1-migration': true, + senderKey: true, + }, + credential: credential?.toString('base64'), + } ]; + }; + this.router.get('/v1/profile/:uuid', getProfile); + this.router.get('/v1/profile/:uuid/:version', getProfile); + this.router.get('/v1/profile/:uuid/:version/:request', getProfile); + + const requireAuth = (handler: Handler): Handler => { + return async (params, body, headers) => { + if (!this.device) { + return [ 401, { error: 'Not authorized' } ]; + } + + return handler(params, body, headers); + }; + }; + + this.router.get('/v1/config', requireAuth(async () => { + return [ 200, { + config: [ + { name: 'desktop.gv2', enabled: true }, + { name: 'desktop.gv2Admin', enabled: true }, + { name: 'desktop.internalUser', enabled: true }, + { name: 'desktop.sendSenderKey2', enabled: true }, + { name: 'desktop.sendSenderKey3', enabled: true }, + { name: 'desktop.senderKey.retry', enabled: true }, + { name: 'desktop.senderKey.send', enabled: true }, + { name: 'desktop.storage', enabled: true }, + { name: 'desktop.storageWrite3', enabled: true }, + { name: 'desktop.messageRequests', enabled: true }, + ], + } ]; + })); + + this.router.put( + '/v1/messages/multi_recipient', + async (_params, body) => { + if (!body) { + return [ 400, { error: 'Missing body' } ]; + } + + const { + recipients, + commonMaterial, + } = parseMultiRecipientMessage(Buffer.from(body)); + + const listByUUID = new Map>(); + + for (const recipient of recipients) { + const { + uuid, + deviceId, + registrationId, + material, + } = recipient; + + let list: Array | undefined = listByUUID.get(uuid); + if (!list) { + list = []; + listByUUID.set(uuid, list); + } + + list.push({ + type: Proto.Envelope.Type.UNIDENTIFIED_SENDER, + destinationDeviceId: deviceId, + destinationRegistrationId: registrationId, + content: combineMultiRecipientMessage({ + material, + commonMaterial, + }).toString('base64'), + }); + } + + // TODO(indutny): verify access key xor + + const results = await Promise.all( + Array.from(listByUUID.entries()).map(async ( + [ uuid, messages ], + ) => { + return { + uuid, + prepared: await this.server.prepareMultiDeviceMessage( + undefined, + uuid, + messages, + ), + }; + }), + ); + + const incomplete = results.filter( + ({ prepared }) => prepared.status === 'incomplete', + ); + + if (incomplete.length !== 0) { + return [ + 409, + incomplete.map(({ uuid, prepared }) => { + assert.ok(prepared.status === 'incomplete'); + return { + uuid, + devices: { + missingDevices: prepared.missingDevices, + extraDevices: prepared.extraDevices, + }, + }; + }), + ]; + } + + const stale = results.filter( + ({ prepared }) => prepared.status === 'stale', + ); + + if (stale.length !== 0) { + return [ + 410, + stale.map(({ uuid, prepared }) => { + assert.ok(prepared.status === 'stale'); + return { uuid, devices: { staleDevices: prepared.staleDevices } }; + }), + ]; + } + + const uuids404 = results.filter( + ({ prepared }) => prepared.status === 'unknown', + ).map(({ uuid }) => uuid); + + const ok = results.filter(({ prepared }) => prepared.status === 'ok'); + + await Promise.all(ok.map(({ prepared }) => { + assert.ok(prepared.status === 'ok'); + return this.server.handlePreparedMultiDeviceMessage( + undefined, + prepared.result, + ); + })); + + return [ 200, { uuids404 } ]; + }, + ); + + this.router.put('/v1/messages/:uuid', async (params, body) => { + if (!body) { + return [ 400, { error: 'Missing body' } ]; + } + + const { messages }: JSONMessageList = JSON.parse( + Buffer.from(body).toString(), + ); + + // TODO(indutny): access key or auth! + + const prepared = await this.server.prepareMultiDeviceMessage( + this.device, + params.uuid as string, + messages, + ); + + switch (prepared.status) { + case 'ok': + await this.server.handlePreparedMultiDeviceMessage( + this.device, + prepared.result, + ); + return [ 200, { ok: true } ]; + case 'unknown': + return [ 404, { error: 'Not found' } ]; + case 'incomplete': + return [ 409, { + missingDevices: prepared.missingDevices, + extraDevices: prepared.extraDevices, + } ]; + case 'stale': + return [ 410, { staleDevices: prepared.staleDevices } ]; + } + }); + + this.router.put('/v1/devices/capabilities', requireAuth(async () => { + return [ 200, { ok: true } ]; + })); + + this.router.put( + '/v1/devices/unauthenticated_delivery', + requireAuth(async () => { + return [ 200, { ok: true } ]; + }), + ); + + this.router.get( + '/v1/certificate/delivery', + requireAuth(async () => { + const device = this.device; + if (!device) { + throw new Error('No support for unauthorized delivery'); + } + + const certificate = await this.server.getSenderCertificate(device); + + return [ + 200, + { certificate: certificate.serialize().toString('base64') }, + ]; + }), + ); + + this.router.put('/v1/devices/:code', async (params, body, headers) => { + const { error, username, password } = parseAuthHeader( + headers.authorization, + ); + if (error) { + return [ 400, { error } ]; + } + if (!username || !password) { + return [ 400, { error: 'Invalid authorization header' } ]; + } + if (!body) { + return [ 400, { error: 'Missing body' } ]; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const json = JSON.parse(Buffer.from(body).toString()); + if (typeof json.registrationId !== 'number') { + return [ 400, { error: 'Invalid registration id' } ]; + } + + const device = await server.provisionDevice( + username, + password, + params.code as string, + json.registrationId as number); + + return [ 200, { + deviceId: device.deviceId, + uuid: device.uuid, + pni: device.pni, + } ]; + }); + + // + // Groups + // + + this.router.get( + '/v1/certificate/group/:from/:to', + async (params) => { + const device = this.device; + if (!device) { + throw new Error('No support for unauthorized delivery'); + } + + return [ + 200, + { + credentials: await this.server.getGroupCredentials(device, { + from: parseInt(params.from as string, 10), + to: parseInt(params.to as string, 10), + }), + }, + ]; + }, + ); + + // + // Storage Service + // + + this.router.get('/v1/storage/auth', async () => { + const device = this.device; + if (!device) { + throw new Error('Storage credentials require authorization'); + } + + return [ 200, await server.getStorageAuth(device) ]; + }); + } + + public async start(): Promise { + debug('Got a websocket connection', this.request.url); + const url = this.request.url; + if (!url) { + throw new Error('Request must have url'); + } + + if (url.startsWith('/v1/websocket/provisioning')) { + const uuid = await this.server.generateUUID(); + try { + await this.handleProvision(uuid); + } catch (error) { + await this.server.releaseUUID(uuid); + throw error; + } + return; + } + + if (url.startsWith('/v1/websocket/?')) { + return await this.handleNormal(url); + } + } + + public async sendMessage(message: Buffer | 'empty'): Promise { + let response; + if (message === 'empty') { + response = await this.send('PUT', '/api/v1/queue/empty', {}); + } else { + response = await this.send('PUT', '/api/v1/message', { + body: message, + }); + } + + assert.strictEqual(response.status, 200, + `WebSocket send error ${response.status} ${response.message}`); + } + + // + // Service implementation + // + + protected async handleRequest( + request: WSRequest, + ): Promise { + return this.router.run(request); + } + + // + // Private + // + + private async handleProvision(uuid: UUID) { + { + const { status } = await this.send('PUT', '/v1/address', { + body: Proto.ProvisioningUuid.encode({ + uuid, + }).finish(), + }); + assert.strictEqual(status, 200); + } + + { + const { envelope } = await this.server.getProvisioningResponse(uuid); + const { status } = await this.send('PUT', '/v1/message', { + body: envelope, + }); + assert.strictEqual(status, 200); + } + } + + private async handleNormal(url: string) { + const query = parseURL(url, true).query || {}; + if (!query.login || + Array.isArray(query.login) || + !query.password || + Array.isArray(query.password)) { + debug('Unauthorized WebSocket connection'); + return; + } + + const device = await this.server.auth(query.login, query.password); + if (!device) { + debug('Invalid WebSocket credentials %j', query); + this.ws.close(); + return; + } + + this.device = device; + + this.ws.once('close', () => { + this.server.removeWebSocket(device, this); + }); + + await this.server.addWebSocket(device, this); + } +} diff --git a/src/server/ws/index.ts b/src/server/ws/index.ts new file mode 100644 index 0000000..2375907 --- /dev/null +++ b/src/server/ws/index.ts @@ -0,0 +1,4 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +export { Connection } from './connection'; diff --git a/src/server/ws/router.ts b/src/server/ws/router.ts new file mode 100644 index 0000000..bcb4f3b --- /dev/null +++ b/src/server/ws/router.ts @@ -0,0 +1,106 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import createDebug from 'debug'; +import { parse as parseURL } from 'url'; +import { ParsedUrlQuery, parse as parseQS } from 'querystring'; + +import { WSRequest, WSResponse } from './service'; + +import URLPattern from 'url-pattern'; + +const debug = createDebug('mock:ws:router'); + +export type AbbreviatedResponse = Readonly<[ + number, + unknown +]>; + +export type Handler = ( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + params: any, + body: Uint8Array | undefined, + headers: Record, + query?: ParsedUrlQuery, +) => Promise; + +type Route = Readonly<{ + method: string; + pattern: URLPattern; + handler: Handler; +}>; + +export class Router { + private readonly routes: Array = []; + + public register(method: string, pattern: string, handler: Handler): void { + this.routes.push({ + method, + pattern: new URLPattern(pattern), + handler, + }); + } + + public get(pattern: string, handler: Handler): void { + this.register('GET', pattern, handler); + } + + public put(pattern: string, handler: Handler): void { + this.register('PUT', pattern, handler); + } + + public async run(request: WSRequest): Promise { + const headers: Record = {}; + for (const pair of request.headers ?? []) { + const [ field, value = '' ] = pair.split(/\s*:\s*/, 2); + + headers[field.toLowerCase()] = value; + } + + let response: AbbreviatedResponse = [ 404, { error: 'Not found' } ]; + + debug('got request %s %s', request.verb, request.path); + + const { + pathname, + query, + } = parseURL(request.path ?? ''); + + for (const { method, pattern, handler } of this.routes) { + if (method !== request.verb) { + continue; + } + + const params = pattern.match(pathname ?? ''); + if (!params) { + continue; + } + + response = await handler( + params, + request.body ?? undefined, + headers, + query === null ? undefined : parseQS(query), + ); + break; + } + + const [ status, json ] = response; + + debug('response %s %s status=%d', request.verb, request.path, status); + + if (json instanceof Uint8Array) { + return { + status, + headers: [ 'Content-Type:application/x-protobuf' ], + body: Buffer.from(json), + }; + } + + return { + status, + headers: [ 'Content-Type:application/json' ], + body: Buffer.from(JSON.stringify(json)), + }; + } +} diff --git a/src/server/ws/service.ts b/src/server/ws/service.ts new file mode 100644 index 0000000..777b57a --- /dev/null +++ b/src/server/ws/service.ts @@ -0,0 +1,142 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import assert from 'assert'; +import Long from 'long'; +import WebSocket from 'ws'; +import createDebug from 'debug'; + +import { signalservice as SignalService } from '../../../protos/compiled'; + +export type WSRequest = SignalService.IWebSocketRequestMessage; +export type WSResponse = SignalService.IWebSocketResponseMessage; + +const debug = createDebug('mock:ws:service'); + +const WSMessage = SignalService.WebSocketMessage; + +interface RequestOptions { + readonly body?: Uint8Array; + readonly headers?: Array | null; +} + +export abstract class Service { + private readonly requests: Map void> = + new Map(); + private lastSentId = 0; + + constructor(protected readonly ws: WebSocket) { + this.ws = ws; + + this.ws.on('message', async (message) => { + try { + await this.onMessage(message); + } catch (error) { + assert(error instanceof Error); + debug('onMessage error', error.stack); + } + }); + this.ws.once('close', () => this.onClose()); + } + + public async send( + verb: string, + path: string, + options: RequestOptions, + ): Promise { + const id = this.lastSentId++; + + const packet = WSMessage.encode({ + type: WSMessage.Type.REQUEST, + request: { + ...options, + verb, + path, + id: Long.fromNumber(id), + }, + }).finish(); + + this.ws.send(packet); + + return await new Promise((resolve) => this.requests.set(id, resolve)); + } + + private async onMessage(raw: WebSocket.Data): Promise { + if (!(raw instanceof Uint8Array)) { + throw new Error('Unexpected input'); + } + + const message = WSMessage.decode(raw); + + if (message.type === WSMessage.Type.RESPONSE) { + const response = message.response; + if (!response) { + throw new Error('Expected response in message'); + } + + if (!response.id) { + throw new Error('Expected response.id'); + } + + const id = parseInt(response.id.toString(), 10); + if (isNaN(id)) { + throw new Error(`Invalid response.id: ${response.id}`); + } + + const resolve = this.requests.get(id); + if (!resolve) { + throw new Error(`Unexpected response: ${id}`); + } + + resolve(response); + } else if (message.type === WSMessage.Type.REQUEST) { + const request = message.request; + if (!request) { + throw new Error('Expected request in message'); + } + + if (!request.id) { + throw new Error('Expected request.id'); + } + + let response: WSResponse; + try { + response = await this.handleRequest(request); + } catch (error) { + assert(error instanceof Error); + console.error('handleRequest error', error.stack); + response = { + status: 500, + body: Buffer.from(JSON.stringify({ + error: error.stack, + })), + }; + } + + // Keepalive responses + const packet = WSMessage.encode({ + type: WSMessage.Type.RESPONSE, + response: { + ...response, + id: request.id, + }, + }).finish(); + + this.ws.send(packet); + } else { + debug('unsupported message', message); + } + } + + private onClose(): void { + for (const [ id, resolve ] of this.requests.entries()) { + resolve({ + id: Long.fromNumber(id), + status: 500, + message: 'WebSocket is gone', + }); + } + } + + protected abstract handleRequest(request: WSRequest): Promise; +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..e84a0d5 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,8 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +export type UUID = string; +export type ProvisioningCode = string; +export type RegistrationId = number; +export type DeviceId = number; +export type AttachmentId = string; diff --git a/src/util.ts b/src/util.ts new file mode 100644 index 0000000..536b507 --- /dev/null +++ b/src/util.ts @@ -0,0 +1,249 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import assert from 'assert'; +import { BufferReader } from 'protobufjs'; +import { ProtocolAddress } from '@signalapp/signal-client'; +import { stringify as stringifyUUID } from 'uuid'; + +type PromiseQueueEntry = Readonly<{ + value: T; + resolvePush?: () => void; +}>; + +export type PromiseQueueConfig = Readonly<{ + timeout?: number; +}>; + +export type MultiRecipientMessageRecipient = Readonly<{ + uuid: string; + deviceId: number; + registrationId: number; + material: Buffer; +}>; + +export type MultiRecipientMessage = Readonly<{ + recipients: ReadonlyArray; + commonMaterial: Buffer; +}>; + +export function generateRandomE164(): string { + // Generate random number + let number = '+141549'; + for (let i = 0; i < 5; i++) { + number += Math.floor(Math.random() * 10).toString(); + } + return number; +} + +export type ParseAuthHeaderResult = { + username: string; + password: string; + error?: undefined; +} | { + username?: undefined + password?: undefined; + error: string; +}; + +const MULTI_RECIPIENT_MESSAGE_VERSION = 0x22; +const MULTI_RECIPIENT_UUID_LEN = 16; +const MULTI_RECIPIENT_SHARED_MATERIAL_LEN = 48; + +export function parseAuthHeader(header?: string): ParseAuthHeaderResult { + if (!header) { + return { error: 'Missing Authorization header' }; + } + + const [ basic, base64 ] = header.split(/\s+/g, 2); + if (basic.toLowerCase() !== 'basic') { + return { error: `Unsupported authorization type ${basic}` }; + } + + let username: string; + let password: string; + try { + const decoded = Buffer.from(base64, 'base64').toString(); + [ username, password ] = decoded.split(':', 2); + } catch (error) { + assert(error instanceof Error); + return { error: error.message }; + } + + if (!username) { + return { error: 'Missing username' }; + } + + if (!password) { + return { error: 'Missing password' }; + } + + return { username, password }; +} + +export class PromiseQueue { + private readonly defaultTimeout: number | undefined; + private readonly entries: Array> = []; + private readonly resolvers: Array<(value: T) => void> = []; + + constructor(config: PromiseQueueConfig = {}) { + this.defaultTimeout = config.timeout; + } + + public async pushAndWait( + value: T, + timeout: number | undefined = this.defaultTimeout, + ): Promise { + // We were waiting for `.shift()` already + const resolveEntry = this.resolvers.shift(); + if (resolveEntry) { + resolveEntry(value); + return; + } + + // Not waiting for `.shift()` - queue. + return await new Promise((resolve, reject) => { + let timer: NodeJS.Timeout | undefined; + + const entry = { + value, + resolvePush() { + if (timer !== undefined) { + clearTimeout(timer); + } + timer = undefined; + + resolve(); + }, + }; + + const cancel = () => { + const index = this.entries.indexOf(entry); + if (index === -1) { + throw new Error('PromiseQueue entries bookkeeping error'); + } + this.entries.splice(index, 1); + + reject(new Error('PromiseQueue pushAndWait timeout')); + }; + + if (timeout !== undefined) { + timer = setTimeout(cancel, timeout); + } + + this.entries.push(entry); + }); + } + + public push( + value: T, + ): void { + // We were waiting for `.shift()` already + const resolveEntry = this.resolvers.shift(); + if (resolveEntry) { + resolveEntry(value); + return; + } + + this.entries.push({ value }); + } + + public async shift( + timeout: number | undefined = this.defaultTimeout, + ): Promise { + // `.pushAndWait()` was called before us + const entry = this.entries.shift(); + if (entry) { + if (entry.resolvePush) { + entry.resolvePush(); + } + return entry.value; + } + + return await new Promise((resolve, reject) => { + let timer: NodeJS.Timeout | undefined; + + const resolveEntry = (value: T) => { + if (timer !== undefined) { + clearTimeout(timer); + } + timer = undefined; + + resolve(value); + }; + + const cancel = () => { + const index = this.resolvers.indexOf(resolveEntry); + if (index === -1) { + throw new Error('PromiseQueue resolvers bookkeeping error'); + } + this.resolvers.splice(index, 1); + + reject(new Error('PromiseQueue shift timeout')); + }; + + if (timeout !== undefined) { + timer = setTimeout(cancel, timeout); + } + + this.resolvers.push(resolveEntry); + }); + } +} + +export function getEpochDay(): number { + return Math.floor(Date.now() / (24 * 3600 * 1000)); +} + +export function addressToString(address: ProtocolAddress): string { + return `${address.name()}.${address.deviceId()}`; +} + +export function parseMultiRecipientMessage( + message: Buffer, +): MultiRecipientMessage { + if (message[0] !== MULTI_RECIPIENT_MESSAGE_VERSION) { + throw new Error('Invalid multi-recipient message'); + } + + const reader = new BufferReader(message); + reader.skip(1); + + const count = reader.uint32(); + const recipients = new Array(); + while (recipients.length < count) { + const uuid = stringifyUUID(message.slice( + reader.pos, + reader.pos + MULTI_RECIPIENT_UUID_LEN, + )); + reader.skip(MULTI_RECIPIENT_UUID_LEN); + + const deviceId = reader.uint32(); + const registrationId = message.readUInt16BE(reader.pos); + reader.skip(2); + const material = message.slice( + reader.pos, + reader.pos + MULTI_RECIPIENT_SHARED_MATERIAL_LEN, + ); + assert.strictEqual(material.length, MULTI_RECIPIENT_SHARED_MATERIAL_LEN); + + reader.skip(MULTI_RECIPIENT_SHARED_MATERIAL_LEN); + + recipients.push({ uuid, deviceId, registrationId, material }); + } + + const commonMaterial = message.slice(reader.pos); + + return { recipients, commonMaterial }; +} + +export function combineMultiRecipientMessage({ material, commonMaterial }: { + material: Buffer; + commonMaterial: Buffer; +}): Buffer { + return Buffer.concat([ + Buffer.from([ MULTI_RECIPIENT_MESSAGE_VERSION ]), + material, + commonMaterial, + ]); +} diff --git a/test/crypto-test.ts b/test/crypto-test.ts new file mode 100644 index 0000000..479285e --- /dev/null +++ b/test/crypto-test.ts @@ -0,0 +1,39 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import assert from 'assert'; +import { PrivateKey } from '@signalapp/signal-client'; + +import { deriveAccessKey, generateServerCertificate } from '../src/crypto'; + +describe('crypto', () => { + // Verify that the generated certificate is valid within our trust root + it('should create ServerCertificate', () => { + const root = PrivateKey.generate(); + + const { + certificate, + } = generateServerCertificate(root); + + if (!certificate.signature || !certificate.certificate) { + throw new Error('Invalid cert'); + } + + assert.ok(root.getPublicKey().verify( + Buffer.from(certificate.certificate), + Buffer.from(certificate.signature), + )); + }); + + // Make sure that access key has correct value when derived from a constant + // input. + it('should derive access key', () => { + const profileKey = Buffer.alloc(32).fill(42); + const accessKey = deriveAccessKey(profileKey); + + assert.strictEqual( + accessKey.toString('base64'), + '2KEiuqkfT794/nwyqqVUYQ==', + ); + }); +}); diff --git a/test/primary-device-test.ts b/test/primary-device-test.ts new file mode 100644 index 0000000..b68cb50 --- /dev/null +++ b/test/primary-device-test.ts @@ -0,0 +1,98 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import assert from 'assert'; +import { v4 as uuidv4 } from 'uuid'; +import { PrivateKey } from '@signalapp/signal-client'; +import { ServerSecretParams } from '@signalapp/signal-client/zkgroup'; + +import { + generateSenderCertificate, + generateServerCertificate, +} from '../src/crypto'; +import { Device } from '../src/data/device'; +import { PrimaryDevice } from '../src/api/primary-device'; + +const trustRoot = PrivateKey.generate(); +const serverCert = generateServerCertificate(trustRoot); +const serverSecretParams = ServerSecretParams.generate(); + +async function createPrimaryDevice(name: string): Promise { + const uuid = uuidv4(); + const pni = uuidv4(); + + const device = new Device({ + uuid, + pni, + number: '+1', + deviceId: 1, + registrationId: 1, + }); + + const primary = new PrimaryDevice(device, { + trustRoot: trustRoot.getPublicKey(), + serverPublicParams: serverSecretParams.getPublicParams(), + profileName: name, + contacts: {}, + groups: {}, + + async getSenderCertificate() { + return generateSenderCertificate(serverCert, { + number: device.number, + uuid: device.uuid, + deviceId: device.deviceId, + identityKey: await device.getIdentityKey(), + }); + }, + + async send() { + throw new Error('Should not be called'); + }, + async getDeviceByUUID() { + throw new Error('Not implemented'); + }, + async issueProfileKeyCredential() { + throw new Error('Not implemented'); + }, + async createGroup() { + throw new Error('Not implemented'); + }, + async getStorageManifest() { + throw new Error('Not implemented'); + }, + async getStorageItem() { + throw new Error('Not implemented'); + }, + async waitForStorageManifest() { + throw new Error('Not implemented'); + }, + async applyStorageWrite() { + throw new Error('Not implemented'); + }, + }); + + await primary.init(); + + return primary; +} + +// The idea of the test here is to verify that PrimaryDevice is capable of: +// - Generating prekeys +// - Adding prekeys from other accounts +// - Encrypting/decrypting messages +describe('PrimaryDevice', () => { + it('should send and receive messages', async () => { + const alice = await createPrimaryDevice('Alice'); + const bob = await createPrimaryDevice('Bob'); + + const key = await bob.device.popSingleUseKey(); + await alice.addSingleUseKey(bob.device, key); + + const encrypted = await alice.encryptText(bob.device, 'Hello'); + await bob.receive(alice.device, encrypted); + + const message = await bob.waitForMessage(); + assert.strictEqual(message.body, 'Hello'); + assert.strictEqual(message.source, alice.device); + }); +}); diff --git a/test/util-test.ts b/test/util-test.ts new file mode 100644 index 0000000..eeda5d2 --- /dev/null +++ b/test/util-test.ts @@ -0,0 +1,97 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import assert from 'assert'; + +import { PromiseQueue } from '../src/util'; + +describe('util', () => { + describe('PromiseQueue', () => { + it('should pushAndWait and shift', async () => { + const q = new PromiseQueue(); + + const push = q.pushAndWait(42); + + assert.strictEqual(await q.shift(), 42); + await push; + }); + + it('should push and shift', async () => { + const q = new PromiseQueue(); + + q.push(42); + + assert.strictEqual(await q.shift(), 42); + }); + + it('should shift and pushAndWait', async () => { + const q = new PromiseQueue(); + + const shift = q.shift(); + + await q.pushAndWait(23); + + assert.strictEqual(await shift, 23); + }); + + it('should shift and push', async () => { + const q = new PromiseQueue(); + + const shift = q.shift(); + + q.push(23); + + assert.strictEqual(await shift, 23); + }); + + it('should timeout on push', async () => { + const q = new PromiseQueue(); + + await assert.rejects(async () => { + await q.pushAndWait(23, 10); + }, { message: 'PromiseQueue pushAndWait timeout' }); + }); + + it('should not timeout on push', async () => { + const q = new PromiseQueue(); + + const push = q.pushAndWait(15, 1000); + + assert.strictEqual(await q.shift(), 15); + await push; + }); + + it('should timeout on shift', async () => { + const q = new PromiseQueue(); + + await assert.rejects(async () => { + await q.shift(10); + }, { message: 'PromiseQueue shift timeout' }); + }); + + it('should not timeout on shift', async () => { + const q = new PromiseQueue(); + + const shift = q.shift(1000); + + await q.pushAndWait(17); + assert.strictEqual(await shift, 17); + }); + + it('should apply default timeouts on push', async () => { + const q = new PromiseQueue({ timeout: 10 }); + + await assert.rejects(async () => { + await q.pushAndWait(23); + }, { message: 'PromiseQueue pushAndWait timeout' }); + }); + + it('should apply default timeouts on shift', async () => { + const q = new PromiseQueue({ timeout: 10 }); + + await assert.rejects(async () => { + await q.shift(); + }, { message: 'PromiseQueue shift timeout' }); + }); + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..d8a7505 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + /* Visit https://aka.ms/tsconfig.json to read more about this file */ + + /* Basic Options */ + "incremental": true, /* Enable incremental compilation */ + "target": "es2020", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */ + "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */ + "declaration": true, /* Generates corresponding '.d.ts' file. */ + + /* Strict Type-Checking Options */ + "strict": true, /* Enable all strict type-checking options. */ + + /* Additional Checks */ + "noUnusedLocals": true, /* Report errors on unused locals. */ + "noImplicitOverride": true, + "noImplicitReturns": true, + + "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ + + /* Advanced Options */ + "skipLibCheck": true, /* Skip type checking of declaration files. */ + "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ + }, + "include": ["src/**/*", "test/**/*"], +}