Public release

This commit is contained in:
Fedor Indutny 2021-03-13 17:54:01 -08:00 committed by Fedor Indutnyy
commit 99d76b798c
59 changed files with 7964 additions and 0 deletions

9
.eslintignore Normal file
View File

@ -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/*

76
.eslintrc.yml Normal file
View File

@ -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

16
.gitignore vendored Normal file
View File

@ -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/

661
LICENSE Normal file
View File

@ -0,0 +1,661 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU 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.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU 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 <https://www.gnu.org/licenses/>.
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
<https://www.gnu.org/licenses/>.

27
README.md Normal file
View File

@ -0,0 +1,27 @@
<!-- Copyright 2021-2022 Signal Messenger, LLC -->
<!-- SPDX-License-Identifier: AGPL-3.0-only -->
# 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

33
certs/Makefile Normal file
View File

@ -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

11
certs/README.md Normal file
View File

@ -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

33
certs/ca-cert.pem Normal file
View File

@ -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-----

1
certs/ca-cert.srl Normal file
View File

@ -0,0 +1 @@
AB0BE03708DC8AD5

54
certs/ca-key.pem Normal file
View File

@ -0,0 +1,54 @@
-----BEGIN ENCRYPTED PRIVATE KEY-----
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
Eng7sf9fuShxeDTuP5YZI/AcNQ==
-----END ENCRYPTED PRIVATE KEY-----

22
certs/ca.cnf Normal file
View File

@ -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

33
certs/cert.pem Normal file
View File

@ -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-----

28
certs/csr.pem Normal file
View File

@ -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-----

66
certs/full-cert.pem Normal file
View File

@ -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-----

View File

@ -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));

View File

@ -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));

51
certs/key.pem Normal file
View File

@ -0,0 +1,51 @@
-----BEGIN RSA PRIVATE KEY-----
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
-----END RSA PRIVATE KEY-----

27
certs/main.cnf Normal file
View File

@ -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

4
certs/trust-root.json Normal file
View File

@ -0,0 +1,4 @@
{
"privateKey": "wNgId00JnobLvJLxeIyigS0DdNtNwwgnBoa9N2/JSnQ=",
"publicKey": "BYWSmRa6qWgg25jjBrX/I97gB3+FRkSJH4+30s4YSlc4"
}

4
certs/zk-params.json Normal file
View File

@ -0,0 +1,4 @@
{
"secretParams": "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",
"publicParams": "APb+6dBgH6bkG18MCWlTTRGKv9Yn0azuU23WQ77i3EEVBn8bNGwMP7+Js1yO9nLo4//WoZNwFo26gXQbk++3XQTme8Np5itBYKyWTDkah/WoulWtIPM6jMkHCBrZtl/EUWazqqpEZmmGbQIdxKKD4wNpt02/5l2viJi3K+6P6dhjgsPTChB3uKS1ygLtptvlwfjipX2hmIO1eU/QVml+aWwMC0g2dTZFyLBVFBE4ATZieKZHGXiRsh1qUh9IZ7i2X3IKLNLLf+RaVof4tgMMuTvkrvhITUHsXx0By/pGkr9inqI4FQGrJi301PYwN1WwQGuCM5dyuevqXDPC5llo+GlSZZLGlmmusFbSTXbH2/veGbFwhQXSaAq+lgCGsnIbLA=="
}

70
package.json Normal file
View File

@ -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"
}
}

View File

@ -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;
}

15
protos/CrashReports.proto Normal file
View File

@ -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;
}

View File

@ -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;
}

10
protos/DeviceName.proto Normal file
View File

@ -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;
}

235
protos/Groups.proto Normal file
View File

@ -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; // Servers 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;
}

View File

@ -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;
}

6
protos/README.md Normal file
View File

@ -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

562
protos/SignalService.proto Normal file
View File

@ -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;
}

144
protos/SignalStorage.proto Normal file
View File

@ -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;
}

16
protos/Stickers.proto Normal file
View File

@ -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;
}

34
protos/SubProtocol.proto Normal file
View File

@ -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;
}

View File

@ -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;
}

101
src/api/group.ts Normal file
View File

@ -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<GroupMember>;
}>;
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));
}
}

1098
src/api/primary-device.ts Normal file

File diff suppressed because it is too large Load Diff

487
src/api/server.ts Normal file
View File

@ -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<PrimaryDevice>;
}
export type PendingProvision = {
complete(response: PendingProvisionResponse): Promise<Device>;
}
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<string, PrimaryDevice>();
private readonly knownNumbers = new Set<string>();
private https: https.Server | undefined;
private emptyAttachment: Proto.IAttachmentPointer | undefined;
private provisionQueue: PromiseQueue<PendingProvision>;
private provisionResultQueueByCode =
new Map<ProvisioningCode, PromiseQueue<Device>>();
private provisionResultQueueByKey = new Map<string, PromiseQueue<Device>>();
private manifestQueueByUuid = new Map<UUID, PromiseQueue<number>>();
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<void> {
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<void> {
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<PendingProvision> {
return await this.provisionQueue.shift();
}
private async waitForStorageManifest(
device: Device,
afterVersion?: number,
): Promise<void> {
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<PrimaryDevice> {
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<Device> {
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<ProvisioningResponse> {
const responseQueue = this.createQueue<PendingProvisionResponse>();
const resultQueue = this.createQueue<Device>();
await this.provisionQueue.pushAndWait({
complete: async (response) => {
await responseQueue.pushAndWait(response);
return await resultQueue.shift();
},
});
const {
// tsdevice:/?uuid=<uuid>&pub_key=<base64>
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<void> {
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<void> {
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<Device> {
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<void> {
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<T>(): PromiseQueue<T> {
return new PromiseQueue({
timeout: this.config.timeout,
});
}
private async generateNumber(): Promise<string> {
let number: string;
do {
number = generateRandomE164();
} while (this.knownNumbers.has(number));
this.knownNumbers.add(number);
return number;
}
}

459
src/api/storage-state.ts Normal file
View File

@ -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<Proto.IStorageRecord>;
removed: ReadonlyArray<Proto.IStorageRecord>;
}>;
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<StorageStateItem>;
constructor(
public readonly version: number,
items: ReadonlyArray<StorageStateItemOptions>,
) {
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<Proto.IStorageItem>();
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<string, Proto.IStorageRecord>();
const removedIds = new Map<string, Proto.IStorageRecord>();
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);
}
}

14
src/constants.ts Normal file
View File

@ -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;

356
src/crypto.ts Normal file
View File

@ -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();
}

24
src/data/attachment.ts Normal file
View File

@ -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,
};
}

62
src/data/certificates.ts Normal file
View File

@ -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<string> {
const raw = await fs.readFile(path.join(CERTS_DIR, file));
return raw.toString();
}
async function loadJSONProperty(
file: string,
property: string,
): Promise<string> {
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<Certificates> {
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,
};
}

45
src/data/contacts.ts Normal file
View File

@ -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<Contact>): 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<number> = [];
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());
}

117
src/data/device.ts Normal file
View File

@ -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<PreKey>;
}
export interface SingleUseKey {
readonly identityKey: PublicKey;
readonly signedPreKey: SignedPreKey;
readonly preKey: PreKey | undefined;
}
interface InternalDeviceKeys {
readonly identityKey: PublicKey;
readonly signedPreKey: SignedPreKey;
readonly preKeys: Array<PreKey>;
}
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<void> {
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<PublicKey> {
if (!this.keys) {
throw new Error('No keys available for device');
}
return this.keys.identityKey;
}
public async popSingleUseKey(): Promise<SingleUseKey> {
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<number> {
if (!this.keys) {
throw new Error('No keys available for device');
}
return this.keys.preKeys.length;
}
}

34
src/data/group.ts Normal file
View File

@ -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),
};
}
}

30
src/data/json.d.ts vendored Normal file
View File

@ -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<JSONMessage>;
timestamp: number;
}>;

21
src/index.ts Normal file
View File

@ -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 };

841
src/server/base.ts Normal file
View File

@ -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<number>;
} | {
status: 'incomplete';
missingDevices: ReadonlyArray<number>;
extraDevices: ReadonlyArray<number>;
} | {
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<void>;
}
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<string, Array<Device>>();
private readonly devicesByUUID = new Map<UUID, Device>();
private readonly usedUUIDs = new Set<string>();
private readonly devicesByAuth = new Map<string, AuthEntry>();
private readonly storageAuthByUsername = new Map<string, StorageAuthEntry>();
private readonly storageAuthByDevice = new Map<Device, StorageAuthEntry>();
private readonly storageManifestByUuid =
new Map<UUID, Proto.IStorageManifest>();
private readonly storageItemsByUuid =
new Map<UUID, Map<string, Buffer>>();
private readonly provisioningCodes =
new Map<string, Map<ProvisioningCode, UUID>>();
private readonly attachments = new Map<AttachmentId, Buffer>();
private readonly webSockets = new Map<Device, Set<WebSocket>>();
private readonly messageQueue =
new WeakMap<Device, Array<MessageQueueEntry>>();
private readonly groups = new Map<string, ServerGroup>();
protected privCertificate: ServerCertificate | undefined;
protected privZKSecret: ServerSecretParams | undefined;
//
// Provisioning
//
public async generateUUID(): Promise<UUID> {
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<void> {
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<RegistrationId> {
return Math.max(1, (Math.random() * 0x4000) | 0);
}
public abstract getProvisioningResponse(
uuid: UUID
): Promise<ProvisioningResponse>;
public async registerDevice({
uuid,
pni,
number,
registrationId,
}: RegisterDeviceOptions): Promise<Device> {
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<ProvisioningCode> {
let entry = this.provisioningCodes.get(number);
if (!entry) {
entry = new Map<ProvisioningCode, UUID>();
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<Device> {
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<void> {
debug('setting device=%s keys', device.debugId);
await device.setKeys(keys);
}
//
// Auth
//
async auth(username: string, password: string): Promise<Device | undefined> {
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<AttachmentId> {
const id = ATTACHMENT_PREFIX +
crypto.createHash('sha256').update(attachment).digest('hex');
this.attachments.set(id, attachment);
return id;
}
async fetchAttachment(id: AttachmentId): Promise<Buffer | undefined> {
return this.attachments.get(id);
}
//
// Messages
//
public async prepareMultiDeviceMessage(
source: Device | undefined,
targetUUID: UUID,
messages: ReadonlyArray<JSONMessage>,
): Promise<PrepareMultiDeviceMessageResult> {
const devices = await this.getAllDevicesByUUID(targetUUID);
if (devices.length === 0) {
return { status: 'unknown' };
}
const deviceById = new Map<DeviceId, Device>();
for (const device of devices) {
deviceById.set(device.deviceId, device);
}
const result = new Array<[ Device, JSONMessage ]>();
const extraDevices = new Set<DeviceId>();
const staleDevices = new Set<DeviceId>();
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<void> {
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<void>;
public async addWebSocket(device: Device, socket: WebSocket): Promise<void> {
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<void> {
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<void>(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<void>((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<ServerGroup> {
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<ServerGroup | undefined> {
return this.groups.get(publicParams.serialize().toString('base64'));
}
//
// Storage
//
public async getStorageAuth(device: Device): Promise<StorageCredentials> {
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<Device | undefined> {
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<Proto.IStorageManifest | undefined> {
return this.storageManifestByUuid.get(device.uuid);
}
public async applyStorageWrite(
device: Device,
{
manifest,
clearAll,
insertItem,
deleteKey,
}: Proto.IWriteOperation,
shouldNotify = true,
): Promise<StorageWriteResult> {
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<void> {
this.storageItemsByUuid.get(device.uuid)?.clear();
}
private async setStorageItem(
device: Device,
key: Buffer,
value: Buffer,
): Promise<void> {
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<Buffer | undefined> {
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<void> {
const map = this.storageItemsByUuid.get(device.uuid);
if (!map) {
return;
}
map.delete(key.toString('hex'));
}
protected abstract onStorageManifestUpdate(
device: Device,
version: Long,
): Promise<void>;
//
// Utils
//
public async getDevice(
number: string,
deviceId: DeviceId,
): Promise<Device | undefined> {
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<Device | undefined> {
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<ReadonlyArray<Device>> {
const primary = this.devicesByUUID.get(uuid);
if (!primary) {
return [];
}
return this.devices.get(primary.number) || [];
}
public async getSenderCertificate(
device: Device,
): Promise<SenderCertificate> {
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<GroupCredentials> {
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<ProfileKeyCredentialResponse | undefined> {
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<void> {
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');
}
}

71
src/server/group.ts Normal file
View File

@ -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,
} ],
};
}
}

451
src/server/http.ts Normal file
View File

@ -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<Device>,
): Promise<void> => {
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<Device | undefined> {
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<ServerGroup | undefined> {
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<Device | undefined> {
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);
};
};

464
src/server/ws/connection.ts Normal file
View File

@ -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<UUID, Array<JSONMessage>>();
for (const recipient of recipients) {
const {
uuid,
deviceId,
registrationId,
material,
} = recipient;
let list: Array<JSONMessage> | 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<void> {
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<void> {
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<WSResponse> {
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);
}
}

4
src/server/ws/index.ts Normal file
View File

@ -0,0 +1,4 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
export { Connection } from './connection';

106
src/server/ws/router.ts Normal file
View File

@ -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<string, string>,
query?: ParsedUrlQuery,
) => Promise<AbbreviatedResponse>;
type Route = Readonly<{
method: string;
pattern: URLPattern;
handler: Handler;
}>;
export class Router {
private readonly routes: Array<Route> = [];
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<WSResponse> {
const headers: Record<string, string> = {};
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)),
};
}
}

142
src/server/ws/service.ts Normal file
View File

@ -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<string> | null;
}
export abstract class Service {
private readonly requests: Map<number, (res: WSResponse) => 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<WSResponse> {
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<void> {
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<WSResponse>;
}

8
src/types.ts Normal file
View File

@ -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;

249
src/util.ts Normal file
View File

@ -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<T> = 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<MultiRecipientMessageRecipient>;
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<T> {
private readonly defaultTimeout: number | undefined;
private readonly entries: Array<PromiseQueueEntry<T>> = [];
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<void> {
// 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<T> {
// `.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<MultiRecipientMessageRecipient>();
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,
]);
}

39
test/crypto-test.ts Normal file
View File

@ -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==',
);
});
});

View File

@ -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<PrimaryDevice> {
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);
});
});

97
test/util-test.ts Normal file
View File

@ -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<number>();
const push = q.pushAndWait(42);
assert.strictEqual(await q.shift(), 42);
await push;
});
it('should push and shift', async () => {
const q = new PromiseQueue<number>();
q.push(42);
assert.strictEqual(await q.shift(), 42);
});
it('should shift and pushAndWait', async () => {
const q = new PromiseQueue<number>();
const shift = q.shift();
await q.pushAndWait(23);
assert.strictEqual(await shift, 23);
});
it('should shift and push', async () => {
const q = new PromiseQueue<number>();
const shift = q.shift();
q.push(23);
assert.strictEqual(await shift, 23);
});
it('should timeout on push', async () => {
const q = new PromiseQueue<number>();
await assert.rejects(async () => {
await q.pushAndWait(23, 10);
}, { message: 'PromiseQueue pushAndWait timeout' });
});
it('should not timeout on push', async () => {
const q = new PromiseQueue<number>();
const push = q.pushAndWait(15, 1000);
assert.strictEqual(await q.shift(), 15);
await push;
});
it('should timeout on shift', async () => {
const q = new PromiseQueue<number>();
await assert.rejects(async () => {
await q.shift(10);
}, { message: 'PromiseQueue shift timeout' });
});
it('should not timeout on shift', async () => {
const q = new PromiseQueue<number>();
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<number>({ 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<number>({ timeout: 10 });
await assert.rejects(async () => {
await q.shift();
}, { message: 'PromiseQueue shift timeout' });
});
});
});

26
tsconfig.json Normal file
View File

@ -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/**/*"],
}