Public release
This commit is contained in:
commit
99d76b798c
9
.eslintignore
Normal file
9
.eslintignore
Normal 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
76
.eslintrc.yml
Normal 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
16
.gitignore
vendored
Normal 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
661
LICENSE
Normal 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
27
README.md
Normal 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
33
certs/Makefile
Normal 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
11
certs/README.md
Normal 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
33
certs/ca-cert.pem
Normal file
@ -0,0 +1,33 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
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
|
||||
f+YofTwPTFQMWqBjD1ml4UWd2egkdC//OA==
|
||||
-----END CERTIFICATE-----
|
||||
1
certs/ca-cert.srl
Normal file
1
certs/ca-cert.srl
Normal file
@ -0,0 +1 @@
|
||||
AB0BE03708DC8AD5
|
||||
54
certs/ca-key.pem
Normal file
54
certs/ca-key.pem
Normal 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
22
certs/ca.cnf
Normal 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
33
certs/cert.pem
Normal file
@ -0,0 +1,33 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
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
|
||||
95xSZsZK6Ww=
|
||||
-----END CERTIFICATE-----
|
||||
28
certs/csr.pem
Normal file
28
certs/csr.pem
Normal 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
66
certs/full-cert.pem
Normal file
@ -0,0 +1,66 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
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
|
||||
95xSZsZK6Ww=
|
||||
-----END CERTIFICATE-----
|
||||
-----BEGIN CERTIFICATE-----
|
||||
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
|
||||
f+YofTwPTFQMWqBjD1ml4UWd2egkdC//OA==
|
||||
-----END CERTIFICATE-----
|
||||
11
certs/generate-trust-root.js
Normal file
11
certs/generate-trust-root.js
Normal 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));
|
||||
12
certs/generate-zk-params.js
Normal file
12
certs/generate-zk-params.js
Normal 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
51
certs/key.pem
Normal file
@ -0,0 +1,51 @@
|
||||
-----BEGIN RSA PRIVATE KEY-----
|
||||
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
|
||||
-----END RSA PRIVATE KEY-----
|
||||
27
certs/main.cnf
Normal file
27
certs/main.cnf
Normal 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
4
certs/trust-root.json
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"privateKey": "wNgId00JnobLvJLxeIyigS0DdNtNwwgnBoa9N2/JSnQ=",
|
||||
"publicKey": "BYWSmRa6qWgg25jjBrX/I97gB3+FRkSJH4+30s4YSlc4"
|
||||
}
|
||||
4
certs/zk-params.json
Normal file
4
certs/zk-params.json
Normal 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
70
package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
40
protos/ContactDiscovery.proto
Normal file
40
protos/ContactDiscovery.proto
Normal 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
15
protos/CrashReports.proto
Normal 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;
|
||||
}
|
||||
33
protos/DeviceMessages.proto
Normal file
33
protos/DeviceMessages.proto
Normal 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
10
protos/DeviceName.proto
Normal 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
235
protos/Groups.proto
Normal 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; // Server’s signature over serialized actions
|
||||
uint32 changeEpoch = 3; // Allows clients to decide whether their change logic can successfully apply this diff
|
||||
}
|
||||
|
||||
message GroupChanges {
|
||||
message GroupChangeState {
|
||||
GroupChange groupChange = 1;
|
||||
Group groupState = 2;
|
||||
}
|
||||
|
||||
repeated GroupChangeState groupChanges = 1;
|
||||
}
|
||||
|
||||
message GroupAttributeBlob {
|
||||
oneof content {
|
||||
string title = 1;
|
||||
bytes avatar = 2;
|
||||
uint32 disappearingMessagesDuration = 3;
|
||||
string descriptionText = 4;
|
||||
}
|
||||
}
|
||||
|
||||
message GroupExternalCredential {
|
||||
string token = 1;
|
||||
}
|
||||
|
||||
message GroupInviteLink {
|
||||
message GroupInviteLinkContentsV1 {
|
||||
bytes groupMasterKey = 1;
|
||||
bytes inviteLinkPassword = 2;
|
||||
}
|
||||
|
||||
oneof contents {
|
||||
GroupInviteLinkContentsV1 v1Contents = 1;
|
||||
}
|
||||
}
|
||||
|
||||
message GroupJoinInfo {
|
||||
bytes publicKey = 1;
|
||||
bytes title = 2;
|
||||
string avatar = 3;
|
||||
uint32 memberCount = 4;
|
||||
AccessControl.AccessRequired addFromInviteLink = 5;
|
||||
uint32 version = 6;
|
||||
bool pendingAdminApproval = 7;
|
||||
bytes descriptionBytes = 8;
|
||||
}
|
||||
107
protos/LibSignal-Client.proto
Normal file
107
protos/LibSignal-Client.proto
Normal 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
6
protos/README.md
Normal 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
562
protos/SignalService.proto
Normal 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
144
protos/SignalStorage.proto
Normal 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
16
protos/Stickers.proto
Normal 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
34
protos/SubProtocol.proto
Normal 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;
|
||||
}
|
||||
69
protos/UnidentifiedDelivery.proto
Normal file
69
protos/UnidentifiedDelivery.proto
Normal 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
101
src/api/group.ts
Normal 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
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
487
src/api/server.ts
Normal 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
459
src/api/storage-state.ts
Normal 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
14
src/constants.ts
Normal 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
356
src/crypto.ts
Normal 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
24
src/data/attachment.ts
Normal 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
62
src/data/certificates.ts
Normal 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
45
src/data/contacts.ts
Normal 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
117
src/data/device.ts
Normal 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
34
src/data/group.ts
Normal 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
30
src/data/json.d.ts
vendored
Normal 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
21
src/index.ts
Normal 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
841
src/server/base.ts
Normal 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
71
src/server/group.ts
Normal 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
451
src/server/http.ts
Normal 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
464
src/server/ws/connection.ts
Normal 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
4
src/server/ws/index.ts
Normal 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
106
src/server/ws/router.ts
Normal 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
142
src/server/ws/service.ts
Normal 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
8
src/types.ts
Normal 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
249
src/util.ts
Normal 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
39
test/crypto-test.ts
Normal 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==',
|
||||
);
|
||||
});
|
||||
});
|
||||
98
test/primary-device-test.ts
Normal file
98
test/primary-device-test.ts
Normal 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
97
test/util-test.ts
Normal 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
26
tsconfig.json
Normal 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/**/*"],
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user