Move mock server into the repo

Co-authored-by: trevor-signal <trevor@signal.org>
Co-authored-by: jamiebuilds-signal <jamie@signal.org>
This commit is contained in:
Fedor Indutny
2026-08-13 12:02:55 -07:00
committed by GitHub
co-authored by trevor-signal jamiebuilds-signal
parent f1b086f96e
commit ae68dc8b49
110 changed files with 20952 additions and 113 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@signalapp/mock-server': major
---
gRPC support for username hash/link endpoints
+37 -1
View File
@@ -1767,6 +1767,7 @@
"packages/lame/**",
"packages/mute-state-change/**",
"packages/windows-ucv/**",
"packages/mock-server/**",
"scripts/**",
"test/test.mjs",
".pnpmfile.mjs",
@@ -1784,7 +1785,7 @@
}
},
{
"files": ["packages/windows-ucv/**"],
"files": ["packages/windows-ucv/**", "packages/mock-server/**"],
"env": { "node": true },
"globals": {
"window": "readonly",
@@ -1824,6 +1825,41 @@
]
}
},
{
"files": [
"packages/mock-server/{src,test}/**/*.ts",
"packages/mock-server/certs/**/*.js"
],
"rules": {
"eslint/default-case": "off",
"eslint/eqeqeq": "off",
"eslint/max-classes-per-file": "off",
"eslint/no-await-in-loop": "off",
"eslint/no-bitwise": "off",
"eslint/no-constant-condition": "off",
"eslint/no-else-return": "off",
"eslint/no-nested-ternary": "off",
"eslint/no-param-reassign": "off",
"eslint/no-plusplus": "off",
"eslint/no-restricted-globals": "off",
"eslint/no-shadow": "off",
"eslint/no-undef": "off",
"eslint/object-shorthand": "off",
"import/extensions": "off",
"promise/prefer-await-to-then": "off",
"signal-desktop/enforce-array-buffer": "off",
"signal-desktop/enforce-license-comments": "off",
"typescript/consistent-type-definitions": "off",
"typescript/consistent-type-exports": "off",
"typescript/consistent-type-imports": "off",
"typescript/no-explicit-any": "off",
"typescript/no-unnecessary-parameter-property-assignment": "off",
"typescript/parameter-properties": "off",
"typescript/prefer-readonly": "off",
"unicorn/prefer-node-protocol": "off",
"eslint/no-console": "off"
}
},
// special cases
{
+5
View File
@@ -29,6 +29,11 @@ packages/*/node_modules/**
packages/lame/wrapper.mjs
packages/lame/lame-*/
packages/windows-ucv/dist/**
packages/mock-server/src/**/*.js
packages/mock-server/src/**/*.d.ts
packages/mock-server/test/**/*.js
packages/mock-server/protos/*.d.ts
packages/mock-server/protos/*.js
danger/node_modules/**
sticker-creator/node_modules/**
components/**
+9
View File
@@ -113,6 +113,15 @@ const config = {
'packages/windows-ucv': {
ignoreDependencies: ['node-addon-api'],
},
'packages/mock-server': {
entry: [
'src/index.ts!',
'certs/generate-trust-root.js',
'certs/generate-zk-params.js',
],
ignoreFiles: ['src/**/*.ts', 'test/**/*.ts'],
ignoreDependencies: ['.*'],
},
'sticker-creator': {
project: [
'src/**/*.{ts,tsx}!',
+1 -1
View File
@@ -162,7 +162,7 @@
"@react-spring/web": "10.0.3",
"@signalapp/lame": "workspace:*",
"@signalapp/minimask": "1.0.1",
"@signalapp/mock-server": "25.2.0",
"@signalapp/mock-server": "workspace:*",
"@signalapp/parchment-cjs": "3.0.1",
"@signalapp/quill-cjs": "2.1.2",
"@storybook/addon-a11y": "8.4.4",
+45
View File
@@ -0,0 +1,45 @@
# Copyright 2025 Signal Messenger, LLC
# SPDX-License-Identifier: AGPL-3.0-only
name: Publish
on:
push:
tags:
- 'v[0-9]+.[0-9]+.*'
jobs:
publish:
if: ${{ github.repository == 'signalapp/Mock-Signal-Server-Private' }}
name: Publish
runs-on: ubuntu-latest
timeout-minutes: 45
permissions:
# Required for OIDC
id-token: 'write'
# Needed for ncipollo/release-action.
contents: 'write'
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4
- name: Setup node.js
uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
with:
node-version-file: '.nvmrc'
registry-url: 'https://registry.npmjs.org/'
- name: Install node_modules
run: pnpm install --frozen-lockfile
- name: Lint
run: pnpm run lint
- name: Test
run: pnpm test
- name: Publish
run: pnpm publish --access public --no-git-checks
+33
View File
@@ -0,0 +1,33 @@
# Copyright 2025 Signal Messenger, LLC
# SPDX-License-Identifier: AGPL-3.0-only
name: Test
on:
push:
branches:
- main
pull_request:
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4
- name: Setup node.js
uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
with:
node-version-file: '.nvmrc'
- name: Install node_modules
run: pnpm install
- name: Run lint
run: pnpm run lint
- name: Run tests
run: pnpm test
+18
View File
@@ -0,0 +1,18 @@
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
protos/server/**/*.md
scripts/**/*.js
scripts/**/*.d.ts
*.tsbuildinfo
.eslintcache
dist/
.vscode
+1
View File
@@ -0,0 +1 @@
24.11.1
+3
View File
@@ -0,0 +1,3 @@
**/*.js
**/*.d.ts
node_modules/**/*
+7
View File
@@ -0,0 +1,7 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
module.exports = {
singleQuote: true,
bracketSpacing: true,
};
+661
View File
@@ -0,0 +1,661 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.
+25
View File
@@ -0,0 +1,25 @@
<!-- Copyright 2021 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-server
```
## 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
+34
View File
@@ -0,0 +1,34 @@
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 -extensions v3_ca -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 -extensions v3_ca -key key.pem -out csr.pem
cert.pem: csr.pem ca-cert.pem ca-key.pem
openssl x509 -req \
-extfile main.cnf \
-extensions v3_ca \
-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
+14
View File
@@ -0,0 +1,14 @@
<!-- Copyright 2026 Signal Messenger, LLC -->
<!-- SPDX-License-Identifier: AGPL-3.0-only -->
## 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
View File
@@ -0,0 +1,33 @@
-----BEGIN CERTIFICATE-----
MIIFwDCCA6igAwIBAgIUH4s+Chj0H/rJGlds6A9JcN9f5BowDQYJKoZIhvcNAQEL
BQAwgY8xCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJDQTELMAkGA1UEBwwCTEExFDAS
BgNVBAoMC1NpZ25hbCBNb2NrMRQwEgYDVQQLDAtTaWduYWwgTW9jazEXMBUGA1UE
AwwOU2lnbmFsIE1vY2sgQ0ExITAfBgkqhkiG9w0BCQEWEmluZHV0bnlAc2lnbmFs
Lm9yZzAgFw0yMjAyMjgxOTU2NDBaGA8yMTIyMDIwNDE5NTY0MFowgY8xCzAJBgNV
BAYTAlVTMQswCQYDVQQIDAJDQTELMAkGA1UEBwwCTEExFDASBgNVBAoMC1NpZ25h
bCBNb2NrMRQwEgYDVQQLDAtTaWduYWwgTW9jazEXMBUGA1UEAwwOU2lnbmFsIE1v
Y2sgQ0ExITAfBgkqhkiG9w0BCQEWEmluZHV0bnlAc2lnbmFsLm9yZzCCAiIwDQYJ
KoZIhvcNAQEBBQADggIPADCCAgoCggIBAM3E82nXdDLWjATjl9cWHwSmstUERDCE
iZwHC2CVXxZalKo0knXDvBCbBDnenG5gaYzJVZ8s1+Vk4K193E1LDNkBuSCfnyMg
gvlNs2PSrIMy2Is8Vi+81dD2J4MBoHSCxPFO6pJQLahjbwyuFnAyffgapfHKnBbL
4zmMcd/hyYjswFZrqbdwm2DBrsJLl//vfveR+E72o9qLggIlVpP9Hii8aU9KOD1p
kE2dMkogfhhyfFFOCOOVZU/i9azYBVgicZxT1rj+O3LAwoHNMo2H0koa77WqaFbf
gqqTpm8yfSLXwWeVv4190I5R8yM/kWnt+/HsBc233wm3WyMJ26WQbQ5FYFwMOMPY
RoNY6KKBzpYKC6UUxlPUqeIKQzwGDOq4CPrjY1YyWXkszDNdT/GSJ2YPMY7P4ehF
Arw+mGLjmoqwRNSPdasU6B/y3VfAK14MHqPuM+rlBv27QiYKN12dICq4q7tITdpd
F0Qy0WLuI1XXEsH+mToPzi1yvfwEwru0jCder0CCzD18ifeYQEorWf1oaNfuVHL/
e5Y5N0ST04oV2/GsNPlGNWgpEdKQxu3kQBDEwZHXnRgxGVCZXTQNzBQkTFJyLpnz
5ns8W4T+OJhLSV6bktqUpWbwRB0S1RYg09sP5z5qeCFdII4zlk4pcicmIQc5NwL2
xyPie+VNHvEFAgMBAAGjEDAOMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQELBQAD
ggIBAI6Oskw0by2g1pmjU53eTqrFt4Rai2BJeUzPM3iUj6OBgB7IiXiwFdxWSLTL
SvkW7SSKOtX0Uc246qrRUStw9CPWdHBdm4LiMTgVFIFXJ0wq5oEZwrNfmV9Yqjo4
PhgQfaAvObB6M8b/yntMQWEHT0u+oBdXEmnCYZZzLN84KeW74p2VRmqjWejtSC3g
4HR+tMhALmUUibyVCTMHEEWUe4ohi0DMjCs2/7lRoTCy8YVC7tHgYAPb1S6pS5BL
VZfOeDjWcqx6UI1JBJXnpUCMNUO6VW14slK92vSPOKoS9tpx4JatI02QSWA4T/Nf
uXMXvTskDKegvNn8hIaXqOct0FpdtoG30MKU606N9Rxnk5p0RNfsMbdu4we47G4J
D9KWxBRGh2RkCl6HLcJlm+TFsQQE1xxYcaXa/icIOrDhlWvkrwj/VZxhgMQoRNBb
+UyCD/meUxUaEDvBBlbp9gVpAqr5mIiIPdKGsgMZY63mGGWujlPXA0OC9cC+FtMU
U9xqkvgBKNs6LaLdK9AUjQq3VYaCoi3Y9lGdkjIP2s5TQ7CuWbGSmoGA439l7qo3
gbKNhQ48VehfGKPPFOdD23U1cK+/KjqT6X3S0/AvWfaNEwVo8fNaP4j4FrWz3fRy
ef8JpIuJ1HKPBaadOw0RZy41liJGwQ0coF9HG/2Sxb7S+OgP
-----END CERTIFICATE-----
+1
View File
@@ -0,0 +1 @@
AB0BE03708DC8ADC
+54
View File
@@ -0,0 +1,54 @@
-----BEGIN ENCRYPTED PRIVATE KEY-----
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
V9gFj5TFNq1t3F9AX1YXyk58iOzcZSfJ
-----END ENCRYPTED PRIVATE KEY-----
+22
View File
@@ -0,0 +1,22 @@
[ req ]
default_bits = 4096
days = 9999
distinguished_name = req_distinguished_name
attributes = req_attributes
prompt = no
output_password = password
x509_extensions = v3_ca
[ req_distinguished_name ]
C = US
ST = CA
L = LA
O = Signal Mock
OU = Signal Mock
CN = Signal Mock CA
emailAddress = indutny@signal.org
[ req_attributes ]
[ v3_ca ]
basicConstraints = CA:TRUE
+41
View File
@@ -0,0 +1,41 @@
-----BEGIN CERTIFICATE-----
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
hDBJ4zQ=
-----END CERTIFICATE-----
+32
View File
@@ -0,0 +1,32 @@
-----BEGIN CERTIFICATE REQUEST-----
MIIFhTCCA20CAQAwgZAxCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJDQTELMAkGA1UE
BwwCTEExFDASBgNVBAoMC1NpZ25hbCBNb2NrMRQwEgYDVQQLDAtTaWduYWwgTW9j
azEYMBYGA1UEAwwPbW9jay5zaWduYWwub3JnMSEwHwYJKoZIhvcNAQkBFhJpbmR1
dG55QHNpZ25hbC5vcmcwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQC7
cw94tAPPKS39Jikzmv221XQvNO1pT2JjPxXu6PPmGFYUXbAqOkny80QOrSkkD+4F
tdJdZNYEPmLkeq/iUiWO/cmEIXWz93nyK384648owL3kBZK1xP2IfOtFJ7klXYli
sc1NPSpzjrwQnrfEuWfkoBy7gT2aNrPQaHdRZSh/Wqf5cr35xI3tjfL61rlSrn5F
QIPB+JoCKVvYpud+tgnXnPLSoHnMOd2plNxmwKfnc6qrWYrK579+/a10Ij+aaEQ5
WJTtAuzxH7X/XX4r1rmHObxZ0TpdjX8E5FBAk+qswkB1ys1G5djFWhNPzc9hRon3
jqnMkt/8GVsi/tNHTMuEssNTMNFdu5UjyLLD1+zj9zePfliS5jS7jlxgNRz7DpYv
zvnmY/x4NIvM/KCJcECx0FFHEdAIW18ZyYAD9QYxes6CBjbAbPpInFIz15cIPmJ9
DGy3nz8N7vTYh2thq5CntywFTBIi7OdXsodFgdToBn4JPsuL1H+uxkeULA128rj1
u3fCURGcLw3G4m4PCqRL5SnBrrcDtFeigOVUIU+GxhKEsmyJcN46DyMlPr74Qzu9
RkiPhwCPYflV9kKJ6n6qZxOyvOPi65Q0LhjaHc/UQSCHqxXr+0myXIvpdqVzWCgw
H/qWCkMBx8RbNxcxmdlFDpoX/EidOjxDhtyk007FVQIDAQABoIGuMIGrBgkqhkiG
9w0BCQ4xgZ0wgZowXwYIKwYBBQUHAQEEUzBRMCMGCCsGAQUFBzABhhdodHRwOi8v
bW9jay5zaWduYWwub3JnLzAqBggrBgEFBQcwAoYeaHR0cDovL21vY2suc2lnbmFs
Lm9yZy9jYS5jZXJ0MAkGA1UdEwQCMAAwLAYDVR0RBCUwI4cEfwAAAYcQAAAAAAAA
AAAAAAAAAAAAAYIJbG9jYWxob3N0MA0GCSqGSIb3DQEBCwUAA4ICAQAvtrnRsa3c
zS6ZeLrPR9vu7MeozLQtW2xCM42Ghuccnpr+zbszmTJXc2pZBORAYV7J8vBd55sS
bNexx9BbopzIrzUwkyp4WzCtz8J60hVcVcF7jaXseXO3TyFV8Ju0RCITDz3fwxML
r4NYAL3ihMemMk8DZ7GGpZHRAd6W5X8L+BdnYJbEWcuTXwzy370PDP4bgcNL9eF8
fskvuUzXmESBDvwrXTnFGh4i9+3o7OC3g2bma2HJDaokkXMq0jgHLwsJN5WhUHFd
Hhni3ftX2AELj6JalC/ATlS1Q96mV9D9wZXW5lZgXBZU4geVjkt49witJ9RWi0we
wU0meZfN/Nz6MmXVz/BwE1i7n7eVW+hrkAMqTvlweKgmptjDgJjGjex1KW08ucxw
8C4oIc9XRyV5fsYpS9TTu+5Y81XLAWDXgzgGDl1cwa4A5pHxgaWNT81JqJrzZIF6
w2uMD//GYPXZI79wnFTk4oFEtCHD84fZNM1bLfXUqR90+mtm84wMVLFbB2jIEePM
cFrgff29UA/DC9Ronp0S4aKDGQwet+PGVbs5J1TctwuYRZTdV68WAFnizMTBaiJv
ddKvI1LoDzvvXM2bsbQVddRXjg1Gy0laidSuBfL5Ksv2xxrbt7qDNyxXFBzPd78P
n4DfIGS8gFGbaHf7EywBrGuWAXj9hAyikA==
-----END CERTIFICATE REQUEST-----
+74
View File
@@ -0,0 +1,74 @@
-----BEGIN CERTIFICATE-----
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
hDBJ4zQ=
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIFwDCCA6igAwIBAgIUH4s+Chj0H/rJGlds6A9JcN9f5BowDQYJKoZIhvcNAQEL
BQAwgY8xCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJDQTELMAkGA1UEBwwCTEExFDAS
BgNVBAoMC1NpZ25hbCBNb2NrMRQwEgYDVQQLDAtTaWduYWwgTW9jazEXMBUGA1UE
AwwOU2lnbmFsIE1vY2sgQ0ExITAfBgkqhkiG9w0BCQEWEmluZHV0bnlAc2lnbmFs
Lm9yZzAgFw0yMjAyMjgxOTU2NDBaGA8yMTIyMDIwNDE5NTY0MFowgY8xCzAJBgNV
BAYTAlVTMQswCQYDVQQIDAJDQTELMAkGA1UEBwwCTEExFDASBgNVBAoMC1NpZ25h
bCBNb2NrMRQwEgYDVQQLDAtTaWduYWwgTW9jazEXMBUGA1UEAwwOU2lnbmFsIE1v
Y2sgQ0ExITAfBgkqhkiG9w0BCQEWEmluZHV0bnlAc2lnbmFsLm9yZzCCAiIwDQYJ
KoZIhvcNAQEBBQADggIPADCCAgoCggIBAM3E82nXdDLWjATjl9cWHwSmstUERDCE
iZwHC2CVXxZalKo0knXDvBCbBDnenG5gaYzJVZ8s1+Vk4K193E1LDNkBuSCfnyMg
gvlNs2PSrIMy2Is8Vi+81dD2J4MBoHSCxPFO6pJQLahjbwyuFnAyffgapfHKnBbL
4zmMcd/hyYjswFZrqbdwm2DBrsJLl//vfveR+E72o9qLggIlVpP9Hii8aU9KOD1p
kE2dMkogfhhyfFFOCOOVZU/i9azYBVgicZxT1rj+O3LAwoHNMo2H0koa77WqaFbf
gqqTpm8yfSLXwWeVv4190I5R8yM/kWnt+/HsBc233wm3WyMJ26WQbQ5FYFwMOMPY
RoNY6KKBzpYKC6UUxlPUqeIKQzwGDOq4CPrjY1YyWXkszDNdT/GSJ2YPMY7P4ehF
Arw+mGLjmoqwRNSPdasU6B/y3VfAK14MHqPuM+rlBv27QiYKN12dICq4q7tITdpd
F0Qy0WLuI1XXEsH+mToPzi1yvfwEwru0jCder0CCzD18ifeYQEorWf1oaNfuVHL/
e5Y5N0ST04oV2/GsNPlGNWgpEdKQxu3kQBDEwZHXnRgxGVCZXTQNzBQkTFJyLpnz
5ns8W4T+OJhLSV6bktqUpWbwRB0S1RYg09sP5z5qeCFdII4zlk4pcicmIQc5NwL2
xyPie+VNHvEFAgMBAAGjEDAOMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQELBQAD
ggIBAI6Oskw0by2g1pmjU53eTqrFt4Rai2BJeUzPM3iUj6OBgB7IiXiwFdxWSLTL
SvkW7SSKOtX0Uc246qrRUStw9CPWdHBdm4LiMTgVFIFXJ0wq5oEZwrNfmV9Yqjo4
PhgQfaAvObB6M8b/yntMQWEHT0u+oBdXEmnCYZZzLN84KeW74p2VRmqjWejtSC3g
4HR+tMhALmUUibyVCTMHEEWUe4ohi0DMjCs2/7lRoTCy8YVC7tHgYAPb1S6pS5BL
VZfOeDjWcqx6UI1JBJXnpUCMNUO6VW14slK92vSPOKoS9tpx4JatI02QSWA4T/Nf
uXMXvTskDKegvNn8hIaXqOct0FpdtoG30MKU606N9Rxnk5p0RNfsMbdu4we47G4J
D9KWxBRGh2RkCl6HLcJlm+TFsQQE1xxYcaXa/icIOrDhlWvkrwj/VZxhgMQoRNBb
+UyCD/meUxUaEDvBBlbp9gVpAqr5mIiIPdKGsgMZY63mGGWujlPXA0OC9cC+FtMU
U9xqkvgBKNs6LaLdK9AUjQq3VYaCoi3Y9lGdkjIP2s5TQ7CuWbGSmoGA439l7qo3
gbKNhQ48VehfGKPPFOdD23U1cK+/KjqT6X3S0/AvWfaNEwVo8fNaP4j4FrWz3fRy
ef8JpIuJ1HKPBaadOw0RZy41liJGwQ0coF9HG/2Sxb7S+OgP
-----END CERTIFICATE-----
@@ -0,0 +1,20 @@
// Copyright 2026 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
'use strict';
const fs = require('fs');
const { PrivateKey } = require('@signalapp/libsignal-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,
),
);
@@ -0,0 +1,32 @@
// Copyright 2026 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
'use strict';
const fs = require('fs');
const {
GenericServerSecretParams,
ServerSecretParams,
} = require('@signalapp/libsignal-client/zkgroup');
const secretParams = ServerSecretParams.generate();
const publicParams = secretParams.getPublicParams();
const genericSecretParams = GenericServerSecretParams.generate();
const genericPublicParams = genericSecretParams.getPublicParams();
const backupSecretParams = GenericServerSecretParams.generate();
const backupPublicParams = backupSecretParams.getPublicParams();
fs.writeFileSync(
process.argv[2],
JSON.stringify(
{
secretParams: secretParams.serialize().toString('base64'),
publicParams: publicParams.serialize().toString('base64'),
genericSecretParams: genericSecretParams.serialize().toString('base64'),
genericPublicParams: genericPublicParams.serialize().toString('base64'),
backupSecretParams: backupSecretParams.serialize().toString('base64'),
backupPublicParams: backupPublicParams.serialize().toString('base64'),
},
null,
2,
),
);
+51
View File
@@ -0,0 +1,51 @@
-----BEGIN RSA PRIVATE KEY-----
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
16w3EwKCAQBh2Wtv5Bob9I9Tr/HuoaW0MHp2IqkbAQ94QKcB3w30B6AvUhjS8Aw4
YJaFxd1jutscOD931X1c/1tQwErUC9lWqsNsrgqGUKC2uLlgVx42+sYSw2VpL8Wx
LwI6da5UczSzbchK4t0zOP3Kw1Y+JCw7RuW9tbDFDHWqqo8Mhjyt891urnuBR6rI
WP8n4fSedzpnMVv/j4shzrHVHD0PdmthDSumsk6mVbX8LFvuNlc8iFVxoe6jmXvJ
1RyqexAxKpUeOmDb7tnj8ehclzahVTJBRtzho8ozV36slaxh5Djt9JoHmMOnHPjy
ow5YYtHaodgOaeuGiryyZ43sry1Tj3BLAoIBAB8QRoM/G+XMAgww4uEVxSv4dmHY
2Yp95VK/IHaOa91dT+oOLkiCq1a6fCjZJDH8nXxRpTfZYfSuf6hMJS124Uiw14r4
qGcHHvtKEn7FxpSZN7qo6cpxyOerFdV1rudq9ulxcbUGuP0sE9aAYRLR5moFxmHJ
3If2xlDRhbxSywMKFYHKBIioUnyZwr0qdK0ZYWU2pAV5FqzkVq7h3JOm9rqTUjeT
qPsTEzVzhaJG1pH92cPpnetSixuxikrpnrgPQWfj04aooDLQIPGzMm3ICZMRSCC9
wNY2D4qYaejt0nlTTGG4eSw2b9DnFy4l48z9DpoU1/1qBGEnS/+UJ5V/lP0=
-----END RSA PRIVATE KEY-----
+27
View File
@@ -0,0 +1,27 @@
[ req ]
default_bits = 4096
days = 9999
distinguished_name = req_distinguished_name
attributes = req_attributes
prompt = no
x509_extensions = v3_ca
[ req_distinguished_name ]
C = US
ST = CA
L = LA
O = Signal Mock
OU = Signal Mock
CN = mock.signal.org
emailAddress = indutny@signal.org
[ req_attributes ]
[ v3_ca ]
authorityInfoAccess = @issuer_info
basicConstraints = CA:FALSE
subjectAltName = IP:127.0.0.1,IP:::1,DNS:localhost
[ issuer_info ]
OCSP;URI.0 = http://mock.signal.org/
caIssuers;URI.0 = http://mock.signal.org/ca.cert
@@ -0,0 +1,4 @@
{
"privateKey": "IIAqba11mCp276QmhiTg4Dtfa/FsWcKUSdCPVt8LT0c=",
"publicKey": "BZ8zqn+/bbZcpoKqnvkHXvoTI+n9o/Iuc9kpVog2ZEYs"
}
@@ -0,0 +1,8 @@
{
"secretParams": "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",
"publicParams": "ACK6MI2KosIo/Ne0RZJByuVUKryZrCnvS1NBCKBqaEVMBK8WjHp29d0uqYTrW1YsU1zNEzA9nVVQoi2+dumkG2XMsW9pgMcG7c0Ms63XPauVr/QTKZZUqqehXtdckDGsYvIWVHoslT8CotrCdF5fkVFJMZLBEfPbWhxVjtBlEkcRDg17pftwEK0Xz+e2S42R8HfnpfnP9WV5ifvyJ7BevyB4HXwo63P8gVllmjDhWDuI57F2dr1T/tZnE7+TxeueD5jDxjaN2+bgE3uGf2xCt+RG+dT/1nkyFpZk02jXj/tpTJWFHuLw9GBo3RHxH8jQnPxEJHxqUUGh1e1uSHwCJm2OMUXZDKNJfG4N7p6lPvTCzA0ZxePAw6D6EllhQzNUWSBC/j8/7eq8mIpiHtxLStUAOnCASzhRmkNB3WiF+r8MmD6bA1gbsmGSjFh7CCTHQGt8J+hoGnj5fxp+QEG9aEUuTJq8AzOWGZVvR3brURrB4U50Wv2gdao6SBZdCtlhFWDyAalGENlbwrJuA33Pt6rqCuBCk9/SMVJ055dVccB0ChRR/UbI42KofhXyURYrxuKfbUSFyq4sV+AbOi+/vQWCgq6IZT4v9Pdz+MWA0+GJcddlgzw8hUOULvWbU8KUBPCOPKV2lbY68ywJOkJu6HWGJRqeCk1d8Dt215D1hh9I+nSI0UNSI0R9fGtnPi/IQO3JANB+g8j5WYMlaiRR/Wj2oVXojkoE7aEL/WBpf0FrVDxnvEpT0/57J1Gs19qHGWzXqWxqc/P+VwyT7/4k7tvCs17cRn3wB/Qrk4Kt/FgayK69dgwv5AwAv6Kv/vShoFirVxPlIKeyUjESLK4GtXwImXSJUL3QCBmYfWt+1O3yUZIaFV404UibUjtx6j69EQ==",
"genericSecretParams": "AD+z/Pi9BEZP+4oZGptLHKWZnFpBi241D1HIFGXjo0cN64psuZguJKjqzL/DUfwweb49VrcZ1tGBC9wTvFPDxg/AY3XI4BYDcAgjzkw4R35zzYKSPGp6zMK0zEPXJSDvKT5523l04Sjy2lBhs74tY2+gJYMae+dXMej9WDLYNg4GcA8yecHPhG2IXvXM5M9O1jLzbcsotPiWlzI2MnkfeweYFL376jbL6Wu+GY4r/Y8XhSfYcLU9gA2bhPshKNmKDbT9l+LttSjlLKnECGihn0KX3Cnro1Qek40WI7qDcc4HPP468mqIdc6bm9SB87MAzRYf9sLD/rkQayf6R+8O4QKUlOxhhncVgE2QQ/KJ5lRZwWPMwsrgP6OnJZCF6bxHBydIYc5RuJa6twxTYegO7eGZuLmNy1Ku3vqBYtmmKMwEfBVHKgTQoxvL5cAy05cnH6jOnCShg2QGGwSsZsxSKgLGyN1FS176CIaqDSZeYJ6ttW8E9J14/yDiROmVqL9pBg==",
"genericPublicParams": "ADZbMQozz5OqOMr7CDAxlhJsLy1mgfZf0sKmxqbCpqshgNntJOvACWmgDT2Ah1CgZRtxDpPKUHcUXHvqupgellEkx07c9O05ha3AsYUjOeRS8Fos8L4Jqibp5zDx7Hd1BFpcpe3bKYYxJaIekPkXCz215RZj4WxqowpxaFb3pqsX+B1JQQ3Rd4FRj14N67dqKvLtIHrJqXwW0T0rBz5CkHbG7Inx+3UTJvb/16uiGJIZTM4BnH/31U9V2zD+PATiQ6I5o0Jn0mD9DStujm+KwU30r3Qk4yB/UwWGG66yoqgR",
"backupSecretParams": "APTh0IoNHMJE2Q7hF2zfSgr5B92mEBNrWhfnkITEpT4MoxbFF1o8nEMWSmWT54NKOyC5wcdth/Fwa0Ha+Pn6gQxQ0Q+/XEmSzDETtxPUUO8yYsy5sTrpZbbeIhJN3Jj0bENH/bo1RA/kUyASqi2afKpGKjkievzW7/bfM2wL6CsPdSAW3G3zLFT1kNnMa4Ys+ppUkjP7zt4SBPnsjsInwQb5EsnEuynVugo1acRUtD14p2verWYkS2tqrBuNpPqpBDtKxjLvtXxpI5WJ0ChYuGPFqsLeBJiZMY9bLiT0MekK0vc4D9RsEwFJYpsXS7BiWGRlSyu0a0swrhozhewUYgYLK0y3/PVEuZ6geuJzzXafvpmOXOYRY197VF3DRBDiCeM9sZXvdxhKodrKUhv3y2Qx3anuILLqDOS+L2VU5ssKQ38l5duJeZ+8ZkhKJUILQFGPEwBq2FHABTevCCZ6DgszALdy9Hsz+n56z8jHDPMNr53W7dj6ron5uarV8aHFAQ==",
"backupPublicParams": "AAKqRyanOBPVl/ujTGopMXOhhzaYqLUCy45p31DztqQdBuG3hss6fzCffFYb7VvhflJuxKvN6Dwc2/4ADcI+gk/6nhPBT1ZBJ4psmCv2keDOseaz8iz43SG27W2OZTB6SI5GP5jFvBLo6Efk6TWsjKrmnkr5s1DxfsT7yXFwFi1GzGL80oYsdtad6YOYJvKoIKwTxtN58DS9HZQn5pyFuBJSRXV5kSbgXBofLH54NGifQQoMc3qQXsEM02dieBBiE85q6CPLZBxSvIgSpHRnetu0f4fGhQzQdUjvRpHt/pNY"
}
+84
View File
@@ -0,0 +1,84 @@
{
"name": "@signalapp/mock-server",
"packageManager": "pnpm@10.18.1",
"version": "25.2.0",
"description": "Mock Signal Server for writing tests",
"main": "src/index.js",
"types": "src/index.d.ts",
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org/",
"provenance": false
},
"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": "protopiler --module cjs --output protos/compiled.js --typedefs protos/compiled.d.ts protos",
"build": "npm run build:protobuf && npm run build:tsc",
"format": "pprettier --write '**/*.ts'",
"mocha": "mocha test/**/*-test.js",
"lint:prettier": "pprettier --check '**/*.ts'",
"lint": "npm run lint:prettier",
"test": "npm run mocha && npm run lint",
"prepare": "npm run build"
},
"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": {
"@indutny/parallel-prettier": "^3.0.0",
"@indutny/protopiler": "4.0.0",
"@signalapp/libsignal-client": "^0.92.1",
"@tus/file-store": "^1.4.0",
"@tus/server": "^1.7.0",
"debug": "^4.3.2",
"is-plain-obj": "3.0.0",
"micro": "^9.3.4",
"microrouter": "^3.1.3",
"prettier": "^3.3.3",
"type-fest": "^4.26.1",
"url-pattern": "^1.0.3",
"uuid": "^8.3.2",
"ws": "^8.4.2",
"zod": "^3.20.2"
},
"devDependencies": {
"@types/debug": "^4.1.7",
"@types/long": "^4.0.1",
"@types/micro": "^7.3.6",
"@types/microrouter": "^3.1.1",
"@types/mocha": "^10.0.10",
"@types/node": "^24.2.0",
"@types/uuid": "^8.3.0",
"@types/ws": "^8.2.2",
"mocha": "^11.7.5",
"typescript": "^5.9.3"
},
"pnpm": {
"patchedDependencies": {
"@types/ws": "patches/@types__ws.patch"
}
}
}
@@ -0,0 +1,30 @@
diff --git a/index.d.ts b/index.d.ts
index 6d08adc155873e948d2ffebf40622fe405159bc0..4041a625f51d84d719c878244aad2f74bc9791d9 100644
--- a/index.d.ts
+++ b/index.d.ts
@@ -10,6 +10,7 @@ import {
Server as HTTPServer,
} from "http";
import { Server as HTTPSServer } from "https";
+import { ServerHttp2Stream } from "http2";
import { createConnection } from "net";
import { Duplex, DuplexOptions } from "stream";
import { SecureContextOptions } from "tls";
@@ -76,7 +77,7 @@ declare class WebSocket extends EventEmitter {
onclose: ((event: WebSocket.CloseEvent) => void) | null;
onmessage: ((event: WebSocket.MessageEvent) => void) | null;
- constructor(address: null);
+ constructor(address: null, protocols: undefined, options: WebSocket.ClientOptions | ClientRequestArgs);
constructor(address: string | URL, options?: WebSocket.ClientOptions | ClientRequestArgs);
constructor(
address: string | URL,
@@ -84,6 +85,8 @@ declare class WebSocket extends EventEmitter {
options?: WebSocket.ClientOptions | ClientRequestArgs,
);
+ setSocket(socket: ServerHttp2Stream, head: Buffer, options: WebSocket.ClientOptions | ClientRequestArgs): void;
+
close(code?: number, data?: string | Buffer): void;
ping(data?: any, mask?: boolean, cb?: (err: Error) => void): void;
pong(data?: any, mask?: boolean, cb?: (err: Error) => void): void;
@@ -0,0 +1,53 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
package signalservice;
message CDSClientRequest {
// Each ACI/UAK pair is a 32-byte buffer, containing the 16-byte ACI followed
// by its 16-byte UAK.
optional bytes aci_uak_pairs = 1;
// Each E164 is an 8-byte big-endian number, as 8 bytes.
optional bytes prev_e164s = 2;
optional bytes new_e164s = 3;
optional bytes discard_e164s = 4;
// If true, the client has more pairs or e164s to send. If false or unset,
// this is the client's last request, and processing should commence.
optional bool has_more = 5;
// If set, a token which allows rate limiting to discount the e164s in
// the request's prev_e164s, only counting new_e164s. If not set, then
// rate limiting considers both prev_e164s' and new_e164s' size.
optional bytes token = 6;
// After receiving a new token from the server, send back a message just
// containing a token_ack.
optional bool token_ack = 7;
// Request that, if the server allows, both ACI and PNI be returned even
// if the aci_uak_pairs don't match.
optional bool return_acis_without_uaks = 8;
}
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.
optional bytes e164_pni_aci_triples = 1;
// A token which allows subsequent calls' rate limiting to discount the
// e164s sent up in this request, only counting those in the next
// request's new_e164s.
optional bytes token = 3;
}
@@ -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;
}
@@ -0,0 +1,56 @@
/*
* Copyright 2020 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
syntax = "proto2";
package signalservice;
option java_package = "org.whispersystems.signalservice.internal.push";
option java_outer_classname = "ProvisioningProtos";
// An opaque address sent by the server when clients first open a provisioning
// WebSocket
message ProvisioningAddress {
// The opaque provisioning address for the active provisioning WebSocket
// session; clients should not attempt to interpret or modify the contents
// of the address string
optional string address = 1;
}
message ProvisionEnvelope {
optional bytes publicKey = 1;
optional bytes body = 2; // Encrypted ProvisionMessage
}
message ProvisionMessage {
optional bytes aciIdentityKeyPublic = 1;
optional bytes aciIdentityKeyPrivate = 2;
optional bytes pniIdentityKeyPublic = 11;
optional bytes pniIdentityKeyPrivate = 12;
// optional string aci = 8;
// optional string pni = 10;
optional string number = 3;
optional string provisioningCode = 4;
optional string userAgent = 5;
optional bytes profileKey = 6;
optional bool readReceipts = 7;
optional uint32 provisioningVersion = 9;
optional bytes masterKey = 13; // Deprecated, but required by linked devices
optional bytes ephemeralBackupKey = 14; // 32 bytes
optional string accountEntropyPool = 15;
optional bytes mediaRootBackupKey = 16; // 32-bytes
optional bytes aciBinary = 17; // 16-byte UUID
optional bytes pniBinary = 18; // 16-byte UUID
// NEXT ID: 19
}
enum ProvisioningVersion {
option allow_alias = true;
INITIAL = 0;
TABLET_SUPPORT = 1;
CURRENT = 1;
}
@@ -0,0 +1,10 @@
// Copyright 2018 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;
}
+308
View File
@@ -0,0 +1,308 @@
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
syntax = "proto3";
package signalservice;
option java_package = "org.signal.storageservice.storage.protos.groups";
option java_outer_classname = "GroupProtos";
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;
}
// Stored data
message Member {
enum Role {
UNKNOWN = 0;
DEFAULT = 1;
ADMINISTRATOR = 2;
}
bytes user_id = 1;
Role role = 2;
bytes profileKey = 3;
bytes presentation = 4;
uint32 joinedAtVersion = 5;
// These two fields each decrypt to a UTF8 string
bytes label_emoji = 6;
bytes label_string = 7;
}
message MemberPendingProfileKey {
Member member = 1;
bytes addedByUserId = 2;
uint64 timestamp = 3; // ms since epoch
}
message MemberPendingAdminApproval {
bytes user_id = 1;
bytes profileKey = 2;
bytes presentation = 3;
uint64 timestamp = 4; // ms since epoch
}
message MemberBanned {
bytes user_id = 1;
uint64 timestamp = 2; // ms since epoch
}
message AccessControl {
enum AccessRequired {
UNKNOWN = 0;
ANY = 1;
MEMBER = 2;
ADMINISTRATOR = 3;
UNSATISFIABLE = 4;
}
AccessRequired attributes = 1;
AccessRequired members = 2;
AccessRequired addFromInviteLink = 3;
AccessRequired member_label = 4;
}
message Group {
bytes publicKey = 1;
bytes title = 2;
bytes description = 11;
// The URL for this group's avatar. The content at this URL can be
// decrypted/deserialized into a `GroupAttributeBlob`.
string avatarUrl = 3;
bytes disappearingMessagesTimer = 4;
AccessControl accessControl = 5;
uint32 version = 6;
repeated Member members = 7;
repeated MemberPendingProfileKey membersPendingProfileKey = 8;
repeated MemberPendingAdminApproval membersPendingAdminApproval = 9;
bytes inviteLinkPassword = 10;
bool announcements_only = 12;
repeated MemberBanned members_banned = 13;
bool terminated = 14;
// next: 15
}
message GroupAttributeBlob {
oneof content {
string title = 1;
bytes avatar = 2;
uint32 disappearingMessagesDuration = 3;
string descriptionText = 4;
}
}
message GroupInviteLink {
message GroupInviteLinkContentsV1 {
bytes groupMasterKey = 1;
bytes inviteLinkPassword = 2;
}
oneof contents {
GroupInviteLinkContentsV1 contentsV1 = 1;
}
}
message GroupJoinInfo {
bytes publicKey = 1;
bytes title = 2;
bytes description = 8;
string avatar = 3;
uint32 memberCount = 4;
AccessControl.AccessRequired addFromInviteLink = 5;
uint32 version = 6;
bool pendingAdminApproval = 7;
bool pendingAdminApprovalFull = 9;
// next: 10
}
// Deltas
message GroupChange {
message Actions {
message AddMemberAction {
Member added = 1;
bool joinFromInviteLink = 2;
}
message DeleteMemberAction {
bytes deletedUserId = 1;
}
message ModifyMemberRoleAction {
bytes user_id = 1;
Member.Role role = 2;
}
message ModifyMemberLabelAction {
bytes user_id = 1;
// These two fields each decrypt to a UTF8 string
bytes label_emoji = 2;
bytes label_string = 3;
}
message ModifyMemberProfileKeyAction {
bytes presentation = 1;
bytes user_id = 2;
bytes profile_key = 3;
}
message AddMemberPendingProfileKeyAction {
MemberPendingProfileKey added = 1;
}
message DeleteMemberPendingProfileKeyAction {
bytes deletedUserId = 1;
}
message PromoteMemberPendingProfileKeyAction {
bytes presentation = 1;
bytes user_id = 2;
bytes profile_key = 3;
}
message PromoteMemberPendingPniAciProfileKeyAction {
bytes presentation = 1;
bytes user_id = 2;
bytes pni = 3;
bytes profile_key = 4;
}
message AddMemberPendingAdminApprovalAction {
MemberPendingAdminApproval added = 1;
}
message DeleteMemberPendingAdminApprovalAction {
bytes deletedUserId = 1;
}
message PromoteMemberPendingAdminApprovalAction {
bytes user_id = 1;
Member.Role role = 2;
}
message AddMemberBannedAction {
MemberBanned added = 1;
}
message DeleteMemberBannedAction {
bytes deletedUserId = 1;
}
message ModifyTitleAction {
bytes title = 1;
}
message ModifyDescriptionAction {
bytes description = 1;
}
message ModifyAvatarAction {
string avatar = 1;
}
message ModifyDisappearingMessageTimerAction {
bytes timer = 1;
}
message ModifyAttributesAccessControlAction {
AccessControl.AccessRequired attributesAccess = 1;
}
message ModifyMembersAccessControlAction {
AccessControl.AccessRequired membersAccess = 1;
}
message ModifyAddFromInviteLinkAccessControlAction {
AccessControl.AccessRequired addFromInviteLinkAccess = 1;
}
message ModifyMemberLabelAccessControlAction {
AccessControl.AccessRequired member_label_access = 1;
}
message ModifyInviteLinkPasswordAction {
bytes inviteLinkPassword = 1;
}
message ModifyAnnouncementsOnlyAction {
bool announcements_only = 1;
}
message TerminateGroupAction {}
bytes sourceUserId = 1;
// clients should not provide this value; the server will provide it in the response buffer to ensure the signature is binding to a particular group
// if clients set it during a request the server will respond with 400.
bytes group_id = 25;
uint32 version = 2;
repeated AddMemberAction addMembers = 3;
repeated DeleteMemberAction deleteMembers = 4;
repeated ModifyMemberRoleAction modifyMemberRoles = 5;
repeated ModifyMemberLabelAction modifyMemberLabels = 26; // change epoch = 6
repeated ModifyMemberProfileKeyAction modifyMemberProfileKeys = 6;
repeated AddMemberPendingProfileKeyAction addMembersPendingProfileKey = 7;
repeated DeleteMemberPendingProfileKeyAction deleteMembersPendingProfileKey = 8;
repeated PromoteMemberPendingProfileKeyAction promoteMembersPendingProfileKey = 9;
ModifyTitleAction modifyTitle = 10;
ModifyAvatarAction modifyAvatar = 11;
ModifyDisappearingMessageTimerAction modifyDisappearingMessageTimer = 12;
ModifyAttributesAccessControlAction modifyAttributesAccess = 13;
ModifyMembersAccessControlAction modifyMemberAccess = 14;
ModifyAddFromInviteLinkAccessControlAction modifyAddFromInviteLinkAccess = 15; // change epoch = 1
ModifyMemberLabelAccessControlAction modify_member_label_access = 27; // change epoch = 6
repeated AddMemberPendingAdminApprovalAction addMembersPendingAdminApproval = 16; // change epoch = 1
repeated DeleteMemberPendingAdminApprovalAction deleteMembersPendingAdminApproval = 17; // change epoch = 1
repeated PromoteMemberPendingAdminApprovalAction promoteMembersPendingAdminApproval = 18; // change epoch = 1
ModifyInviteLinkPasswordAction modifyInviteLinkPassword = 19; // change epoch = 1
ModifyDescriptionAction modifyDescription = 20; // change epoch = 2
ModifyAnnouncementsOnlyAction modify_announcements_only = 21; // change epoch = 3
repeated AddMemberBannedAction add_members_banned = 22; // change epoch = 4
repeated DeleteMemberBannedAction delete_members_banned = 23; // change epoch = 4
repeated PromoteMemberPendingPniAciProfileKeyAction promote_members_pending_pni_aci_profile_key = 24; // change epoch = 5
TerminateGroupAction terminate_group = 28; // change epoch = 7
// next: 29
}
bytes actions = 1;
bytes serverSignature = 2;
uint32 changeEpoch = 3;
}
// External credentials
message ExternalGroupCredential {
string token = 1;
}
// API responses
message GroupResponse {
Group group = 1;
bytes group_send_endorsements_response = 2;
}
message GroupChanges {
message GroupChangeState {
GroupChange groupChange = 1;
Group groupState = 2;
}
repeated GroupChangeState groupChanges = 1;
bytes group_send_endorsements_response = 2;
}
message GroupChangeResponse {
GroupChange group_change = 1;
bytes group_send_endorsements_response = 2;
}
@@ -0,0 +1,107 @@
syntax = "proto3";
//
// Copyright 2020 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;
}
+7
View File
@@ -0,0 +1,7 @@
# Protobufs
Files in this directory are a copy of `protos` folder in [Signal-Desktop][0]
repository and `server` folder is from [Signal-Server][1].
[0]: https://github.com/signalapp/Signal-Desktop/tree/main/protos
[1]: https://github.com/signalapp/Signal-Server/tree/main/service/src/main/proto
+29
View File
@@ -0,0 +1,29 @@
/*
* Copyright 2019-2021 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
syntax = "proto2";
package signaling;
message DeviceToDevice {
optional bytes group_id = 1;
}
message CallMessage {
message RingIntention {
enum Type {
RING = 0;
CANCELLED = 1;
}
optional bytes group_id = 1;
optional Type type = 2;
// This is signed so it fits in a SQLite integer column.
optional sfixed64 ring_id = 3;
}
optional DeviceToDevice group_call_message = 1;
optional RingIntention ring_intention = 2;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,405 @@
/*
* Copyright 2020-2021 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
syntax = "proto3";
package signalservice;
option java_package = "org.signal.storageservice.storage.protos.contacts";
option java_outer_classname = "StorageProtos";
option java_multiple_files = true;
enum OptionalBool {
UNSET = 0;
ENABLED = 1;
DISABLED = 2;
}
message StorageManifest {
uint64 version = 1;
bytes value = 2;
}
message StorageItem {
bytes key = 1;
bytes value = 2;
}
message StorageItems {
repeated StorageItem items = 1;
}
message WriteOperation {
StorageManifest manifest = 1;
repeated StorageItem insertItem = 2;
repeated bytes deleteKey = 3;
bool clearAll = 4;
}
message ReadOperation {
repeated bytes readKey = 1;
}
message ManifestRecord {
message Identifier {
enum Type {
UNKNOWN = 0;
CONTACT = 1;
GROUPV1 = 2;
GROUPV2 = 3;
ACCOUNT = 4;
STORY_DISTRIBUTION_LIST = 5;
STICKER_PACK = 6;
CALL_LINK = 7;
CHAT_FOLDER = 8;
NOTIFICATION_PROFILE = 9;
}
bytes raw = 1;
Type type = 2;
}
uint64 version = 1;
uint32 sourceDevice = 3;
repeated Identifier identifiers = 2;
bytes recordIkm = 4;
// Next ID: 5
}
message StorageRecord {
oneof record {
ContactRecord contact = 1;
GroupV1Record groupV1 = 2;
GroupV2Record groupV2 = 3;
AccountRecord account = 4;
StoryDistributionListRecord storyDistributionList = 5;
StickerPackRecord stickerPack = 6;
CallLinkRecord callLink = 7;
ChatFolderRecord chatFolder = 8;
NotificationProfile notificationProfile = 9;
}
}
// If unset - computed as the value of the first byte of SHA-256(msg=CONTACT_ID)
// modulo the count of colors. Once set the avatar color for a recipient is
// never recomputed or changed.
//
// `CONTACT_ID` is the first available identifier from the list:
// - ServiceIdToBinary(ACI)
// - E164
// - ServiceIdToBinary(PNI)
// - Group Id
enum AvatarColor {
A100 = 0;
A110 = 1;
A120 = 2;
A130 = 3;
A140 = 4;
A150 = 5;
A160 = 6;
A170 = 7;
A180 = 8;
A190 = 9;
A200 = 10;
A210 = 11;
}
message ContactRecord {
enum IdentityState {
DEFAULT = 0;
VERIFIED = 1;
UNVERIFIED = 2;
}
message Name {
string given = 1;
string family = 2;
}
// string aci = 1;
string e164 = 2;
// string pni = 15;
bytes profileKey = 3;
bytes identityKey = 4;
IdentityState identityState = 5;
string givenName = 6;
string familyName = 7;
string username = 8;
bool blocked = 9;
bool whitelisted = 10;
bool archived = 11;
bool markedUnread = 12;
uint64 mutedUntilTimestamp = 13;
bool hideStory = 14;
uint64 unregisteredAtTimestamp = 16;
string systemGivenName = 17;
string systemFamilyName = 18;
string systemNickname = 19;
bool hidden = 20;
bool pniSignatureVerified = 21;
Name nickname = 22;
string note = 23;
optional AvatarColor avatarColor = 24;
bytes aciBinary = 25; // 16-byte UUID
bytes pniBinary = 26; // 16-byte UUID
// Next ID: 27
}
message GroupV1Record {
bytes id = 1;
reserved /*blocked*/ 2;
reserved /*whitelisted*/ 3;
reserved /*archived*/ 4;
reserved /*markedUnread*/ 5;
reserved /*mutedUntilTimestamp*/ 6;
}
message GroupV2Record {
enum StorySendMode {
DEFAULT = 0;
DISABLED = 1;
ENABLED = 2;
}
bytes masterKey = 1;
bool blocked = 2;
bool whitelisted = 3;
bool archived = 4;
bool markedUnread = 5;
uint64 mutedUntilTimestamp = 6;
bool dontNotifyForMentionsIfMuted = 7;
bool hideStory = 8;
reserved 9;
StorySendMode storySendMode = 10;
optional AvatarColor avatarColor = 11;
}
message Payments {
bool enabled = 1;
bytes entropy = 2;
}
message AccountRecord {
enum PhoneNumberSharingMode {
UNKNOWN = 0;
EVERYBODY = 1;
NOBODY = 2;
}
message PinnedConversation {
message Contact {
// string serviceId = 1;
string e164 = 2;
bytes serviceIdBinary = 3; // service ID binary (i.e. 16 byte UUID for ACI, 1 byte prefix + 16 byte UUID for PNI)
}
oneof identifier {
Contact contact = 1;
bytes legacyGroupId = 3;
bytes groupMasterKey = 4;
}
}
message UsernameLink {
enum Color {
UNKNOWN = 0;
BLUE = 1;
WHITE = 2;
GREY = 3;
OLIVE = 4;
GREEN = 5;
ORANGE = 6;
PINK = 7;
PURPLE = 8;
}
bytes entropy = 1; // 32 bytes of entropy used for encryption
bytes serverId = 2; // 16 bytes of encoded UUID provided by the server
Color color = 3; // color of the QR code itself
}
message IAPSubscriberData {
bytes subscriberId = 1;
oneof iapSubscriptionId {
// Identifies an Android Play Store IAP subscription.
string purchaseToken = 2;
// Identifies an iOS App Store IAP subscription.
uint64 originalTransactionId = 3;
}
}
message BackupTierHistory {
// See zkgroup for integer particular values. Unset if backups are not enabled.
optional uint64 backupTier = 1;
optional uint64 endedAtTimestamp = 2;
}
message NotificationProfileManualOverride {
message ManuallyEnabled {
bytes id = 1;
// This will be unset if no timespan was chosen in the UI.
uint64 endAtTimestampMs = 3;
}
oneof override {
uint64 disabledAtTimestampMs = 1;
ManuallyEnabled enabled = 2;
}
}
bytes profileKey = 1;
string givenName = 2;
string familyName = 3;
string avatarUrlPath = 4;
bool noteToSelfArchived = 5;
bool readReceipts = 6;
bool sealedSenderIndicators = 7;
bool typingIndicators = 8;
reserved 9; // proxiedLinkPreviews
bool noteToSelfMarkedUnread = 10;
bool linkPreviews = 11;
PhoneNumberSharingMode phoneNumberSharingMode = 12;
bool unlistedPhoneNumber = 13;
repeated PinnedConversation pinnedConversations = 14;
bool preferContactAvatars = 15;
Payments payments = 16;
uint32 universalExpireTimer = 17;
reserved 18; // primarySendsSms
reserved 19; // deprecatedE164
repeated string preferredReactionEmoji = 20;
bytes donorSubscriberId = 21;
string donorSubscriberCurrencyCode = 22;
bool displayBadgesOnProfile = 23;
bool donorSubscriptionManuallyCancelled = 24;
bool keepMutedChatsArchived = 25;
bool hasSetMyStoriesPrivacy = 26;
bool hasViewedOnboardingStory = 27; // Whether the user has opened and played back the
// onboarding story in the story viewer.
reserved 28; // deprecatedStoriesDisabled
bool storiesDisabled = 29;
OptionalBool storyViewReceiptsEnabled = 30;
reserved 31; // hasReadOnboardingStory
bool hasSeenGroupStoryEducationSheet = 32; // Whether the user has seen the group story education
// sheet. This is a sticky value.
string username = 33; // Format: `nickname.discriminator`, e.g. `signalapp.123`
// Updated only when username is confirmed or deleted on server.
bool hasCompletedUsernameOnboarding = 34; // Whether the user has completed username
// onboarding.
UsernameLink usernameLink = 35;
reserved /*backupsSubscriberId*/ 36;
reserved /*backupsSubscriberCurrencyCode*/ 37;
reserved /*backupsSubscriptionManuallyCancelled*/ 38;
// Set to true after backups are enabled and one is uploaded.
optional bool hasBackup = 39;
// See zkgroup for integer particular values. Unset if backups are not enabled.
optional uint64 backupTier = 40;
IAPSubscriberData backupSubscriberData = 41;
optional AvatarColor avatarColor = 42;
NotificationProfileManualOverride notificationProfileManualOverride = 44;
bool notificationProfileSyncDisabled = 45;
}
message StoryDistributionListRecord {
bytes identifier = 1;
string name = 2;
// repeated string recipientServiceIds = 3;
uint64 deletedAtTimestamp = 4;
bool allowsReplies = 5;
bool isBlockList = 6;
repeated bytes recipientServiceIdsBinary = 7; // service ID binary (i.e. 16 byte UUID for ACI, 1 byte prefix + 16 byte UUID for PNI)
}
message StickerPackRecord {
bytes packId = 1; // 16 bytes
bytes packKey = 2; // 32 bytes, used to derive the AES-256 key
// aesKey = HKDF(
// input = packKey,
// salt = 32 zero bytes,
// info = "Sticker Pack"
// )
uint32 position = 3; // When displayed sticker packs should be first sorted
// in descending order by zero-based `position` and
// then by ascending `packId` (lexicographically,
// packId can be treated as a hex string).
// When installing a sticker pack the client should find
// the maximum `position` among currently known stickers
// and use `max_position + 1` as the value for the new
// `position`.
uint64 deletedAtTimestamp = 4; // Timestamp in milliseconds. When present and
// non-zero - `packKey` and `position` should
// be unset
}
message CallLinkRecord {
bytes rootKey = 1; // 16 bytes
bytes adminPasskey = 2; // Non-empty when the current user is an admin
uint64 deletedAtTimestampMs = 3; // When present and non-zero, `adminPasskey`
// should be cleared
}
message Recipient {
message Contact {
// string serviceId = 1;
string e164 = 2;
bytes serviceIdBinary = 3; // service ID binary (i.e. 16 byte UUID for ACI, 1 byte prefix + 16 byte UUID for PNI)
}
oneof identifier {
Contact contact = 1;
bytes legacyGroupId = 2;
bytes groupMasterKey = 3;
}
}
message ChatFolderRecord {
// Represents the default "All chats" folder record vs all other custom folders
enum FolderType {
UNKNOWN = 0;
ALL = 1;
CUSTOM = 2;
}
bytes id = 1;
string name = 2;
uint32 position = 3; // Position order of folder, low-to-high from start-to-end
bool showOnlyUnread = 4;
bool showMutedChats = 5;
bool includeAllIndividualChats = 6; // Folder includes all 1:1 chats, unless excluded
bool includeAllGroupChats = 7; // Folder includes all group chats, unless excluded
FolderType folderType = 8;
repeated Recipient includedRecipients = 9;
repeated Recipient excludedRecipients = 10;
uint64 deletedAtTimestampMs = 11; // When non-zero, `position` should be set to -1 and `includedRecipients` should be empty
}
message NotificationProfile {
enum DayOfWeek {
UNKNOWN = 0; // Interpret as "Monday"
MONDAY = 1;
TUESDAY = 2;
WEDNESDAY = 3;
THURSDAY = 4;
FRIDAY = 5;
SATURDAY = 6;
SUNDAY = 7;
}
bytes id = 1;
string name = 2;
optional string emoji = 3;
fixed32 color = 4; // 0xAARRGGBB
uint64 createdAtMs = 5;
bool allowAllCalls = 6;
bool allowAllMentions = 7;
repeated Recipient allowedMembers = 8;
bool scheduleEnabled = 9;
uint32 scheduleStartTime = 10; // 24-hour clock int, 0000-2359 (e.g., 15, 900, 1130, 2345)
uint32 scheduleEndTime = 11; // 24-hour clock int, 0000-2359 (e.g., 15, 900, 1130, 2345)
repeated DayOfWeek scheduleDaysEnabled = 12;
uint64 deletedAtTimestampMs = 13;
}
@@ -0,0 +1,16 @@
// Copyright 2019 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;
}
@@ -0,0 +1,34 @@
// Copyright 2014 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;
}
@@ -0,0 +1,70 @@
// Copyright 2018 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 {
// Our parser does not handle reserved in enums: DESKTOP-1569
// reserved 1;
MESSAGE = 2;
PREKEY_MESSAGE = 3;
// Further cases should line up with Envelope.Type, even though old cases don't.
// 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;
}
@@ -0,0 +1,128 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
// Note: proto2 is de-facto required here because BigQuery pub/sub
// subscriptions demand strict matching of "modes" (i.e. nullability), and
// the BigQuery subscription system doesn't recognize proto3 fields as
// "required".
syntax = "proto2";
package org.signal.calling.survey;
option java_multiple_files = true;
message CallQualitySurveyResponsePubSubMessage {
// A unique identifier for this call quality survey response
required string response_id = 1;
// The time at which this call quality survey response was received in
// microseconds since the epoch (see
// https://cloud.google.com/bigquery/docs/reference/standard-sql/data-types#timestamp_type)
required int64 submission_timestamp = 2;
// The geographic region (an ISO 3166-1 alpha-2 region code) associated with
// the IP address of the client that submitted this call quality survey
// response
optional string asn_region = 3;
// The platform of the client that submitted this call quality survey response
optional string client_platform = 4;
// The semantic version of the client that submitted this call quality survey
// response
optional string client_version = 5;
// Any additional specifiers (e.g. "Windows 10.0.19045 libsignal/0.81.1") from
// the caller's user-agent string
optional string client_ua_additional_specifiers = 6;
// Indicates whether the user was generally satisfied with the quality of the
// call
required bool user_satisfied = 7;
// A list of call quality issues selected by the user
repeated string call_quality_issues = 8;
// A free-form description of any additional issues as written by the user
optional string additional_issues_description = 9;
// A URL for a set of debug logs associated with the call if the user chose to
// submit debug logs
optional string debug_log_url = 10;
// The time at which the call started in microseconds since the epoch (see
// https://cloud.google.com/bigquery/docs/reference/standard-sql/data-types#timestamp_type)
required int64 start_timestamp = 11;
// The time at which the call ended in microseconds since the epoch (see
// https://cloud.google.com/bigquery/docs/reference/standard-sql/data-types#timestamp_type)
required int64 end_timestamp = 12;
// The type of call; note that direct voice calls can become video calls and
// vice versa, and this field indicates which mode was selected at call
// initiation time. At the time of writing, expected call types are
// "direct_voice", "direct_video", "group", and "call_link".
required string call_type = 13;
// Indicates whether the call completed without error or if it terminated
// abnormally
required bool success = 14;
// A client-defined, but human-readable reason for call termination
required string call_end_reason = 15;
// The median round-trip time, measured in milliseconds, for STUN/ICE packets
// (i.e. connection maintenance and establishment)
optional float connection_rtt_median = 16;
// The median round-trip time, measured in milliseconds, for RTP/RTCP packets
// for audio streams
optional float audio_rtt_median = 17;
// The median round-trip time, measured in milliseconds, for RTP/RTCP packets
// for video streams
optional float video_rtt_median = 18;
// The median jitter for audio streams, measured in milliseconds, for the
// duration of the call as measured by the client submitting the survey
optional float audio_recv_jitter_median = 19;
// The median jitter for video streams, measured in milliseconds, for the
// duration of the call as measured by the client submitting the survey
optional float video_recv_jitter_median = 20;
// The median jitter for audio streams, measured in milliseconds, for the
// duration of the call as measured by the remote endpoint in the call (either
// the peer of the client submitting the survey in a direct call or the SFU in
// a group call)
optional float audio_send_jitter_median = 21;
// The median jitter for video streams, measured in milliseconds, for the
// duration of the call as measured by the remote endpoint in the call (either
// the peer of the client submitting the survey in a direct call or the SFU in
// a group call)
optional float video_send_jitter_median = 22;
// The fraction of audio packets lost over the duration of the call as
// measured by the client submitting the survey
optional float audio_recv_packet_loss_fraction = 23;
// The fraction of video packets lost over the duration of the call as
// measured by the client submitting the survey
optional float video_recv_packet_loss_fraction = 24;
// The fraction of audio packets lost over the duration of the call as
// measured by the remote endpoint in the call (either the peer of the client
// submitting the survey in a direct call or the SFU in a group call)
optional float audio_send_packet_loss_fraction = 25;
// The fraction of video packets lost over the duration of the call as
// measured by the remote endpoint in the call (either the peer of the client
// submitting the survey in a direct call or the SFU in a group call)
optional float video_send_packet_loss_fraction = 26;
// Technical, machine-generated data about the quality and mechanics of a
// call; this is a serialized protobuf entity generated (and, critically,
// explained to the user!) by the calling library
optional bytes call_telemetry = 27;
}
@@ -0,0 +1,15 @@
/**
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
syntax = "proto3";
package org.signal.chat.auth;
option java_package = "org.whispersystems.textsecuregcm.auth";
option java_multiple_files = true;
message DisconnectionRequest {
bytes account_identifier = 1;
repeated uint32 device_ids = 2;
}
@@ -0,0 +1,62 @@
syntax = "proto2";
option java_package = "org.whispersystems.textsecuregcm.subscriptions";
/**
* A message that contains details about a new donation, whether a one-time "boost" or a recurring subscription.
*/
message DonationPubSubMessage {
/**
* The instant at which this donation took place in microseconds since the epoch.
*/
required int64 timestamp = 1;
/**
* A string identifying the source (either "web" or "app") from which this donation originated.
*/
required string source = 2;
/**
* An identifier for the payment provider that handled this donation (e.g. "stripe" or "braintree" or "donorbox").
*/
required string provider = 3;
/**
* If `true`, indicates that this donation is part of a subscription. If `false`, this is a one-time donation.
*/
required bool recurring = 4;
/**
* The type of payment method used for this donation (e.g. "credit_card" or "apple_pay" or "paypal").
*/
required string payment_method_type = 5;
/**
* The original amount of the donation before fees or conversion, in millionths of a full unit of the currency. For
* example, an amount of 9.75 USD would be represented as 9750000.
*/
required int64 original_amount_micros = 6;
/**
* The ISO 4217 identifier for the original currency of this donation (e.g. "USD" or "EUR").
*/
required string original_currency = 7;
/**
* The amount of the donation after conversion to USD in millionths of a dollar. If the original amount was in USD,
* this value must be the same as `original_amount_micros`.
*/
required int64 original_amount_usd_micros = 8;
/**
* The ISO 3166 country code of the country from which this donation originated. May be omitted if not known.
*/
optional string country = 9;
/**
* The platform of the client that made this donation (e.g. "ios" or "android" or "desktop") if known. May be omitted
* if not known.
*/
optional string client_platform = 10;
}
@@ -0,0 +1,399 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
syntax = "proto3";
option java_multiple_files = true;
option java_package = "org.signal.keytransparency.client";
package kt_query;
import "org/signal/chat/require.proto";
/**
* An external-facing, read-only key transparency service used by Signal's chat server
* to look up and monitor identifiers.
* There are three types of identifier mappings stored by the key transparency log:
* - An ACI which maps to an ACI identity key
* - An E164-formatted phone number which maps to an ACI
* - A username hash which also maps to an ACI
* Separately, the log also stores and periodically updates a fixed value known as the `distinguished` key.
* Clients use the verified tree head from looking up this key for future calls to the Search and Monitor endpoints.
*
* Note that this service definition is used in two different contexts:
* 1. Implementing the endpoints with rate-limiting and request validation
* 2. Using the generated client stub to forward requests to the remote key transparency service
*/
service KeyTransparencyQueryService {
option (org.signal.chat.require.auth) = AUTH_ONLY_ANONYMOUS;
/**
* An endpoint used by clients to retrieve the most recent distinguished tree
* head, which should be used to derive consistency parameters for
* subsequent Search and Monitor requests. It should be the first key
* transparency RPC a client calls.
*/
rpc Distinguished(DistinguishedRequest) returns (DistinguishedResponse) {}
/**
* An endpoint used by clients to search for one or more identifiers in the transparency log.
* The server returns proof that the identifier(s) exist in the log.
*/
rpc Search(SearchRequest) returns (SearchResponse) {}
/**
* An endpoint that allows users to monitor a group of identifiers by returning proof that the log continues to be
* constructed correctly in later entries for those identifiers.
*/
rpc Monitor(MonitorRequest) returns (MonitorResponse) {}
}
message SearchRequest {
/**
* The ACI to look up in the log.
*/
bytes aci = 1 [(org.signal.chat.require.exactlySize) = 16];
/**
* The ACI identity key that the client thinks the ACI maps to in the log.
*/
bytes aci_identity_key = 2 [(org.signal.chat.require.nonEmpty) = true];
/**
* The username hash to look up in the log.
*/
optional bytes username_hash = 3 [(org.signal.chat.require.exactlySize) = 0, (org.signal.chat.require.exactlySize) = 32];
/**
* The E164 to look up in the log along with associated data.
*/
optional E164SearchRequest e164_search_request = 4;
/**
* The tree head size(s) to prove consistency against.
*/
ConsistencyParameters consistency = 5 [(org.signal.chat.require.present) = true];
}
/**
* E164SearchRequest contains the data that the user must provide when looking up an E164.
*/
message E164SearchRequest {
/**
* The E164 that the client wishes to look up in the transparency log.
*/
optional string e164 = 1 [(org.signal.chat.require.e164) = true];
/**
* The unidentified access key of the account associated with the provided E164.
*/
bytes unidentified_access_key = 2;
}
/**
* SearchResponse contains search proofs for each of the requested identifiers.
*/
message SearchResponse {
/**
* A signed representation of the log tree's current state along with some
* additional information necessary for validation such as a consistency proof and an auditor-signed tree head.
*/
FullTreeHead tree_head = 1;
/**
* The ACI search response is always provided.
*/
CondensedTreeSearchResponse aci = 2;
/**
* This response is only provided if all of the conditions are met:
* - the E164 exists in the log
* - its mapped ACI matches the one provided in the request
* - the account associated with the ACI is discoverable
* - the unidentified access key provided in E164SearchRequest matches the one on the account
*/
optional CondensedTreeSearchResponse e164 = 3;
/**
* This response is only provided if the username hash exists in the log and
* its mapped ACI matches the one provided in the request.
*/
optional CondensedTreeSearchResponse username_hash = 4;
}
/**
* The tree head size(s) to prove consistency against. A client's very first
* key transparency request should be looking up the "distinguished" key;
* in this case, both fields will be omitted since the client has no previous
* tree heads to prove consistency against.
*/
message ConsistencyParameters {
/**
* The non-distinguished tree head size to prove consistency against.
* This field may be omitted if the client is looking up an identifier
* for the first time.
*/
optional uint64 last = 1;
/**
* The distinguished tree head size to prove consistency against.
* This field may be omitted when the client is looking up the
* "distinguished" key for the very first time.
*/
optional uint64 distinguished = 2;
}
/**
* DistinguishedRequest looks up the most recent distinguished key in the
* transparency log.
*/
message DistinguishedRequest {
/**
* The tree size of the client's last verified distinguished request. With the
* exception of a client's very first request, this field should always be
* set.
*/
optional uint64 last = 1;
}
/**
* DistinguishedResponse contains the tree head and search proof for the most
* recent `distinguished` key in the log.
*/
message DistinguishedResponse {
/**
* A signed representation of the log tree's current state along with some
* additional information necessary for validation such as a consistency proof and an auditor-signed tree head.
*/
FullTreeHead tree_head = 1;
/**
* This search response is always provided.
*/
CondensedTreeSearchResponse distinguished = 2;
}
message CondensedTreeSearchResponse {
/**
* A proof that is combined with the original requested identifier and the VRF public key
* and outputs whether the proof is valid, and if so, the commitment index.
*/
bytes vrf_proof = 1;
/**
* A proof that the binary search for the given identifier was done correctly.
*/
SearchProof search = 2;
/**
* A 32-byte value computed based on the log position of the identifier
* and a random 32 byte key that is only known by the key transparency service.
* It is provided so that clients can recompute and verify the commitment.
*/
bytes opening = 3;
/**
* The new or updated value that the identifier maps to.
*/
UpdateValue value = 4;
}
message FullTreeHead {
/**
* A representation of the log tree's current state signed by the key transparency service.
*/
TreeHead tree_head = 1;
/**
* A consistency proof between the current tree size and the requested tree size.
*/
repeated bytes last = 2;
/**
* A consistency proof between the current tree size and the requested distinguished tree size.
*/
repeated bytes distinguished = 3;
/**
* A list of tree heads signed by third-party auditors.
*/
repeated FullAuditorTreeHead full_auditor_tree_heads = 4;
}
/**
* TreeHead represents the key transparency service's view of the transparency log.
*/
message TreeHead {
/**
* The number of entries in the log tree.
*/
uint64 tree_size = 1;
/**
* The time in milliseconds since epoch when the tree head signature was generated.
*/
int64 timestamp = 2;
/**
* A list of the key transparency service's signatures over the transparency log. Since the
* signed data structure assumes one auditor, the key transparency service generates
* one signature per auditor.
*/
repeated Signature signatures = 3;
}
/**
* The key transparency service provides one Signature per auditor.
*/
message Signature {
/**
* The public component of the Ed25519 key pair that the auditor used to sign its view
* of the transparency log. This value allows clients to identify the corresponding signature.
*/
bytes auditor_public_key = 1;
/**
* The key transparency service's signature over the transparency log using the
* the given public auditor key.
*/
bytes signature = 2;
}
/**
* AuditorTreeHead represents an auditor's view of the transparency log.
*/
message AuditorTreeHead {
/**
* The number of entries in the auditor's view of the transparency log.
*/
uint64 tree_size = 1;
/**
* The time in milliseconds since epoch when the auditor's signature was generated.
*/
int64 timestamp = 2;
/**
* The auditor's signature computed over its view of the transparency log's current state
* and long-term log configuration.
*/
bytes signature = 3;
}
message FullAuditorTreeHead {
/**
* A representation of the log tree state signed by a third-party auditor.
*/
AuditorTreeHead tree_head = 1;
/**
* The root hash of the log tree when the auditor produced the tree head signature.
* Provided if the auditor tree head size is smaller than the size of the most recent
* tree head provided to the user.
*/
optional bytes root_value = 2;
/**
* A consistency proof between the auditor tree head and the most recent tree head.
* Provided if the auditor tree head size is smaller than the size of the most recent
* tree head provided by the key transparency service to the user.
*/
repeated bytes consistency = 3;
/**
* The public component of the Ed25519 key pair that the third-party auditor used to generate
* a signature. This value allows clients to identify the auditor tree head and signature.
*/
bytes public_key = 4;
}
/**
* A ProofStep represents one "step" or log entry in the binary search
* and can be used to calculate a log tree leaf hash.
*/
message ProofStep {
/**
* Provides the data needed to recompute the prefix tree root hash corresponding to the given log entry.
*/
PrefixSearchResult prefix = 1;
/**
* A cryptographic hash of the update used to calculate the log tree leaf hash.
*/
bytes commitment = 2;
}
message SearchProof {
/**
* The position in the log tree of the first occurrence of the requested identifier.
*/
uint64 pos = 1;
/**
* The steps of a binary search through the entries of the log tree for the given identifier version.
* Each ProofStep corresponds to a log entry and provides the information necessary to recompute a log tree
* leaf hash.
*/
repeated ProofStep steps = 2;
/**
* A batch inclusion proof for all log tree leaves involved in the binary search for the given identifier.
*/
repeated bytes inclusion = 3;
}
message UpdateValue {
/**
* The new mapped value for an identifier or the "distinguished" key.
*/
bytes value = 1;
}
message PrefixSearchResult {
/**
* A proof from a prefix tree that indicates a search was done correctly for a given identifier.
* The elements of this array are the copath of the prefix tree leaf node in bottom-to-top order.
*/
repeated bytes proof = 1;
/**
* The version of the requested identifier in the prefix tree.
*/
uint32 counter = 2;
}
message MonitorRequest {
AciMonitorRequest aci = 1 [(org.signal.chat.require.present) = true];
optional UsernameHashMonitorRequest username_hash = 2;
optional E164MonitorRequest e164 = 3;
ConsistencyParameters consistency = 4 [(org.signal.chat.require.present) = true];
}
message AciMonitorRequest {
bytes aci = 1 [(org.signal.chat.require.exactlySize) = 16];
uint64 entry_position = 2;
bytes commitment_index = 3 [(org.signal.chat.require.exactlySize) = 32];
}
message UsernameHashMonitorRequest {
bytes username_hash = 1 [(org.signal.chat.require.exactlySize) = 0, (org.signal.chat.require.exactlySize) = 32];
uint64 entry_position = 2;
bytes commitment_index = 3 [(org.signal.chat.require.exactlySize) = 0, (org.signal.chat.require.exactlySize) = 32];
}
message E164MonitorRequest {
optional string e164 = 1 [(org.signal.chat.require.e164) = true];
uint64 entry_position = 2;
bytes commitment_index = 3 [(org.signal.chat.require.exactlySize) = 0, (org.signal.chat.require.exactlySize) = 32];
}
message MonitorProof {
/**
* Generated based on the monitored entry provided in MonitorRequest.entries. Each ProofStep
* corresponds to a log tree entry that exists in the search path to each monitored entry
* and that came *after* that monitored entry. It proves that the log tree has been constructed
* correctly at that later entry. This list also includes any remaining entries
* along the "frontier" of the log tree which proves that the very last entry in the log
* has been constructed correctly.
*/
repeated ProofStep steps = 1;
}
message MonitorResponse {
/**
* A signed representation of the log tree's current state along with some
* additional information necessary for validation such as a consistency proof and an auditor-signed tree head.
*/
FullTreeHead tree_head = 1;
/**
* A proof that the MonitorRequest's ACI continues to be constructed correctly in later entries of the log tree.
*/
MonitorProof aci = 2;
/**
* A proof that the username hash continues to be constructed correctly in later entries of the log tree.
* Will be absent if the request did not include a UsernameHashMonitorRequest.
*/
optional MonitorProof username_hash = 3;
/**
* A proof that the e164 continues to be constructed correctly in later entries of the log tree.
* Will be absent if the request did not include a E164MonitorRequest.
*/
optional MonitorProof e164 = 4;
/**
* A batch inclusion proof that the log entries involved in the binary search for each of the entries
* being monitored in the request are included in the current log tree.
*/
repeated bytes inclusion = 5;
}
@@ -0,0 +1,24 @@
/**
* Copyright 2014 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
syntax = "proto2";
package textsecure;
option java_package = "org.whispersystems.textsecuregcm.storage";
option java_outer_classname = "PubSubProtos";
message PubSubMessage {
enum Type {
UNKNOWN = 0;
QUERY_DB = 1;
DELIVER = 2;
KEEPALIVE = 3;
CLOSE = 4;
CONNECTED = 5;
}
optional Type type = 1;
optional bytes content = 2;
}
@@ -0,0 +1,431 @@
syntax = "proto3";
option java_multiple_files = true;
package org.signal.registration.rpc;
service RegistrationService {
/**
* Create a new registration session for a given destination phone number.
*/
rpc CreateSession (CreateRegistrationSessionRequest) returns (CreateRegistrationSessionResponse) {}
/**
* Retrieves session metadata for a given session.
*/
rpc GetSessionMetadata (GetRegistrationSessionMetadataRequest) returns (GetRegistrationSessionMetadataResponse) {}
/**
* Sends a verification code to a destination phone number within the context
* of a previously-created registration session.
*/
rpc SendVerificationCode (SendVerificationCodeRequest) returns (SendVerificationCodeResponse) {}
/**
* Checks a client-provided verification code for a given registration
* session.
*/
rpc CheckVerificationCode (CheckVerificationCodeRequest) returns (CheckVerificationCodeResponse) {}
}
message CreateRegistrationSessionRequest {
/**
* The phone number for which to create a new registration session.
*/
uint64 e164 = 1;
/**
* Indicates whether an account already exists with the given e164 (i.e. this
* session represents a "re-registration" attempt).
*/
bool account_exists_with_e164 = 2;
/**
* The session creation rate limit for the number will be
* collated by this key.
*/
string rate_limit_collation_key = 3;
/**
* The MCC for the given `e164` as reported by a number lookup service.
*/
string mcc = 4;
/**
* The MNC for the given `e164` as reported by a number lookup service.
*/
string mnc = 5;
}
message CreateRegistrationSessionResponse {
oneof response {
/**
* Metadata for the newly-created session.
*/
RegistrationSessionMetadata session_metadata = 1;
/**
* A response explaining why a session could not be created as requested.
*/
CreateRegistrationSessionError error = 2;
}
}
message RegistrationSessionMetadata {
/**
* An opaque sequence of bytes that uniquely identifies the registration
* session associated with this registration attempt.
*/
bytes session_id = 1;
/**
* Indicates whether a valid verification code has been submitted in the scope
* of this session.
*/
bool verified = 2;
/**
* The phone number associated with this registration session.
*/
uint64 e164 = 3;
/**
* Indicates whether the caller may request delivery of a verification code
* via SMS now or at some time in the future. If true, the time a caller must
* wait before requesting a verification code via SMS is given in the
* `next_sms_seconds` field.
*/
bool may_request_sms = 4;
/**
* The duration, in seconds, after which a caller will next be allowed to
* request delivery of a verification code via SMS if `may_request_sms` is
* true. If zero, a caller may request a verification code via SMS
* immediately. If `may_request_sms` is false, this field has no meaning.
*/
uint64 next_sms_seconds = 5;
/**
* Indicates whether the caller may request delivery of a verification code
* via a phone call now or at some time in the future. If true, the time a
* caller must wait before requesting a verification code via SMS is given in
* the `next_voice_call_seconds` field. If false, simply waiting will not
* allow the caller to request a phone call and the caller may need to
* perform some other action (like attempting verification code delivery via
* SMS) before requesting a voice call.
*/
bool may_request_voice_call = 6;
/**
* The duration, in seconds, after which a caller will next be allowed to
* request delivery of a verification code via a phone call if
* `may_request_voice_call` is true. If zero, a caller may request a
* verification code via a phone call immediately. If `may_request_voice_call`
* is false, this field has no meaning.
*/
uint64 next_voice_call_seconds = 7;
/**
* Indicates whether the caller may submit new verification codes now or at
* some time in the future. If true, the time a caller must wait before
* submitting a verification code is given in the `next_code_check_seconds`
* field. If false, simply waiting will not allow the caller to submit a
* verification code and the caller may need to perform some other action
* (like requesting delivery of a verification code) before checking a
* verification code.
*/
bool may_check_code = 8;
/**
* The duration, in seconds, after which a caller will next be allowed to
* submit a verification code if `may_check_code` is true. If zero, a caller
* may submit a verification code immediately. If `may_check_code` is false,
* this field has no meaning.
*/
uint64 next_code_check_seconds = 9;
/**
* The duration, in seconds, after which this session will expire.
*/
uint64 expiration_seconds = 10;
}
message CreateRegistrationSessionError {
/**
* The type of error that prevented a session from being created.
*/
CreateRegistrationSessionErrorType error_type = 1;
/**
* Indicates that this error may succeed if retried without modification after
* a delay indicated by `retry_after_seconds`. If false, callers should not
* retry the request without modification.
*/
bool may_retry = 2;
/**
* If this error may be retried,, indicates the duration in seconds from the
* present after which the request may be retried without modification. This
* value has no meaning otherwise.
*/
uint64 retry_after_seconds = 3;
}
enum CreateRegistrationSessionErrorType {
CREATE_REGISTRATION_SESSION_ERROR_TYPE_UNSPECIFIED = 0;
/**
* Indicates that a session could not be created because too many requests to
* create a session for the given phone number have been received in some
* window of time. Callers should wait and try again later.
*/
CREATE_REGISTRATION_SESSION_ERROR_TYPE_RATE_LIMITED = 1;
/**
* Indicates that the provided phone number could not be parsed.
*/
CREATE_REGISTRATION_SESSION_ERROR_TYPE_ILLEGAL_PHONE_NUMBER = 2;
}
message GetRegistrationSessionMetadataRequest {
/**
* The ID of the session for which to retrieve metadata.
*/
bytes session_id = 1;
}
message GetRegistrationSessionMetadataResponse {
oneof response {
RegistrationSessionMetadata session_metadata = 1;
GetRegistrationSessionMetadataError error = 2;
}
}
message GetRegistrationSessionMetadataError {
GetRegistrationSessionMetadataErrorType error_type = 1;
}
enum GetRegistrationSessionMetadataErrorType {
GET_REGISTRATION_SESSION_METADATA_ERROR_TYPE_UNSPECIFIED = 0;
/**
* No session was found with the given identifier.
*/
GET_REGISTRATION_SESSION_METADATA_ERROR_TYPE_NOT_FOUND = 1;
}
message SendVerificationCodeRequest {
reserved 1;
/**
* The message transport to use to send a verification code to the destination
* phone number.
*/
MessageTransport transport = 2;
/**
* A prioritized list of languages accepted by the destination; should be
* provided in the same format as the value of an HTTP Accept-Language header.
*/
string accept_language = 3;
/**
* The type of client requesting a verification code.
*/
ClientType client_type = 4;
/**
* The ID of a session within which to send (or re-send) a verification code.
*/
bytes session_id = 5;
/**
* If provided, always attempt to use the specified sender to send
* this message.
*/
string sender_name = 6;
}
enum MessageTransport {
MESSAGE_TRANSPORT_UNSPECIFIED = 0;
MESSAGE_TRANSPORT_SMS = 1;
MESSAGE_TRANSPORT_VOICE = 2;
}
enum ClientType {
CLIENT_TYPE_UNSPECIFIED = 0;
CLIENT_TYPE_IOS = 1;
CLIENT_TYPE_ANDROID_WITH_FCM = 2;
CLIENT_TYPE_ANDROID_WITHOUT_FCM = 3;
}
message SendVerificationCodeResponse {
reserved 1;
/**
* Metadata for the named session. May be absent if the session could not be
* found or has expired.
*/
RegistrationSessionMetadata session_metadata = 2;
/**
* If a code could not be sent, explains the underlying error. Will be absent
* if a code was sent successfully. Note that both an error and session
* metadata may be present in the same response because the session metadata
* may include information helpful for resolving the underlying error (i.e.
* "next attempt" times).
*/
SendVerificationCodeError error = 3;
}
message SendVerificationCodeError {
/**
* The type of error that prevented a verification code from being sent.
*/
SendVerificationCodeErrorType error_type = 1;
/**
* Indicates that this error may succeed if retried without modification after
* a delay indicated by `retry_after_seconds`. If false, callers should not
* retry the request without modification.
*/
bool may_retry = 2;
/**
* If this error may be retried,, indicates the duration in seconds from the
* present after which the request may be retried without modification. This
* value has no meaning otherwise.
*/
uint64 retry_after_seconds = 3;
}
enum SendVerificationCodeErrorType {
SEND_VERIFICATION_CODE_ERROR_TYPE_UNSPECIFIED = 0;
/**
* The sender received and understood the request to send a verification code,
* but declined to do so (i.e. due to rate limits or suspected fraud).
*/
SEND_VERIFICATION_CODE_ERROR_TYPE_SENDER_REJECTED = 1;
/**
* The sender could not process or would not accept some part of a request
* (e.g. a valid phone number that cannot receive SMS messages).
*/
SEND_VERIFICATION_CODE_ERROR_TYPE_SENDER_ILLEGAL_ARGUMENT = 2;
/**
* A verification could could not be sent via the requested channel due to
* timing/rate restrictions. The response object containing this error should
* include session metadata that indicates when the next attempt is allowed.
*/
SEND_VERIFICATION_CODE_ERROR_TYPE_RATE_LIMITED = 3;
/**
* No session was found with the given ID.
*/
SEND_VERIFICATION_CODE_ERROR_TYPE_SESSION_NOT_FOUND = 4;
/**
* A new verification could could not be sent because the session has already
* been verified.
*/
SEND_VERIFICATION_CODE_ERROR_TYPE_SESSION_ALREADY_VERIFIED = 5;
/**
* A verification code could not be sent via the requested transport because
* the destination phone number (or the sender) does not support the requested
* transport.
*/
SEND_VERIFICATION_CODE_ERROR_TYPE_TRANSPORT_NOT_ALLOWED = 6;
/**
* The sender declined to send the verification code due to suspected fraud
*/
SEND_VERIFICATION_CODE_ERROR_TYPE_SUSPECTED_FRAUD = 7;
}
message CheckVerificationCodeRequest {
/**
* The session ID returned when sending a verification code.
*/
bytes session_id = 1;
/**
* The client-provided verification code.
*/
string verification_code = 2;
}
message CheckVerificationCodeResponse {
reserved 1;
/**
* Metadata for the named session. May be absent if the session could not be
* found or has expired.
*/
RegistrationSessionMetadata session_metadata = 2;
/**
* If a code could not be checked, explains the underlying error. Will be
* absent if no error occurred. Note that both an error and session
* metadata may be present in the same response because the session metadata
* may include information helpful for resolving the underlying error (i.e.
* "next attempt" times).
*/
CheckVerificationCodeError error = 3;
}
message CheckVerificationCodeError {
/**
* The type of error that prevented a verification code from being checked.
*/
CheckVerificationCodeErrorType error_type = 1;
/**
* Indicates that this error may succeed if retried without modification after
* a delay indicated by `retry_after_seconds`. If false, callers should not
* retry the request without modification.
*/
bool may_retry = 2;
/**
* If this error may be retried,, indicates the duration in seconds from the
* present after which the request may be retried without modification. This
* value has no meaning otherwise.
*/
uint64 retry_after_seconds = 3;
}
enum CheckVerificationCodeErrorType {
CHECK_VERIFICATION_CODE_ERROR_TYPE_UNSPECIFIED = 0;
/**
* The caller has attempted to submit a verification code even though no
* verification codes have been sent within the scope of this session. The
* caller must issue a "send code" request before trying again.
*/
CHECK_VERIFICATION_CODE_ERROR_TYPE_NO_CODE_SENT = 1;
/**
* The caller has made too many guesses within some period of time. Callers
* should wait for the duration prescribed in the session metadata object
* elsewhere in the response before trying again.
*/
CHECK_VERIFICATION_CODE_ERROR_TYPE_RATE_LIMITED = 2;
/**
* The session identified in this request could not be found (possibly due to
* session expiration).
*/
CHECK_VERIFICATION_CODE_ERROR_TYPE_SESSION_NOT_FOUND = 3;
/**
* The session identified in this request is still active, but the most
* recently-sent code has expired. Callers should request a new code, then
* try again.
*/
CHECK_VERIFICATION_CODE_ERROR_TYPE_ATTEMPT_EXPIRED = 4;
}
@@ -0,0 +1,77 @@
/**
* Copyright 2013 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
syntax = "proto2";
package textsecure;
option java_package = "org.whispersystems.textsecuregcm.entities";
option java_outer_classname = "MessageProtos";
message Envelope {
enum Type {
reserved 2, 7;
UNKNOWN = 0;
CIPHERTEXT = 1;
PREKEY_BUNDLE = 3;
SERVER_DELIVERY_RECEIPT = 5;
UNIDENTIFIED_SENDER = 6;
PLAINTEXT_CONTENT = 8; // for decryption error receipts
}
optional Type type = 1;
optional string source_service_id = 11;
optional uint32 source_device = 7;
optional uint64 client_timestamp = 5;
optional bytes content = 8; // Contains an encrypted Content
optional string server_guid = 9;
optional uint64 server_timestamp = 10;
optional bool ephemeral = 12; // indicates that the message should not be persisted if the recipient is offline
optional string destination_service_id = 13;
optional bool urgent = 14 [default=true];
optional string updated_pni = 15;
optional bool story = 16; // indicates that the content is a story.
optional bytes report_spam_token = 17; // token sent when reporting spam
optional bytes shared_mrm_key = 18; // indicates content should be fetched from multi-recipient message datastore
optional bytes source_service_id_binary = 19; // service ID binary (i.e. 16 byte UUID for ACI, 1 byte prefix + 16 byte UUID for PNI)
optional bytes destination_service_id_binary = 20; // service ID binary (i.e. 16 byte UUID for ACI, 1 byte prefix + 16 byte UUID for PNI)
optional bytes server_guid_binary = 21; // 16-byte UUID
optional bytes updated_pni_binary = 22; // 16-byte UUID
// next: 22
}
message ProvisioningAddress {
optional string address = 1;
}
message ServerCertificate {
message Certificate {
optional uint32 id = 1;
optional bytes key = 2;
}
optional bytes certificate = 1;
optional bytes signature = 2;
}
message SenderCertificate {
message Certificate {
reserved 6;
optional string sender_e164 = 1;
optional bytes sender_uuid_= 7;
optional uint32 sender_device = 2;
optional fixed64 expires = 3;
optional bytes identity_key = 4;
oneof signer {
ServerCertificate signer_certificate = 5;
uint32 signer_id = 8;
}
// next: 9
}
optional bytes certificate = 1;
optional bytes signature = 2;
}
@@ -0,0 +1,39 @@
/**
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
syntax = "proto3";
package org.signal.chat.presence;
option java_package = "org.whispersystems.textsecuregcm.push";
option java_multiple_files = true;
message ClientEvent {
reserved 3;
oneof event {
NewMessageAvailableEvent new_message_available = 1;
ClientConnectedEvent client_connected = 2;
MessagesPersistedEvent messages_persisted = 4;
}
}
/**
* Indicates that a new message is available for the client to retrieve.
*/
message NewMessageAvailableEvent {
}
/**
* Indicates that a client has connected to the presence system.
*/
message ClientConnectedEvent {
bytes server_id = 1;
}
/**
* Indicates that messages for the client have been persisted from short-term
* storage to long-term storage.
*/
message MessagesPersistedEvent {
}
@@ -0,0 +1,21 @@
syntax = "proto3";
package google.protobuf;
option go_package = "google.golang.org/protobuf/types/known/emptypb";
option java_package = "com.google.protobuf";
option java_outer_classname = "EmptyProto";
option java_multiple_files = true;
option objc_class_prefix = "GPB";
option csharp_namespace = "Google.Protobuf.WellKnownTypes";
option cc_enable_arenas = true;
// A generic empty message that you can re-use to avoid defining duplicated
// empty messages in your APIs. A typical example is to use it as the request
// or the response type of an API method. For instance:
//
// service Foo {
// rpc Bar(google.protobuf.Empty) returns (google.protobuf.Empty);
// }
//
message Empty {}
@@ -0,0 +1,267 @@
syntax = "proto3";
option java_multiple_files = true;
package org.signal.chat.account;
import "org/signal/chat/common.proto";
import "org/signal/chat/errors.proto";
import "org/signal/chat/require.proto";
import "org/signal/chat/tag.proto";
// Provides methods for working with Signal accounts.
service Accounts {
// Returns basic identifiers for the authenticated account.
rpc GetAccountIdentity(GetAccountIdentityRequest) returns (GetAccountIdentityResponse) {}
// Deletes the authenticated account, purging all associated data in the
// process.
rpc DeleteAccount(DeleteAccountRequest) returns (DeleteAccountResponse) {}
// Sets the registration lock secret for the authenticated account. To remove
// a registration lock, please use `ClearRegistrationLock`.
rpc SetRegistrationLock(SetRegistrationLockRequest) returns (SetRegistrationLockResponse) {}
// Removes any registration lock credentials from the authenticated account.
rpc ClearRegistrationLock(ClearRegistrationLockRequest) returns (ClearRegistrationLockResponse) {}
// Attempts to reserve one of multiple given username hashes. Reserved
// usernames may be claimed later via `ConfirmUsernameHash`.
rpc ReserveUsernameHash(ReserveUsernameHashRequest) returns (ReserveUsernameHashResponse) {}
// Sets the username hash/encrypted username to a previously-reserved value
// (see `ReserveUsernameHash`).
rpc ConfirmUsernameHash(ConfirmUsernameHashRequest) returns (ConfirmUsernameHashResponse) {}
// Clears the current username hash, ciphertext, and link for the
// authenticated user.
rpc DeleteUsernameHash(DeleteUsernameHashRequest) returns (DeleteUsernameHashResponse) {}
// Associates the given username ciphertext with the account, replacing any
// previously stored ciphertext. A new link handle will optionally be created,
// and the link handle to use will be returned in any event.
rpc SetUsernameLink(SetUsernameLinkRequest) returns (SetUsernameLinkResponse) {}
// Clears any username link associated with the authenticated account.
rpc DeleteUsernameLink(DeleteUsernameLinkRequest) returns (DeleteUsernameLinkResponse) {}
// Configures "unidentified access" keys and preferences for the authenticated
// account. Other users permitted to interact with this account anonymously
// may take actions like fetching pre-keys and profiles for this account or
// sending sealed-sender messages without providing identifying credentials.
rpc ConfigureUnidentifiedAccess(ConfigureUnidentifiedAccessRequest) returns (ConfigureUnidentifiedAccessResponse) {}
// Sets whether the authenticated account may be discovered by phone number
// via the Contact Discovery Service (CDS).
rpc SetDiscoverableByPhoneNumber(SetDiscoverableByPhoneNumberRequest) returns (SetDiscoverableByPhoneNumberResponse) {}
// Sets the registration recovery password for the authenticated account.
rpc SetRegistrationRecoveryPassword(SetRegistrationRecoveryPasswordRequest) returns (SetRegistrationRecoveryPasswordResponse) {}
}
// Provides methods for looking up Signal accounts. Callers must not provide
// identifying credentials when calling methods in this service.
service AccountsAnonymous {
// Checks whether an account with the given service identifier exists.
rpc CheckAccountExistence(CheckAccountExistenceRequest) returns (CheckAccountExistenceResponse) {}
// Finds the service identifier of the account associated with the given
// username hash.
rpc LookupUsernameHash(LookupUsernameHashRequest) returns (LookupUsernameHashResponse) {}
// Finds the encrypted username identified by a given username link handle.
rpc LookupUsernameLink(LookupUsernameLinkRequest) returns (LookupUsernameLinkResponse) {}
}
message GetAccountIdentityRequest {
}
message GetAccountIdentityResponse {
// A set of account identifiers for the authenticated account.
common.AccountIdentifiers account_identifiers = 1;
}
message DeleteAccountRequest {
}
message DeleteAccountResponse {
}
message SetRegistrationLockRequest {
// The new registration lock secret for the authenticated account.
bytes registration_lock = 1 [(require.exactlySize) = 32];
}
message SetRegistrationLockResponse {
}
message ClearRegistrationLockRequest {
}
message ClearRegistrationLockResponse {
}
message ReserveUsernameHashRequest {
// A prioritized list of username hashes to attempt to reserve. Each hash must
// be exactly 32 bytes.
repeated bytes username_hashes = 1 [(require.size) = {min: 1, max: 20}];
}
message UsernameNotAvailable {}
message ReserveUsernameHashResponse {
oneof response {
// The first username hash that was available (and actually reserved).
bytes username_hash = 1;
// Indicates that, of all of the candidate hashes provided, none were
// available. Callers may generate a new set of hashes and and retry.
UsernameNotAvailable username_not_available = 2 [(tag.reason) = "username_not_available"];
}
}
message ConfirmUsernameHashRequest {
// The username hash to claim for the authenticated account.
bytes username_hash = 1 [(require.exactlySize) = 32];
// A zero-knowledge proof that the given username hash was generated by the
// Signal username algorithm.
bytes zk_proof = 2 [(require.nonEmpty) = true];
// The ciphertext of the chosen username for use in public-facing contexts
// (e.g. links and QR codes).
bytes username_ciphertext = 3 [(require.size) = {min: 1, max: 128}];
}
message ConfirmUsernameHashResponse {
message ConfirmedUsernameHash {
// The newly-confirmed username hash.
bytes username_hash = 1;
// The server-generated username link handle for the newly-confirmed username.
bytes username_link_handle = 2;
}
oneof response {
// The details of the successfully confirmed username.
ConfirmedUsernameHash confirmed_username_hash = 1;
// The provided hash was not reserved for the account.
errors.FailedPrecondition reservation_not_found = 2 [(tag.reason) = "reservation_not_found"];
// The reservation has lapsed and the requested username has been claimed by
// another caller.
UsernameNotAvailable username_not_available = 3 [(tag.reason) = "username_not_available"];
}
}
message DeleteUsernameHashRequest {
}
message DeleteUsernameHashResponse {
}
message SetUsernameLinkRequest {
// The username ciphertext for which to generate a new link handle.
bytes username_ciphertext = 1 [(require.size) = {min: 1, max: 128}];
// If true and the account already had an encrypted username stored, the
// existing link handle will be reused. Otherwise a new link handle will be
// created.
bool keep_link_handle = 2;
}
message SetUsernameLinkResponse {
oneof response {
// A new link handle for the given username ciphertext.
bytes username_link_handle = 1;
// The authenticated account did not have a username set.
errors.FailedPrecondition no_username_set = 2 [(tag.reason) = "no_username_set"];
}
}
message DeleteUsernameLinkRequest {
}
message DeleteUsernameLinkResponse {
}
message ConfigureUnidentifiedAccessRequest {
// The key that other users must provide to interact with this account
// anonymously (i.e. to retrieve keys or profiles or to send messages) unless
// unrestricted unidentified access is permitted. Must be present if
// unrestricted unidentified access is not allowed.
bytes unidentified_access_key = 1;
// If `true`, any user may interact with this account anonymously without
// providing an unidentified access key. Otherwise, users must provide the
// given unidentified access key to interact with this account anonymously.
bool allow_unrestricted_unidentified_access = 2;
}
message ConfigureUnidentifiedAccessResponse {
}
message SetDiscoverableByPhoneNumberRequest {
// If true, the authenticated account may be discovered by phone number via
// the Contact Discovery Service (CDS). Otherwise, other users must discover
// this account by other means (i.e. by username).
bool discoverable_by_phone_number = 1;
}
message SetDiscoverableByPhoneNumberResponse {
}
message SetRegistrationRecoveryPasswordRequest {
// The new registration recovery password for the authenticated account.
bytes registration_recovery_password = 1 [(require.exactlySize) = 32];
}
message SetRegistrationRecoveryPasswordResponse {
}
message CheckAccountExistenceRequest {
// The service identifier of an account that may or may not exist.
common.ServiceIdentifier service_identifier = 1;
}
message CheckAccountExistenceResponse {
// True if an account exists with the given service identifier or false if no
// account was found.
bool account_exists = 1;
}
message LookupUsernameHashRequest {
// A 32-byte username hash for which to find an account.
bytes username_hash = 1 [(require.exactlySize) = 32];
}
message LookupUsernameHashResponse {
oneof response {
// The service identifier associated with the provided username hash.
common.ServiceIdentifier service_identifier = 1;
// No account was found for the provided username hash.
errors.NotFound not_found = 2 [(tag.reason) = "not_found"];
}
}
message LookupUsernameLinkRequest {
// The link handle for which to find an encrypted username. Link handles are
// 16-byte representations of UUIDs.
bytes username_link_handle = 1 [(require.exactlySize) = 16];
}
message LookupUsernameLinkResponse {
oneof response {
// The ciphertext of the username identified by the provided link handle.
bytes username_ciphertext = 1;
// No username was found for the provided link handle.
errors.NotFound not_found = 2 [(tag.reason) = "not_found"];
}
}
@@ -0,0 +1,39 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
syntax = "proto3";
option java_multiple_files = true;
package org.signal.chat.attachments;
import "org/signal/chat/common.proto";
import "org/signal/chat/require.proto";
import "org/signal/chat/errors.proto";
import "org/signal/chat/tag.proto";
service Attachments {
option (require.auth) = AUTH_ONLY_AUTHENTICATED;
// Retrieve an upload form that can be used to perform a resumable upload
rpc GetUploadForm(GetUploadFormRequest) returns (GetUploadFormResponse) {}
}
message GetUploadFormRequest {
// The length of the attachment for the requested upload form. Uploads
// performed with this form will be limited to the provided length.
uint64 uploadLength = 1 [(require.range) = {min: 1}];
}
message GetUploadFormResponse {
oneof outcome {
common.UploadForm upload_form = 1;
// The request size was larger than the maximum supported upload size. The
// maximum upload size is subject to change and is governed by
// `global.attachments.maxBytes`
errors.FailedPrecondition exceeds_max_upload_length = 2 [(tag.reason) = "oversize_upload"];
}
}
@@ -0,0 +1,539 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
syntax = "proto3";
option java_multiple_files = true;
package org.signal.chat.backup;
import "google/protobuf/empty.proto";
import "org/signal/chat/common.proto";
import "org/signal/chat/errors.proto";
import "org/signal/chat/require.proto";
import "org/signal/chat/tag.proto";
// Service for backup operations that require account authentication.
//
// Most actual backup operations operate on the backup-id and cannot be linked
// to the caller's account, but setting up anonymous credentials and changing
// backup tier requires account authentication.
service Backups {
option (require.auth) = AUTH_ONLY_AUTHENTICATED;
// Set (blinded) backup-id(s) for the account.
//
// Each account may have a single active backup-id for each credential type
// that can be used to store and retrieve backups. Once the backup-id is set,
// BackupAuthCredentials can be generated using GetBackupAuthCredentials.
//
// The blinded backup-id and the key-pair used to blind it must be derived
// from a recoverable secret.
//
// At least one of the credential types must be set on the request.
// Only the primary device can set a blinded backup-id.
rpc SetBackupId(SetBackupIdRequest) returns (SetBackupIdResponse) {}
// Redeem a receipt acquired from /v1/subscription/{subscriberId}/receipt_credentials
// to mark the account as eligible for the paid backup tier.
//
// After successful redemption, subsequent requests to
// GetBackupAuthCredentials will return credentials with the level on the
// provided receipt until the expiration time on the receipt.
rpc RedeemReceipt(RedeemReceiptRequest) returns (RedeemReceiptResponse) {}
// After setting a blinded backup-id with PUT /v1/archives/, this fetches
// credentials that can be used to perform operations against that backup-id.
// Clients may (and should) request up to 7 days of credentials at a time.
//
// The redemption_start and redemption_end seconds must be UTC day aligned, and
// must not span more than 7 days.
//
// Each credential contains a receipt level which indicates the backup level
// the credential is good for. If the account has paid backup access that
// expires at some point in the provided redemption window, credentials with
// redemption times after the expiration may be on a lower backup level.
//
// Clients must validate the receipt level on the credential matches a known
// receipt level before using it.
rpc GetBackupAuthCredentials(GetBackupAuthCredentialsRequest) returns (GetBackupAuthCredentialsResponse) {}
}
message SetBackupIdRequest {
// A BackupAuthCredentialRequest containing a blinded encrypted backup-id,
// encoded in standard padded base64. This backup-id should be used for
// message backups only, and must have the message backup type set on the
// credential. If absent, the message credential request will not be updated.
bytes messages_backup_auth_credential_request = 1;
// A BackupAuthCredentialRequest containing a blinded encrypted backup-id,
// encoded in standard padded base64. This backup-id should be used for
// media only, and must have the media type set on the credential. If absent,
// the media credential request will not be updated.
bytes media_backup_auth_credential_request = 2;
}
message SetBackupIdResponse {}
message RedeemReceiptRequest {
// Presentation for a previously acquired receipt, serialized with libsignal
bytes presentation = 1;
}
message RedeemReceiptResponse {
oneof response {
// The receipt was successfully redeemed
google.protobuf.Empty success = 1;
// The target account does not have a backup-id commitment
errors.FailedPrecondition account_missing_commitment = 2 [(tag.reason) = "account_missing_commitment"];
// The provided receipt presentation was malformed or expired
errors.FailedPrecondition invalid_receipt = 3 [(tag.reason) = "invalid_receipt"];
}
}
message GetBackupAuthCredentialsRequest {
// The redemption time for the first credential. This must be a day-aligned
// seconds since epoch in UTC.
int64 redemption_start = 1 [(require.range).min = 1];
// The redemption time for the last credential. This must be a day-aligned
// seconds since epoch in UTC. The span between redemptionStart and
// redemptionEnd must not exceed 7 days.
int64 redemption_stop = 2 [(require.range).min = 1];
}
message GetBackupAuthCredentialsResponse {
message Credentials {
// The requested message backup ZkCredentials indexed by the start of their
// validity period. The smallest key should be for the requested
// redemption_start, the largest for the requested redemption_end.
map<int64, common.ZkCredential> message_credentials = 1;
// The requested media backup ZkCredentials indexed by the start of their
// validity period. The smallest key should be for the requested
// redemption_start, the largest for the requested redemption_end.
map<int64, common.ZkCredential> media_credentials = 2;
}
// The requested credentials. If absent, there was no existing blinded
// backup id associated with the provided account.
Credentials credentials = 1;
}
// Service for backup operations with anonymous credentials
//
// This service never requires account authentication. It instead requires a
// backup-id authenticated with an anonymous credential that cannot be linked
// to the account.
//
// To register an anonymous credential:
// 1. Set a backup-id on the authenticated channel via Backups::SetBackupId
// 2. Retrieve BackupAuthCredentials via Backups::GetBackupAuthCredentials
// 3. Generate a key pair and set the public key via
// BackupsAnonymous::SetPublicKey
//
// Unless otherwise noted, requests for this service require a
// SignedPresentation, which includes:
// - a presentation generated from a BackupAuthCredential issued by
// GetBackupAuthCredentials
// - a signature of that presentation using the private key of a key pair
// previously set with SetPublicKey.
service BackupsAnonymous {
option (require.auth) = AUTH_ONLY_ANONYMOUS;
// Retrieve credentials used to read objects stored on the backup cdn
rpc GetCdnCredentials(GetCdnCredentialsRequest) returns (GetCdnCredentialsResponse) {}
// Retrieve credentials used to interact with the SecureValueRecoveryB service
rpc GetSvrBCredentials(GetSvrBCredentialsRequest) returns (GetSvrBCredentialsResponse) {}
// Retrieve information about the currently stored message backup
rpc GetMessageBackupInfo(GetBackupInfoRequest) returns (GetMessageBackupInfoResponse) {}
// Retrieve information about the currently stored media backup
rpc GetMediaBackupInfo(GetBackupInfoRequest) returns (GetMediaBackupInfoResponse) {}
// Permanently set the public key of an ED25519 key-pair for the backup-id.
// All requests (including this one!) must sign their BackupAuthCredential
// presentations with the private key corresponding to the provided public key.
rpc SetPublicKey(SetPublicKeyRequest) returns (SetPublicKeyResponse) {}
// Refresh the backup, indicating that the backup is still active. Clients
// must periodically upload new backups or perform a refresh. If a backup has
// not been active for 30 days, it may be deleted.
rpc Refresh(RefreshRequest) returns (RefreshResponse) {}
// Retrieve an upload form that can be used to perform a resumable upload
rpc GetUploadForm(GetUploadFormRequest) returns (GetUploadFormResponse) {}
// Copy and re-encrypt media from the attachments cdn into the backup cdn.
// The original, already encrypted, attachments will be encrypted with the
// provided key material before being copied.
//
// The copy operation is not atomic and responses will be returned as copy
// operations complete with detailed information about the outcome. If an
// error is encountered, not all requests may be reflected in the responses.
//
// On retries, a particular destination media id must not be reused with a
// different source media id or different encryption parameters.
rpc CopyMedia(CopyMediaRequest) returns (stream CopyMediaResponse) {}
// Retrieve a page of media objects stored for this backup-id. A client may
// have previously stored media objects that are no longer referenced in their
// current backup. To reclaim storage space used by these orphaned objects,
// perform a list operation and remove any unreferenced media objects
// via DeleteMedia.
rpc ListMedia(ListMediaRequest) returns (ListMediaResponse) {}
// Delete media objects stored with this backup-id. Streams the locations of
// media items back when the item has successfully been removed.
rpc DeleteMedia(DeleteMediaRequest) returns (stream DeleteMediaResponse) {}
// Delete all backup metadata, objects, and stored public key. To use
// backups again, a public key must be resupplied.
rpc DeleteAll(DeleteAllRequest) returns (DeleteAllResponse) {}
}
message SignedPresentation {
// Presentation of a BackupAuthCredential previously retrieved from
// GetBackupAuthCredentials on the authenticated channel
bytes presentation = 1 [(require.nonEmpty) = true];
// The presentation signed with the private key corresponding to the public
// key set with SetPublicKey
bytes presentation_signature = 2 [(require.nonEmpty) = true];
}
message SetPublicKeyRequest {
SignedPresentation signed_presentation = 1;
// The public key, serialized in libsignal's elliptic-curve public key format.
bytes public_key = 2 [(require.nonEmpty) = true];
}
message SetPublicKeyResponse {
oneof response {
// The public key was successfully set
google.protobuf.Empty success = 1;
// The provided backup auth credential presentation could not be
// authenticated. Either, the presentation could not be verified, or
// the public key signature was invalid, or there is no backup associated
// with the backup-id in the presentation.
//
// This may also be returned if there was an existing public key and the
// provided public key did not match.
errors.FailedZkAuthentication failed_authentication = 2 [(tag.reason) = "failed_authentication"];
}
}
message GetCdnCredentialsRequest {
SignedPresentation signed_presentation = 1;
uint32 cdn = 2;
}
message GetCdnCredentialsResponse {
message CdnCredentials {
map<string, string> headers = 1;
}
oneof response {
// Headers to include with requests to the read from the backup CDN. Includes
// time limited read-only credentials.
CdnCredentials cdn_credentials = 1;
// The provided backup auth credential presentation could not be
// authenticated. Either, the presentation could not be verified, or
// the public key signature was invalid, or there is no backup associated
// with the backup-id in the presentation.
errors.FailedZkAuthentication failed_authentication = 2 [(tag.reason) = "failed_authentication"];
}
}
message GetSvrBCredentialsRequest {
SignedPresentation signed_presentation = 1;
}
message GetSvrBCredentialsResponse {
message SvrBCredentials {
// A username that can be presented to authenticate with SVRB
string username = 1;
// A password that can be presented to authenticate with SVRB
string password = 2;
}
oneof response {
SvrBCredentials svrb_credentials = 1;
// The provided backup auth credential presentation could not be
// authenticated. Either, the presentation could not be verified, or
// the public key signature was invalid, or there is no backup associated
// with the backup-id in the presentation.
errors.FailedZkAuthentication failed_authentication = 2 [(tag.reason) = "failed_authentication"];
}
}
message GetBackupInfoRequest {
SignedPresentation signed_presentation = 1;
}
message GetMessageBackupInfoResponse {
message MessageBackupInfo {
// The base directory of your backup data on the cdn. The message backup can
// be found in the returned cdn at /backup_dir/backup_name
string backup_dir = 1;
// The CDN type where the message backup is stored. Media may be stored
// elsewhere.
uint32 cdn = 2;
// The location of the message backup on the cdn. If a backup was previously
// uploaded and unexpired, it can be found at /backup_dir/backup_name.
string backup_name = 3;
}
oneof response {
MessageBackupInfo backup_info = 1;
// The provided backup auth credential presentation could not be
// authenticated. Either, the presentation could not be verified, or
// the public key signature was invalid, or there is no backup associated
// with the backup-id in the presentation.
errors.FailedZkAuthentication failed_authentication = 2 [(tag.reason) = "failed_authentication"];
}
}
message GetMediaBackupInfoResponse {
message MediaBackupInfo {
// The base directory of your backup data on the cdn.
string backup_dir = 1;
// The prefix path component for media objects on a cdn. Stored media for a
// media_id can be found at /backup_dir/media_dir/media_id, where the media_id
// is encoded in unpadded url-safe base64.
string media_dir = 2;
// The amount of space used to store media
uint64 used_space = 3;
}
oneof response {
MediaBackupInfo backup_info = 1;
// The provided backup auth credential presentation could not be
// authenticated. Either, the presentation could not be verified, or
// the public key signature was invalid, or there is no backup associated
// with the backup-id in the presentation.
errors.FailedZkAuthentication failed_authentication = 2 [(tag.reason) = "failed_authentication"];
}
}
message RefreshRequest {
SignedPresentation signed_presentation = 1;
}
message RefreshResponse {
oneof response {
// The backup was successfully refreshed
google.protobuf.Empty success = 1;
// The provided backup auth credential presentation could not be
// authenticated. Either, the presentation could not be verified, or
// the public key signature was invalid, or there is no backup associated
// with the backup-id in the presentation.
errors.FailedZkAuthentication failed_authentication = 2 [(tag.reason) = "failed_authentication"];
}
}
message GetUploadFormRequest {
SignedPresentation signed_presentation = 1;
message MessagesUploadType {}
message MediaUploadType {}
oneof upload_type {
// Retrieve an upload form that can be used to perform a resumable upload of
// a message backup. The finished upload will be available on the backup cdn.
MessagesUploadType messages = 2;
// Retrieve an upload form for a temporary location that can be used to
// perform a resumable upload of an attachment. After uploading, the
// attachment can be copied into the backup via CopyMedia.
//
// Behaves identically to the account authenticated version at /attachments.
MediaUploadType media = 3;
}
// The length of the attachment for the requested upload form. Uploads
// performed with this form will be limited to the provided length.
uint64 uploadLength = 4 [(require.range) = {min: 1}];
}
message GetUploadFormResponse {
oneof response {
common.UploadForm upload_form = 1;
// The provided backup auth credential presentation could not be
// authenticated. Either, the presentation could not be verified, or
// the public key signature was invalid, or there is no backup associated
// with the backup-id in the presentation.
errors.FailedZkAuthentication failed_authentication = 2 [(tag.reason) = "failed_authentication"];
// The request size was larger than the maximum supported upload size. The
// maximum upload size is subject to change and is governed by
// `global.attachments.maxBytes`
errors.FailedPrecondition exceeds_max_upload_length = 3 [(tag.reason) = "oversize_upload"];
}
}
message CopyMediaItem {
// The attachment cdn of the object to copy into the backup
uint32 source_attachment_cdn = 1 [(require.range).min = 1, (require.range).max = 3];
// The attachment key of the object to copy into the backup
string source_key = 2 [(require.nonEmpty) = true];
// The length of the source attachment before the encryption applied by the
// copy operation
uint32 object_length = 3;
// media_id to copy on to the backup CDN
bytes media_id = 4 [(require.exactlySize) = 15];
// A 32-byte key for the MAC
bytes hmac_key = 5 [(require.exactlySize) = 32];
// A 32-byte encryption key for AES
bytes encryption_key = 6 [(require.exactlySize) = 32];
}
message CopyMediaRequest {
SignedPresentation signed_presentation = 1;
// Items to copy
repeated CopyMediaItem items = 2 [(require.size) = {min: 1, max: 1000}];
}
message CopyMediaResponse {
message SourceNotFound {}
message WrongSourceLength {}
message OutOfSpace {}
message CopySuccess {
// The backup cdn where this media object is stored
uint32 cdn = 1;
}
// The 15-byte media_id from the corresponding CopyMediaItem in the request
bytes media_id = 1;
oneof response {
// The media item was successfully copied into the backup
CopySuccess success = 2;
// The provided backup auth credential presentation could not be
// authenticated. Either, the presentation could not be verified, or
// the public key signature was invalid, or there is no backup associated
// with the backup-id in the presentation.
errors.FailedZkAuthentication failed_authentication = 3 [(tag.reason) = "failed_authentication"];
// The source object was not found
SourceNotFound source_not_found = 4 [(tag.reason) = "source_not_found"];
// The provided object length was incorrect
WrongSourceLength wrong_source_length = 5 [(tag.reason) = "wrong_source_length"];
// All media capacity has been consumed. Free some space to continue.
OutOfSpace out_of_space = 6 [(tag.reason) = "out_of_space"];
}
}
message ListMediaRequest {
SignedPresentation signed_presentation = 1;
// A cursor returned by a previous call to ListMedia, absent on the first call
optional string cursor = 2;
// If provided, the maximum number of entries to return in a page
uint32 limit = 3 [(require.range) = {min: 1, max: 10000}];
}
message ListMediaResponse {
message ListEntry {
// The backup cdn where this media object is stored
uint32 cdn = 1;
// The media_id of the object
bytes media_id = 2;
// The length of the object in bytes
uint64 length = 3;
}
message ListResult {
// A page of media objects stored for this backup ID
repeated ListEntry page = 1;
// The base directory of the backup data on the cdn. The stored media can be
// found at /backup_dir/media_dir/media_id, where the media_id is encoded with
// unpadded url-safe base64.
string backup_dir = 2;
// The prefix path component for the media objects. The stored media for
// media_id can be found at /backup_dir/media_dir/media_id, where the media_id
// is encoded with unpadded url-safe base64.
string media_dir = 3;
// If set, the cursor value to pass to the next list request to continue
// listing. If absent, all objects have been listed
optional string cursor = 4;
}
oneof response {
ListResult list_result = 1;
// The provided backup auth credential presentation could not be
// authenticated. Either, the presentation could not be verified, or
// the public key signature was invalid, or there is no backup associated
// with the backup-id in the presentation.
errors.FailedZkAuthentication failed_authentication = 2 [(tag.reason) = "failed_authentication"];
}
}
message DeleteAllRequest {
SignedPresentation signed_presentation = 1;
}
message DeleteAllResponse {
oneof response {
// The backup was successfully scheduled for deletion
google.protobuf.Empty success = 1;
// The provided backup auth credential presentation could not be
// authenticated. Either, the presentation could not be verified, or
// the public key signature was invalid, or there is no backup associated
// with the backup-id in the presentation.
errors.FailedZkAuthentication failed_authentication = 2 [(tag.reason) = "failed_authentication"];
}
}
message DeleteMediaItem {
// The backup cdn where this media object is stored
uint32 cdn = 1;
// The media_id of the object to delete
bytes media_id = 2 [(require.exactlySize) = 15];
}
message DeleteMediaRequest {
SignedPresentation signed_presentation = 1;
repeated DeleteMediaItem items = 2 [(require.size) = {min: 1, max: 1000}];
}
message DeleteMediaResponse {
oneof response {
DeleteMediaItem deleted_item = 1;
// The provided backup auth credential presentation could not be
// authenticated. Either, the presentation could not be verified, or
// the public key signature was invalid, or there is no backup associated
// with the backup-id in the presentation.
errors.FailedZkAuthentication failed_authentication = 2 [(tag.reason) = "failed_authentication"];
}
}
@@ -0,0 +1,110 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
syntax = "proto3";
option java_multiple_files = true;
package org.signal.chat.calling.quality;
// Provides methods for submitting call quality surveys
service CallQuality {
// Submits a call quality survey response.
rpc SubmitCallQualitySurvey(SubmitCallQualitySurveyRequest) returns (SubmitCallQualitySurveyResponse) {}
}
message SubmitCallQualitySurveyRequest {
// Indicates whether the caller was generally satisfied with the quality of
// the call
bool user_satisfied = 1;
// A list of call quality issues selected by the caller
repeated string call_quality_issues = 2;
// A free-form description of any additional issues as written by the caller
optional string additional_issues_description = 3;
// A URL for a set of debug logs associated with the call if the caller chose
// to submit debug logs
optional string debug_log_url = 4;
// The time at which the call started in milliseconds since the epoch
int64 start_timestamp = 5;
// The time at which the call ended in milliseconds since the epoch
int64 end_timestamp = 6;
// The type of call; note that direct voice calls can become video calls and
// vice versa, and this field indicates which mode was selected at call
// initiation time. At the time of writing, expected call types are
// "direct_voice", "direct_video", "group", and "call_link".
string call_type = 7;
// Indicates whether the call completed without error or if it terminated
// abnormally
bool success = 8;
// A client-defined, but human-readable reason for call termination
string call_end_reason = 9;
// The median round-trip time, measured in milliseconds, for STUN/ICE packets
// (i.e. connection maintenance and establishment)
optional float connection_rtt_median = 10;
// The median round-trip time, measured in milliseconds, for RTP/RTCP packets
// for audio streams
optional float audio_rtt_median = 11;
// The median round-trip time, measured in milliseconds, for RTP/RTCP packets
// for video streams
optional float video_rtt_median = 12;
// The median jitter for audio streams, measured in milliseconds, for the
// duration of the call as measured by the client submitting the survey
optional float audio_recv_jitter_median = 13;
// The median jitter for video streams, measured in milliseconds, for the
// duration of the call as measured by the client submitting the survey
optional float video_recv_jitter_median = 14;
// The median jitter for audio streams, measured in milliseconds, for the
// duration of the call as measured by the remote endpoint in the call (either
// the peer of the client submitting the survey in a direct call or the SFU in
// a group call)
optional float audio_send_jitter_median = 15;
// The median jitter for video streams, measured in milliseconds, for the
// duration of the call as measured by the remote endpoint in the call (either
// the peer of the client submitting the survey in a direct call or the SFU in
// a group call)
optional float video_send_jitter_median = 16;
// The fraction of audio packets lost over the duration of the call as
// measured by the client submitting the survey
optional float audio_recv_packet_loss_fraction = 17;
// The fraction of video packets lost over the duration of the call as
// measured by the client submitting the survey
optional float video_recv_packet_loss_fraction = 18;
// The fraction of audio packets lost over the duration of the call as
// measured by the remote endpoint in the call (either the peer of the client
// submitting the survey in a direct call or the SFU in a group call)
optional float audio_send_packet_loss_fraction = 19;
// The fraction of video packets lost over the duration of the call as
// measured by the remote endpoint in the call (either the peer of the client
// submitting the survey in a direct call or the SFU in a group call)
optional float video_send_packet_loss_fraction = 20;
// Machine-generated telemetry from the call; this is a serialized protobuf
// entity generated (and, critically, explained to the user!) by the calling
// library
optional bytes call_telemetry = 21;
}
message SubmitCallQualitySurveyResponse {
}
@@ -0,0 +1,31 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
syntax = "proto3";
option java_multiple_files = true;
package org.signal.chat.calling;
// Provides methods for getting credentials for one-on-one and group calls.
service Calling {
// Generates and returns TURN credentials for the caller.
rpc GetTurnCredentials(GetTurnCredentialsRequest) returns (GetTurnCredentialsResponse) {}
}
message GetTurnCredentialsRequest {}
message GetTurnCredentialsResponse {
// A username that can be presented to authenticate with a TURN server.
string username = 1;
// A password that can be presented to authenticate with a TURN server.
string password = 2;
// A list of TURN (or TURNS or STUN) servers where the provided credentials
// may be used.
repeated string urls = 3;
}
@@ -0,0 +1,115 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
syntax = "proto3";
option java_multiple_files = true;
package org.signal.chat.common;
import "org/signal/chat/require.proto";
enum IdentityType {
IDENTITY_TYPE_UNSPECIFIED = 0;
IDENTITY_TYPE_ACI = 1;
IDENTITY_TYPE_PNI = 2;
}
message ServiceIdentifier {
// The type of identity represented by this service identifier.
IdentityType identity_type = 1;
// The UUID of the identity represented by this service identifier.
bytes uuid = 2 [(require.exactlySize) = 16];
}
message AccountIdentifiers {
// A list of service identifiers for the identified account.
repeated ServiceIdentifier service_identifiers = 1;
// The phone number associated with the identified account.
string e164 = 2;
// The username hash (if any) associated with the identified account. May be
// empty if no username is associated with the identified account.
bytes username_hash = 3;
}
message EcPreKey {
// A locally-unique identifier for this key, which will be provided by
// peers using this key to encrypt messages so the private key can be looked
// up.
uint32 key_id = 1;
// The public key, serialized in libsignal's elliptic-curve public key format.
bytes public_key = 2 [(require.nonEmpty) = true];
}
message EcSignedPreKey {
// A locally-unique identifier for this key, which will be provided by
// peers using this key to encrypt messages so the private key can be looked
// up.
uint32 key_id = 1;
// The public key, serialized in libsignal's elliptic-curve public key format.
bytes public_key = 2 [(require.nonEmpty) = true];
// A signature of the public key, verifiable with the identity key for the
// account/identity associated with this pre-key.
bytes signature = 3 [(require.nonEmpty) = true];
}
message KemSignedPreKey {
// An locally-unique identifier for this key, which will be provided by peers
// using this key to encrypt messages so the private key can be looked up.
uint32 key_id = 1;
// The public key, serialized in libsignal's Kyber1024 public key format.
bytes public_key = 2 [(require.nonEmpty) = true];
// A signature of the public key, verifiable with the identity key for the
// account/identity associated with this pre-key.
bytes signature = 3 [(require.nonEmpty) = true];
}
enum DeviceCapability {
DEVICE_CAPABILITY_UNSPECIFIED = 0;
DEVICE_CAPABILITY_STORAGE = 1;
DEVICE_CAPABILITY_TRANSFER = 2;
reserved 3;
reserved 4;
reserved 5;
DEVICE_CAPABILITY_ATTACHMENT_BACKFILL = 6;
DEVICE_CAPABILITY_SPARSE_POST_QUANTUM_RATCHET = 7;
}
message ZkCredential {
/*
* Day on which this credential can be redeemed, in UTC seconds since epoch
*/
int64 redemption_time = 1;
/*
* The ZK credential, using libsignal's serialization
*/
bytes credential = 2 [(require.nonEmpty) = true];
}
// An upload location and credentials which may be used to upload an object
// to an external CDN
message UploadForm {
// Indicates the CDN type. 3 indicates resumable uploads using TUS
uint32 cdn = 1;
// The location within the specified cdn where the finished upload can be found
string key = 2;
// A map of headers to include with all upload requests. Potentially contains
// time-limited upload credentials
map<string, string> headers = 3;
// The URL to upload to with the appropriate protocol
string signed_upload_location = 4;
}
@@ -0,0 +1,81 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
syntax = "proto3";
option java_multiple_files = true;
import "org/signal/chat/require.proto";
package org.signal.chat.credentials;
// Provides methods for obtaining and verifying credentials for "external" services
// (i.e. services that are not a part of the chat server deployment).
// All methods of this service require authentication.
service ExternalServiceCredentials {
// Generates and returns an external service credentials for the caller.
rpc GetExternalServiceCredentials(GetExternalServiceCredentialsRequest)
returns (GetExternalServiceCredentialsResponse) {}
}
service ExternalServiceCredentialsAnonymous {
// Given a list of secure value recovery (SVR) service credentials and a phone number,
// checks, which of the provided credentials were generated by the user with the given phone number
// and have not yet expired.
rpc CheckSvrCredentials(CheckSvrCredentialsRequest)
returns (CheckSvrCredentialsResponse) {}
}
enum ExternalServiceType {
EXTERNAL_SERVICE_TYPE_UNSPECIFIED = 0;
EXTERNAL_SERVICE_TYPE_DIRECTORY = 1;
EXTERNAL_SERVICE_TYPE_PAYMENTS = 2;
EXTERNAL_SERVICE_TYPE_STORAGE = 3;
EXTERNAL_SERVICE_TYPE_SVR = 4;
}
message GetExternalServiceCredentialsRequest {
// A service to request credentials for.
ExternalServiceType externalService = 1;
}
message GetExternalServiceCredentialsResponse {
// A username that can be presented to authenticate with the external service.
string username = 1;
// A password that can be presented to authenticate with the external service.
string password = 2;
}
enum AuthCheckResult {
AUTH_CHECK_RESULT_UNSPECIFIED = 0;
// The credentials could be used to make a call to SVR service by the user
// associated with the `CheckSvrCredentialsRequest.number` phone number.
AUTH_CHECK_RESULT_MATCH = 1;
// The credentials were generated by a different user.
AUTH_CHECK_RESULT_NO_MATCH = 2;
// This status indicates that the corresponding credentials token should no longer be used.
// This may be because it has expired or invalid, but it can also mean that there is a more
// recent token in the request which should be used instead.
AUTH_CHECK_RESULT_INVALID = 3;
}
message CheckSvrCredentialsRequest {
// A phone number in the E164 format to check the passwords against.
// Only passwords generated for the user associated with the given number will be marked as `AUTH_CHECK_RESULT_MATCH`.
string number = 1;
// A list of credentials from previously made calls to `ExternalServiceCredentials.GetExternalServiceCredentials()`
// for `EXTERNAL_SERVICE_TYPE_SVR`. This list may contain credentials generated by different users. Up to 10 credentials
// can be checked.
repeated string passwords = 2 [(require.nonEmpty) = true, (require.size) = {max: 10}];
}
// For each of the credentials tokens in the `CheckSvrCredentialsRequest` contains the result of the check.
message CheckSvrCredentialsResponse {
map<string, AuthCheckResult> matches = 1;
}
@@ -0,0 +1,136 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
syntax = "proto3";
option java_multiple_files = true;
package org.signal.chat.device;
import "google/protobuf/empty.proto";
import "org/signal/chat/common.proto";
import "org/signal/chat/errors.proto";
import "org/signal/chat/require.proto";
import "org/signal/chat/tag.proto";
// Provides methods for working with devices attached to a Signal account.
service Devices {
// Returns a list of devices associated with the caller's account.
rpc GetDevices(GetDevicesRequest) returns (GetDevicesResponse) {}
// Removes a linked device from the caller's account.
//
// Linked devices may only remove themselves. Primary devices may remove
// any device other than themselves.
rpc RemoveDevice(RemoveDeviceRequest) returns (RemoveDeviceResponse) {}
// Sets the encrypted human-readable name for a specific devices. Primary
// devices may change the name of any device associated with their account,
// but linked devices may only change their own name. The response will
// indicate if the target device was not found.
rpc SetDeviceName(SetDeviceNameRequest) returns (SetDeviceNameResponse) {}
// Sets the token(s) the server should use to send new message notifications
// to the authenticated device.
rpc SetPushToken(SetPushTokenRequest) returns (SetPushTokenResponse) {}
// Removes any push tokens associated with the authenticated device. After
// calling this method, the server will assume that the authenticated device
// will periodically poll for new messages.
rpc ClearPushToken(ClearPushTokenRequest) returns (ClearPushTokenResponse) {}
// Declares that the authenticated device supports certain features.
rpc SetCapabilities(SetCapabilitiesRequest) returns (SetCapabilitiesResponse) {}
}
message GetDevicesRequest {}
message GetDevicesResponse {
message LinkedDevice {
// The identifier for the device within an account.
uint32 id = 1;
// A sequence of bytes that encodes an encrypted human-readable name for
// this device.
bytes name = 2;
// The approximate time, in milliseconds since the epoch, at which this
// device last connected to the server.
uint64 last_seen = 3;
// The registration ID of the given device.
uint32 registration_id = 4 [(require.range).max = 0x3fff];
// A sequence of bytes that encodes the time,
// in milliseconds since the epoch, at which this device was
// attached to its parent account.
bytes created_at_ciphertext = 5;
}
// A list of devices linked to the authenticated account.
repeated LinkedDevice devices = 1;
}
message RemoveDeviceRequest {
// The identifier for the device to remove from the authenticated account. The
// identifier must not be for the primary device.
uint32 id = 1;
}
message SetDeviceNameRequest {
// A sequence of bytes that encodes an encrypted human-readable name for this
// device.
bytes name = 1 [(require.size) = {min: 1, max: 225}];
// The identifier for the device for which to set a name.
uint32 id = 2;
}
message SetDeviceNameResponse {
oneof response {
// The device name was successfully set
google.protobuf.Empty success = 1;
// No device with the provided identifier was found on the account
errors.NotFound target_device_not_found = 2 [(tag.reason) = "not_found"];
}
}
message RemoveDeviceResponse {}
message SetPushTokenRequest {
message ApnsTokenRequest {
// A "standard" APNs device token.
string apns_token = 1 [(require.nonEmpty) = true];
}
message FcmTokenRequest {
// An FCM push token.
string fcm_token = 1 [(require.nonEmpty) = true];
}
oneof token_request {
// If present, specifies the APNs device token(s) the server will use to
// send new message notifications to the authenticated device.
ApnsTokenRequest apns_token_request = 1;
// If present, specifies the FCM push token the server will use to send new
// message notifications to the authenticated device.
FcmTokenRequest fcm_token_request = 2;
}
}
message SetPushTokenResponse {}
message ClearPushTokenRequest {}
message ClearPushTokenResponse {}
message SetCapabilitiesRequest {
repeated common.DeviceCapability capabilities = 1;
}
message SetCapabilitiesResponse {}
@@ -0,0 +1,34 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
syntax = "proto3";
option java_multiple_files = true;
package org.signal.chat.errors;
// Response message that indicates a particular resource was not found.
message NotFound {}
// Response message that indicates that some precondition of the request was not
// met. For example, if there was a request to update foo, but foo had not been
// set, this would be an appropriate error.
message FailedPrecondition {
// An optional description indicating what precondition failed.
string description = 1;
}
// Response message that authentication via an anonymous credential failed.
message FailedZkAuthentication {
// An optional description with additional information about the failure.
string description = 1;
}
// Response message that indicates authorization to perform an unidentified
// operation via an endorsement or access key failed
message FailedUnidentifiedAuthorization {
// An optional description with additional information about the failure.
string description = 1;
}
@@ -0,0 +1,223 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
syntax = "proto3";
option java_multiple_files = true;
package org.signal.chat.keys;
import "google/protobuf/empty.proto";
import "org/signal/chat/common.proto";
import "org/signal/chat/errors.proto";
import "org/signal/chat/require.proto";
import "org/signal/chat/tag.proto";
// Provides methods for working with pre-keys.
service Keys {
// Retrieves an approximate count of the number of the various kinds of
// pre-keys stored for the authenticated device.
rpc GetPreKeyCount (GetPreKeyCountRequest) returns (GetPreKeyCountResponse) {}
// Retrieves a set of pre-keys for establishing a session with the targeted
// device or devices. Note that callers with an unidentified access key for
// the targeted account should use the version of this method in
// `KeysAnonymous` instead.
rpc GetPreKeys(GetPreKeysRequest) returns (GetPreKeysResponse) {}
// Uploads a new set of one-time EC pre-keys for the authenticated device,
// clearing any previously-stored pre-keys. Note that all keys submitted via
// a single call to this method _must_ have the same identity type (i.e. if
// the first key has an ACI identity type, then all other keys in the same
// stream must also have an ACI identity type). The provided list of pre-keys
// must be non-empty.
rpc SetOneTimeEcPreKeys (SetOneTimeEcPreKeysRequest) returns (SetPreKeyResponse) {}
// Uploads a new set of one-time KEM pre-keys for the authenticated device,
// clearing any previously-stored pre-keys. Note that all keys submitted via
// a single call to this method _must_ have the same identity type (i.e. if
// the first key has an ACI identity type, then all other keys in the same
// stream must also have an ACI identity type). The provided list of pre-keys
// must be non-empty.
rpc SetOneTimeKemSignedPreKeys (SetOneTimeKemSignedPreKeysRequest) returns (SetPreKeyResponse) {}
// Sets the signed EC pre-key for one identity (i.e. ACI or PNI) associated
// with the authenticated device.
rpc SetEcSignedPreKey (SetEcSignedPreKeyRequest) returns (SetPreKeyResponse) {}
// Sets the last-resort KEM pre-key for one identity (i.e. ACI or PNI)
// associated with the authenticated device.
rpc SetKemLastResortPreKey (SetKemLastResortPreKeyRequest) returns (SetPreKeyResponse) {}
}
// Provides methods for working with pre-keys using "unidentified access"
// credentials.
service KeysAnonymous {
// Retrieves a set of pre-keys for establishing a session with the targeted
// device or devices. Callers must not submit any self-identifying credentials
// when calling this method and must instead present the targeted account's
// unidentified access key as an anonymous authentication mechanism. Callers
// without an unidentified access key should use the equivalent, authenticated
// method in `Keys` instead.
rpc GetPreKeys(GetPreKeysAnonymousRequest) returns (GetPreKeysAnonymousResponse) {}
// Checks identity key fingerprints of the target accounts.
//
// Returns a stream of elements, each one representing an account that had a mismatched
// identity key fingerprint with the server and the corresponding identity key stored by the server.
rpc CheckIdentityKeys(stream CheckIdentityKeyRequest) returns (stream CheckIdentityKeyResponse) {}
}
message GetPreKeyCountRequest {
}
message GetPreKeyCountResponse {
// The approximate number of one-time EC pre-keys stored for the
// authenticated device and associated with the caller's ACI.
uint32 aci_ec_pre_key_count = 1;
// The approximate number of one-time Kyber pre-keys stored for the
// authenticated device and associated with the caller's ACI.
uint32 aci_kem_pre_key_count = 2;
// The approximate number of one-time EC pre-keys stored for the
// authenticated device and associated with the caller's PNI.
uint32 pni_ec_pre_key_count = 3;
// The approximate number of one-time KEM pre-keys stored for the
// authenticated device and associated with the caller's PNI.
uint32 pni_kem_pre_key_count = 4;
}
message GetPreKeysRequest {
// The service identifier of the account for which to retrieve pre-keys.
common.ServiceIdentifier target_identifier = 1;
// The ID of the device associated with the targeted account for which to
// retrieve pre-keys. If not set, pre-keys are returned for all devices
// associated with the targeted account.
optional uint32 device_id = 2;
}
message GetPreKeysAnonymousRequest {
// The request to retrieve pre-keys for a specific account/device(s).
GetPreKeysRequest request = 1;
// A means to authorize the request.
oneof authorization {
// The unidentified access key (UAK) for the targeted account.
bytes unidentified_access_key = 2;
// A group send endorsement token for the targeted account.
bytes group_send_token = 3;
// The destination account allows unrestricted unidentified access
google.protobuf.Empty unrestricted_access = 4;
}
}
message DevicePreKeyBundle {
// The EC signed pre-key associated with the targeted
// account/device/identity.
common.EcSignedPreKey ec_signed_pre_key = 1;
// A one-time EC pre-key for the targeted account/device/identity. May not
// be set if no one-time EC pre-keys are available.
common.EcPreKey ec_one_time_pre_key = 2;
// A one-time KEM pre-key (or a last-resort KEM pre-key) for the targeted
// account/device/identity.
common.KemSignedPreKey kem_one_time_pre_key = 3;
// The registration ID for the targeted account/device/identity.
uint32 registration_id = 4;
}
message AccountPreKeyBundles {
// The identity key associated with the targeted account/identity.
bytes identity_key = 1;
// A map of device IDs to pre-key "bundles" for the targeted account.
map<uint32, DevicePreKeyBundle> device_pre_keys = 2;
}
message GetPreKeysResponse {
oneof response {
// The requested pre-key bundles
AccountPreKeyBundles pre_keys = 1;
// Either the target account was not found, no active device with the given
// ID (if specified) was found on the target account.
errors.NotFound target_not_found = 2 [(tag.reason) = "not_found"];
}
}
message GetPreKeysAnonymousResponse {
oneof response {
// The requested pre-key bundles
AccountPreKeyBundles pre_keys = 1;
// Either the target account was not found, no active device with the given
// ID (if specified) was found on the target account.
errors.NotFound target_not_found = 2 [(tag.reason) = "not_found"];
// The provided unidentified authorization credential was invalid
errors.FailedUnidentifiedAuthorization failed_unidentified_authorization = 3 [(tag.reason) = "failed_unidentified_authorization"];
}
}
message SetOneTimeEcPreKeysRequest {
// The identity type (i.e. ACI/PNI) with which the keys in this request are
// associated.
common.IdentityType identity_type = 1;
// The unsigned EC pre-keys to be stored.
repeated common.EcPreKey pre_keys = 2 [(require.size) = {min: 1, max: 100}];
}
message SetOneTimeKemSignedPreKeysRequest {
// The identity type (i.e. ACI/PNI) with which the keys in this request are
// associated.
common.IdentityType identity_type = 1;
// The KEM pre-keys to be stored.
repeated common.KemSignedPreKey pre_keys = 2 [(require.size) = {min: 1, max: 100}];
}
message SetEcSignedPreKeyRequest {
// The identity type (i.e. ACI/PNI) with which this key is associated.
common.IdentityType identity_type = 1;
// The signed EC pre-key itself.
common.EcSignedPreKey signed_pre_key = 2 [(require.present) = true];
}
message SetKemLastResortPreKeyRequest {
// The identity type (i.e. ACI/PNI) with which this key is associated.
common.IdentityType identity_type = 1;
// The signed KEM pre-key itself.
common.KemSignedPreKey signed_pre_key = 2 [(require.present) = true];
}
message SetPreKeyResponse {
}
message CheckIdentityKeyRequest {
// The service identifier of the account for which we want to check the associated identity key fingerprint.
common.ServiceIdentifier target_identifier = 1;
// The most significant 4 bytes of the SHA-256 hash of the identity key associated with the target account/identity type.
bytes fingerprint = 2 [(require.exactlySize) = 4];
}
message CheckIdentityKeyResponse {
// The service identifier of the account for which there is a mismatch between the client and server identity key fingerprints.
common.ServiceIdentifier target_identifier = 1;
// The identity key that is stored by the server for the target account/identity type.
bytes identity_key = 2;
}
@@ -0,0 +1,376 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
syntax = "proto3";
option java_multiple_files = true;
package org.signal.chat.messages;
import "google/protobuf/empty.proto";
import "org/signal/chat/common.proto";
import "org/signal/chat/require.proto";
import "org/signal/chat/errors.proto";
import "org/signal/chat/tag.proto";
// Provides methods for sending "unsealed sender" messages.
service Messages {
option (require.auth) = AUTH_ONLY_AUTHENTICATED;
// Sends an "unsealed sender" message to all devices linked to a single
// destination account.
//
// The destination account must not be the same as the authenticated caller.
// Callers should use `SendSyncMessage` to send messages to themselves.
rpc SendMessage(SendAuthenticatedSenderMessageRequest) returns (SendMessageAuthenticatedSenderResponse) {}
// Sends a "sync" message to all other devices linked to the authenticated
// sender's account.
rpc SendSyncMessage(SendSyncMessageRequest) returns (SendMessageAuthenticatedSenderResponse) {}
}
// Provides methods for sending "sealed sender" messages.
service MessagesAnonymous {
option (require.auth) = AUTH_ONLY_ANONYMOUS;
// Sends a "sealed sender" message to all devices linked to a single
// destination account.
//
// If this RPC is authorized with an unidentified access key, it will fail
// with an authorization failure if the credential is invalid OR if the
// destination account was not found. If it is authorized using a group send
// token, it will fail with an authorization failure if the credential is
// invalid and with an destination not found error if the account does not
// exist
rpc SendSingleRecipientMessage(SendSealedSenderMessageRequest) returns (SendMessageResponse) {}
// Sends a "sealed sender" message with a common payload to all devices linked
// to multiple destination accounts.
rpc SendMultiRecipientMessage(SendMultiRecipientMessageRequest) returns (SendMultiRecipientMessageResponse) {}
// Sends a story message to devices linked to a single destination account.
rpc SendStory(SendStoryMessageRequest) returns (SendMessageResponse) {}
// Sends a story message with a common payload to devices linked to devices
// linked to multiple destination accounts.
rpc SendMultiRecipientStory(SendMultiRecipientStoryRequest) returns (SendMultiRecipientMessageResponse) {}
}
message IndividualRecipientMessageBundle {
// A message for an individual device linked to a destination account.
message Message {
// The registration ID for the destination device.
uint32 registration_id = 1 [(require.range).max = 0x3fff];
// The content of the message to deliver to the destination device.
bytes payload = 2 [(require.size) = {min: 1, max: 262144}]; // 256 KiB
// The message type of the message. If this message is part of an
// unidentified send, this must be UNIDENTIFIED_SENDER
SendMessageType type = 3;
}
// The time, in milliseconds since the epoch, at which this message was
// originally sent from the perspective of the sender. Note that the maximum
// allowable timestamp for JavaScript clients is less than Long.MAX_VALUE; see
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date#the_epoch_timestamps_and_invalid_date
// for additional details and discussion.
uint64 timestamp = 1 [(require.range).min = 1, (require.range).max = 8640000000000000];
// A map of device IDs to individual messages. Generally, callers must include
// one message for each device linked to the destination account. In cases of
// "sync messages" where a sender is distributing information to other devices
// linked to the sender's account, senders may omit a message for the sending
// device.
map<uint32, Message> messages = 2 [(require.nonEmpty) = true];
}
enum SendMessageType {
UNSPECIFIED = 0;
// A double-ratchet message represents a "normal," "unsealed-sender" message
// encrypted using the Double Ratchet within an established Signal session.
DOUBLE_RATCHET = 1;
// A prekey message begins a new Signal session. The `content` of a prekey
// message is a superset of a double-ratchet message's `content` and
// contains the sender's identity public key and information identifying the
// pre-keys used in the message's ciphertext.
PREKEY_MESSAGE = 2;
// A plaintext message is used solely to convey encryption error receipts
// and never contains encrypted message content. Encryption error receipts
// must be delivered in plaintext because encryption/decryption of a prior
// message failed and there is no reason to believe that
// encryption/decryption of subsequent messages with the same key material
// would succeed.
//
// Critically, plaintext messages never have "real" message content
// generated by users. Plaintext messages include sender information.
PLAINTEXT_CONTENT = 3;
// An unidentified sender message is an encrypted message. No other
// information about the type of the encrypted message is known to the server.
//
// Unidenitfied sender messages require an unidentified access token or a
// group send endorsement token to prove the unidentified sender is authorized
// to send messages to the destination.
UNIDENTIFIED_SENDER = 4;
}
message SendAuthenticatedSenderMessageRequest {
// The service identifier of the account to which to deliver the message.
common.ServiceIdentifier destination = 1;
// If true, this message will only be delivered to destination devices that
// have an active message delivery channel with a Signal server.
bool ephemeral = 2;
// Indicates whether this message is urgent and should trigger a high-priority
// notification if the destination device does not have an active message
// delivery channel with a Signal server
bool urgent = 3;
// The messages to send to the destination account.
IndividualRecipientMessageBundle messages = 4;
}
message SendMessageAuthenticatedSenderResponse {
// The outcome of the message delivery
oneof response {
// The message was successfully delivered to all destination devices
google.protobuf.Empty success = 1;
// A list of discrepancies between the destination devices identified in a
// request to send a message and the devices that are actually linked to an
// account.
MismatchedDevices mismatched_devices = 2 [(tag.reason) = "mismatched_devices"];
// A description of a challenge callers must complete before sending
// additional messages.
ChallengeRequired challenge_required = 3 [(tag.reason) = "challenge_required"];
// The destination account did not exist
errors.NotFound destination_not_found = 4 [(tag.reason) = "destination_not_found"];
}
}
message SendSyncMessageRequest {
// Indicates whether this message is urgent and should trigger a high-priority
// notification if the destination device does not have an active message
// delivery channel with a Signal server
bool urgent = 1;
// The messages to send to the destination account.
IndividualRecipientMessageBundle messages = 2;
}
message SendSealedSenderMessageRequest {
// The service identifier of the account to which to deliver the message.
common.ServiceIdentifier destination = 1;
// If true, this message will only be delivered to destination devices that
// have an active message delivery channel with a Signal server.
bool ephemeral = 2;
// Indicates whether this message is urgent and should trigger a high-priority
// notification if the destination device does not have an active message
// delivery channel with a Signal server
bool urgent = 3;
// The messages to send to the destination account.
IndividualRecipientMessageBundle messages = 4;
// A means to authorize the request.
oneof authorization {
// The unidentified access key (UAK) for the destination account.
bytes unidentified_access_key = 5 [(require.exactlySize) = 16];
// A group send endorsement token for the destination account.
bytes group_send_token = 6;
// The destination account allows unrestricted unidentified access
google.protobuf.Empty unrestricted_access = 7;
}
}
message SendStoryMessageRequest {
// The service identifier of the account to which to deliver the message.
common.ServiceIdentifier destination = 1;
// Indicates whether this message is urgent and should trigger a high-priority
// notification if the destination device does not have an active message
// delivery channel with a Signal server
bool urgent = 2;
// The messages to send to the destination account.
IndividualRecipientMessageBundle messages = 3;
}
message SendMessageResponse {
// The outcome of the message delivery
oneof response {
// The message was successfully delivered to all destination devices
google.protobuf.Empty success = 1;
// A list of discrepancies between the destination devices identified in a
// request to send a message and the devices that are actually linked to an
// account.
MismatchedDevices mismatched_devices = 2 [(tag.reason) = "mismatched_devices"];
// The provided unidentified authorization credential was invalid
errors.FailedUnidentifiedAuthorization failed_unidentified_authorization = 3 [(tag.reason) = "failed_unidentified_authorization"];
// The destination account did not exist
errors.NotFound destination_not_found = 4 [(tag.reason) = "destination_not_found"];
}
}
message MultiRecipientMessage {
// The time, in milliseconds since the epoch, at which this message was
// originally sent from the perspective of the sender. Note that the maximum
// allowable timestamp for JavaScript clients is less than Long.MAX_VALUE; see
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date#the_epoch_timestamps_and_invalid_date
// for additional details and discussion.
uint64 timestamp = 1 [(require.range).min = 1, (require.range).max = 8640000000000000];
// The serialized multi-recipient message payload.
bytes payload = 2 [(require.size).max = 762144]; // 256 KiB payload + (5000 * 100) of overhead
}
message SendMultiRecipientMessageRequest {
// If true, this message will only be delivered to destination devices that
// have an active message delivery channel with a Signal server.
bool ephemeral = 1;
// Indicates whether this message is urgent and should trigger a high-priority
// notification if the destination device does not have an active message
// delivery channel with a Signal server
bool urgent = 2;
// The multi-recipient message to send to all destination accounts and
// devices.
MultiRecipientMessage message = 3;
// A group send endorsement token for the destination account.
bytes group_send_token = 4 [(require.nonEmpty) = true];
}
message SendMultiRecipientStoryRequest {
// Indicates whether this message is urgent and should trigger a high-priority
// notification if the destination device does not have an active message
// delivery channel with a Signal server
bool urgent = 1;
// The multi-recipient story message to send to all destination accounts and
// devices.
MultiRecipientMessage message = 2;
}
message MultiRecipientSuccess {
// A list of destination service identifiers that could not be resolved to
// registered Signal accounts. The message in the original request was sent
// to all service identifiers/devices in the original request except for the
// destination devices associated with the service identifiers in this list.
repeated common.ServiceIdentifier unresolved_recipients = 1;
}
message SendMultiRecipientMessageResponse {
// The outcome of the message delivery
oneof response {
// The message was sent to at least some of the destination accounts/devices
// identified in the original request.
MultiRecipientSuccess success = 1;
// A list of sets of discrepancies between the destination devices
// identified in a request to send a message and the devices that are
// actually linked to a destination account.
MultiRecipientMismatchedDevices mismatched_devices = 2 [(tag.reason) = "mismatched_devices"];
// The provided unidentified authorization credential was invalid
errors.FailedUnidentifiedAuthorization failed_unidentified_authorization = 3 [(tag.reason) = "failed_unidentified_authorization"];
}
}
message MismatchedDevices {
// The service identifier to which the devices named in this object are
// linked.
common.ServiceIdentifier service_identifier = 1;
// A list of device IDs that are linked to the destination account, but were
// not included in the collection of messages bound for the destination
// account.
repeated uint32 missing_devices = 2 [(require.range).max = 0x7f];
// A list of device IDs that were included in the collection of messages bound
// for the destination account, but are not currently linked to the
// destination account.
repeated uint32 extra_devices = 3 [(require.range).max = 0x7f];
// A list of device IDs that present in the collection of messages bound for
// the destination account and are linked to the destination account, but have
// a different registration ID than the registration ID presented by the
// sender (indicating that the destination device has likely been replaced by
// another device).
repeated uint32 stale_devices = 4 [(require.range).max = 0x7f];
}
message MultiRecipientMismatchedDevices {
// A list of sets of discrepancies between the destination devices identified
// in a request to send a message and the devices that are actually linked to
// a destination account.
repeated MismatchedDevices mismatched_devices = 1;
}
message ChallengeRequired {
enum ChallengeType {
UNSPECIFIED = 0;
// A challenge that callers can fulfill by completing a captcha.
CAPTCHA = 1;
// A challenge that callers can fulfill by supplying a token delivered via
// push notification.
PUSH_CHALLENGE = 2;
};
// An opaque token identifying this challenge request. Clients must generally
// submit this token when submitting a challenge response.
string token = 1;
// A list of challenge types callers may choose to complete to resolve the
// challenge requirement. May be empty, in which case callers cannot resolve
// the challenge by any means other than waiting.
repeated ChallengeType challenge_options = 2;
// A duration (in seconds) after which the challenge requirement may be
// resolved by simply waiting. May not be set if the challenge cannot be
// resolved by waiting.
optional uint64 retry_after_seconds = 3;
}
@@ -0,0 +1,33 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
syntax = "proto3";
option java_multiple_files = true;
package org.signal.chat.payments;
// Provides methods for working with payments.
service Payments {
rpc GetCurrencyConversions(GetCurrencyConversionsRequest) returns (GetCurrencyConversionsResponse) {}
}
message GetCurrencyConversionsRequest {
}
message GetCurrencyConversionsResponse {
message CurrencyConversionEntity {
string base = 1;
map<string, string> conversions = 2;
}
uint64 timestamp = 1;
repeated CurrencyConversionEntity currencies = 2;
}
@@ -0,0 +1,243 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
syntax = "proto3";
option java_multiple_files = true;
package org.signal.chat.profile;
import "org/signal/chat/common.proto";
// Provides methods for working with profiles and profile-related data.
service Profile {
// Sets profile data and if needed, returns S3 credentials used by clients to upload an avatar.
//
// This RPC may fail with `PERMISSION_DENIED` if it attempts to set the MobileCoin wallet ID
// on an account whose profile does not currently have a MobileCoin wallet ID and
// whose phone number contains a disallowed country prefix.
rpc SetProfile(SetProfileRequest) returns (SetProfileResponse) {}
// Retrieves versioned profile data. Callers with an unidentified access key for the account
// should use the version of this method in `ProfileAnonymous` instead.
//
// This RPC may fail with a `NOT_FOUND` status if the target account was not
// found. It may fail with a `RESOURCE_EXHAUSTED` if a rate limit for fetching profiles has been
// exceeded, in which case a `retry-after` header containing an ISO 8601
// duration string will be present in the response trailers.
rpc GetVersionedProfile(GetVersionedProfileRequest) returns (GetVersionedProfileResponse) {}
// Retrieves unversioned profile data. Callers with an unidentified access key for the account
// should use the version of this method in `ProfileAnonymous` instead.
//
// This RPC may fail with a `NOT_FOUND` status if the target account was not
// found. It may fail with a `RESOURCE_EXHAUSTED` if a rate limit for fetching profiles has been
// exceeded, in which case a `retry-after` header containing an ISO 8601
// duration string will be present in the response trailers.
rpc GetUnversionedProfile(GetUnversionedProfileRequest) returns (GetUnversionedProfileResponse) {}
// Retrieves a profile key credential.
// Callers with an unidentified access key for the account
// should use the version of this method in `ProfileAnonymous` instead.
//
// This RPC may fail with a `NOT_FOUND` status if the target account was not
// found. It may fail with a `RESOURCE_EXHAUSTED` if a rate limit for fetching profiles has been
// exceeded, in which case a `retry-after` header containing an ISO 8601
// duration string will be present in the response trailers. It may also fail with an
// `INVALID_ARGUMENT` status if the given credential type is invalid.
rpc GetExpiringProfileKeyCredential(GetExpiringProfileKeyCredentialRequest) returns (GetExpiringProfileKeyCredentialResponse) {}
}
// Provides methods for working with profiles and profile-related data using "unidentified access"
// credentials. Callers must not submit any self-identifying credentials
// when calling methods in this service and must instead present the targeted account's
// unidentified access key as an anonymous authentication mechanism. Callers
// without an unidentified access key should use the equivalent, authenticated
// methods in `Profile` instead.
service ProfileAnonymous {
// Retrieves versioned profile data.
//
// This RPC may fail with a `NOT_FOUND` status if the target account was not
// found. It may also fail with an `UNAUTHENTICATED` status if the given
// unidentified access key did not match the target account's unidentified
// access key.
rpc GetVersionedProfile(GetVersionedProfileAnonymousRequest) returns (GetVersionedProfileResponse) {}
// Retrieves unversioned profile data.
//
// This RPC may fail with a `NOT_FOUND` status if the target account was not
// found. It may also fail with an `UNAUTHENTICATED` status if the given
// unidentified access key did not match the target account's unidentified
// access key.
rpc GetUnversionedProfile(GetUnversionedProfileAnonymousRequest) returns (GetUnversionedProfileResponse) {}
// Retrieves a profile key credential.
//
// This RPC may fail with a `NOT_FOUND` status if the target account was not
// found. It may also fail with an `UNAUTHENTICATED` status if the given
// unidentified access key did not match the target account's unidentified
// access key, or an `INVALID_ARGUMENT` status if the given credential type is invalid.
rpc GetExpiringProfileKeyCredential(GetExpiringProfileKeyCredentialAnonymousRequest) returns (GetExpiringProfileKeyCredentialResponse) {}
}
message SetProfileRequest {
enum AvatarChange {
AVATAR_CHANGE_UNCHANGED = 0;
AVATAR_CHANGE_CLEAR = 1;
AVATAR_CHANGE_UPDATE = 2;
}
// The profile version. Must be set.
string version = 1;
// The ciphertext of a name that users must set on the profile.
bytes name = 2;
// An enum to indicate what change, if any, is made to the avatar with this request.
AvatarChange avatarChange = 3;
// The ciphertext of an emoji that users can set on their profile.
bytes about_emoji = 4;
// The ciphertext of a description that users can set on their profile.
bytes about = 5;
// The ciphertext of the MobileCoin wallet ID on the profile.
bytes payment_address = 6;
// A list of badge IDs associated with the profile.
repeated string badge_ids = 7;
// The ciphertext of the phone-number sharing setting on the profile. 29-byte encrypted boolean.
bytes phone_number_sharing = 8;
// The profile key commitment. Used to issue a profile key credential response.
// Must be set on the request.
bytes commitment = 9;
}
message SetProfileResponse {
// The policy and credential used by clients to upload an avatar to S3.
ProfileAvatarUploadAttributes attributes = 1;
}
message GetVersionedProfileRequest {
// The ACI of the account for which to get profile data.
common.ServiceIdentifier accountIdentifier = 1;
// The profile version to retrieve.
string version = 2;
}
message GetVersionedProfileAnonymousRequest {
// Contains the data necessary to request a versioned profile.
GetVersionedProfileRequest request = 1;
// The unidentified access key for the targeted account.
bytes unidentified_access_key = 2;
}
message GetVersionedProfileResponse {
// The ciphertext of the name on the profile.
bytes name = 1;
// The ciphertext of the description on the profile.
bytes about = 2;
// The ciphertext of the emoji on the profile.
bytes about_emoji = 3;
// The S3 path of the avatar on the profile.
string avatar = 4;
// The ciphertext of the MobileCoin wallet ID on the profile.
bytes payment_address = 5;
// The ciphertext of the phone-number sharing setting on the profile.
bytes phone_number_sharing = 6;
}
message GetUnversionedProfileRequest {
// The service identifier of the account for which to get profile data.
common.ServiceIdentifier serviceIdentifier = 1;
}
message GetUnversionedProfileAnonymousRequest {
// Contains the data necessary to request an unversioned profile.
GetUnversionedProfileRequest request = 1;
oneof authentication {
// The unidentified access key for the targeted account.
bytes unidentified_access_key = 2;
// A group send endorsement token for the targeted account.
bytes group_send_token = 3;
}
}
message GetUnversionedProfileResponse {
// The identity key of the targeted account/identity type.
bytes identity_key = 1;
// A checksum of the unidentified access key for the targeted account.
bytes unidentified_access = 2;
// Whether the account has enabled sealed sender from anyone.
bool unrestricted_unidentified_access = 3;
// A list of capabilities enabled on the account.
repeated common.DeviceCapability capabilities = 4;
// A list of badges associated with the account.
repeated Badge badges = 5;
}
message GetExpiringProfileKeyCredentialRequest {
// The ACI of the account for which to get a profile key credential.
common.ServiceIdentifier accountIdentifier = 1;
// A zkgroup request for a profile key credential.
bytes credential_request = 2;
// The type of credential being requested.
CredentialType credential_type = 3;
// The profile version for which to generate a profile key credential.
string version = 4;
}
message GetExpiringProfileKeyCredentialAnonymousRequest {
// Contains the data necessary to request an expiring profile key credential.
GetExpiringProfileKeyCredentialRequest request = 1;
// The unidentified access key for the targeted account.
bytes unidentified_access_key = 2;
}
message GetExpiringProfileKeyCredentialResponse {
// A zkgroup credential used by a client to prove that it has the profile key
// of a targeted account.
bytes profileKeyCredential = 1;
}
message ProfileAvatarUploadAttributes {
// The S3 upload path for the profile's avatar.
string path = 1;
// A scoped credential. Includes the AWS access key, date, region targeted, and AWS service.
string credential = 2;
// The type of access control for the avatar object.
string acl = 3;
// The algorithm used to calculate a signature on the S3 policy.
string algorithm = 4;
// The timestamp at which the S3 policy and signature were generated.
string date = 5;
// The S3 policy used to upload the avatar object.
string policy = 6;
// A digital signature on the S3 policy.
bytes signature = 7;
}
message Badge {
// An ID that uniquely identifies the badge.
string id = 1;
// The category the badge falls in ("donor" or "other").
string category = 2;
// The badge name.
string name = 3;
// The badge description.
string description = 4;
// Different size badge SVG files.
repeated string sprites6 = 5;
// File name of the scalable vector graphic representing this badge.
string svg = 6;
// Pairs of light/dark SVG files designed for display at different sizes.
repeated BadgeSvg svgs = 7;
}
message BadgeSvg {
// File name of the scalable vector graphic for light mode.
string light = 1;
// File name of the scalable vector graphic for dark mode.
string dark = 2;
}
enum CredentialType {
CREDENTIAL_TYPE_UNSPECIFIED = 0;
CREDENTIAL_TYPE_EXPIRING_PROFILE_KEY = 1;
}
@@ -0,0 +1,184 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
syntax = "proto3";
option java_multiple_files = true;
package org.signal.chat.require;
import "google/protobuf/descriptor.proto";
extend google.protobuf.FieldOptions {
/*
* Requires a field to have content of non-zero size/length.
* Applies to both `optional` and regular fields, i.e. if the field is not set
* or has a default value, it's considered to be empty. This does not apply
* to fields that are contained in a `oneof`.
*
* ```
* import "org/signal/chat/require.proto";
*
* message Data {
* string nonEmptyString = 1 [(require.nonEmpty) = true];
* bytes nonEmptyBytes = 2 [(require.nonEmpty) = true];
* optional string nonEmptyStringOptional = 3 [(require.nonEmpty) = true];
* optional bytes nonEmptyBytesOptional = 4 [(require.nonEmpty) = true];
* repeated string nonEmptyList = 5 [(require.nonEmpty) = true];
* }
* ```
*
* Applicable to fields of type `string`, `byte`, and `repeated` fields.
*/
optional bool nonEmpty = 70001;
/*
* Requires a enum field to have value with an index greater than zero.
* Applies to both `optional` and regular fields, i.e. if the field is not set or has a default value,
* its index will be <= 0.
*
* ```
* import "org/signal/chat/require.proto";
*
* message Data {
* Color color = 1 [(require.specified) = true];
* }
*
* enum Color {
* COLOR_UNSPECIFIED = 0;
* COLOR_RED = 1;
* COLOR_GREEN = 2;
* COLOR_BLUE = 3;
* }
* ```
*/
optional bool specified = 70002;
/*
* Requires a size/length of a field to be within certain boundaries.
* Applies to both `optional` and regular fields, i.e. if the field is not set
* or has a default value, its size considered to be zero. However, if the
* field is contained in a `oneof` and is not set, this annotation does not
* apply.
*
* ```
* import "org/signal/chat/require.proto";
*
* message Data {
*
* string name = 1 [(require.size) = {min: 3, max: 8}];
*
* optional string address = 2 [(require.size) = {min: 3, max: 8}];
* }
* ```
*
* Applicable to fields of type `string`, `byte`, and `repeated` fields.
*/
optional SizeConstraint size = 70003;
/*
* Requires a size/length of a field to be within certain boundaries.
* Applies to both `optional` and regular fields, i.e. if the field is not set
* or has a default value, its size considered to be zero. However, if the
* field is contained in a `oneof` and is not set, this annotation does not
* apply.
*
* ```
* import "org/signal/chat/require.proto";
*
* message Data {
*
* string zip = 1 [(require.exactlySize) = 5];
*
* optional string exactlySizeVariants = 2 [(require.exactlySize) = 2, (require.exactlySize) = 4];
* }
* ```
*
* Applicable to fields of type `string`, `byte`, and `repeated` fields.
*/
repeated uint32 exactlySize = 70004;
/*
* Requires a value of a string field to be a valid E164-normalized phone number.
* If the field is `optional`, this check allows a value to be not set.
*
* ```
* import "org/signal/chat/require.proto";
*
* message Data {
* string number = 1 [(require.e164)];
* }
* ```
*/
optional bool e164 = 70005;
/*
* Requires an integer value to be within a certain range. The range boundaries are specified
* with the values of type `int32`, which should be enough for all practical purposes.
*
* If the field is `optional`, this check allows a value to be not set.
*
* ```
* import "org/signal/chat/require.proto";
*
* message Data {
* int32 byte = 1 [(require.range) = {min: -128, max: 127}];
* uint32 unsignedByte = 2 [(require.range).max = 255];
* }
* ```
*/
optional ValueRangeConstraint range = 70006;
/*
* Require a value of a message field to be present.
*
* Applies to both `optional` and regular fields (both of which have explicit
* presence for the message type anyways). This does not apply to fields that
* are contained in a `oneof`.
*
* ```
* import "org/signal/chat/require.proto";
* message Data {
* message MyMessage {}
* MyMessage myMessage = 1 [(require.present) = true];
* }
*````
*/
optional bool present = 70007;
}
message SizeConstraint {
optional uint32 min = 1;
optional uint32 max = 2;
}
message ValueRangeConstraint {
optional int64 min = 1;
optional int64 max = 2;
}
extend google.protobuf.ServiceOptions {
/*
* Indicates that all methods in a given service require a certain kind of authentication.
*
* ```
* import "org/signal/chat/require.proto";
*
* service AuthService {
* option (require.auth) = AUTH_ONLY_AUTHENTICATED;
*
* rpc AuthenticatedMethod (google.protobuf.Empty) returns (google.protobuf.Empty) {}
* }
* ```
*/
optional Auth auth = 71001;
}
enum Auth {
AUTH_UNSPECIFIED = 0;
AUTH_ONLY_AUTHENTICATED = 1;
AUTH_ONLY_ANONYMOUS = 2;
}
@@ -0,0 +1,38 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
syntax = "proto3";
option java_multiple_files = true;
package org.signal.chat.tag;
import "google/protobuf/descriptor.proto";
extend google.protobuf.FieldOptions {
// Indicate that a message which includes this field (directly or indirectly)
// was generated for a particular reason.
//
// ```
// import "org/signal/chat/tag.proto"
//
// message LookupThingResponse {
// oneof response {
// string thing = 1;
// Error not_found = 2 [(tag.reason) = "not_found"];
// Error forbidden = 3 [(tag.reason) = "forbidden"];
// }
// }
// ```
//
// Metrics middleware may then inspect `LookupThingResponse` and tag responses
// with the provided reason. This is useful when multiple outcomes are
// potentially represented with a status = "OK" RPC response.
//
// Valid messages should only have a single reason set. If a message has
// multiple fields present that have a reason option set, no guarantees are
// made about the reason that is selected.
optional string reason = 71000;
}
+185
View File
@@ -0,0 +1,185 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import assert from 'assert';
import {
ClientZkGroupCipher,
GroupSecretParams,
ProfileKey,
ProfileKeyCredentialPresentation,
UuidCiphertext,
} from '@signalapp/libsignal-client/zkgroup';
import { ServiceId } from '@signalapp/libsignal-client';
import { signalservice as Proto } from '../../protos/compiled';
import { AciString, ServiceIdString } from '../types';
import { Group as GroupData } from '../data/group';
const AccessRequired = Proto.AccessControl.AccessRequired;
export type GroupOptions = Readonly<{
secretParams: GroupSecretParams;
groupState: Proto.Group.Params;
}>;
export type GroupMember = Readonly<{
presentation: ProfileKeyCredentialPresentation;
profileKey: ProfileKey;
aci: AciString;
}>;
export type GroupFromConfigOptions = Readonly<{
secretParams: GroupSecretParams;
title: string;
members: ReadonlyArray<GroupMember>;
}>;
function encryptBlob(
cipher: ClientZkGroupCipher,
proto: Proto.GroupAttributeBlob.Params,
): Buffer<ArrayBuffer> {
const plaintext = Proto.GroupAttributeBlob.encode(proto);
return Buffer.from(cipher.encryptBlob(plaintext));
}
function decryptBlob(
cipher: ClientZkGroupCipher,
ciphertext: Uint8Array<ArrayBuffer>,
): Proto.GroupAttributeBlob {
const plaintext = cipher.decryptBlob(Buffer.from(ciphertext));
return Proto.GroupAttributeBlob.decode(plaintext);
}
export class Group extends GroupData {
public readonly secretParams: GroupSecretParams;
public readonly title: string;
constructor({ secretParams, groupState }: GroupOptions) {
super();
assert.ok(groupState.title, 'Group must have a title blob');
this.secretParams = secretParams;
const cipher = new ClientZkGroupCipher(secretParams);
const decrypted = decryptBlob(cipher, groupState.title);
assert(decrypted.content?.title != null, 'expected title');
this.title = decrypted.content.title;
this.privPublicParams = this.secretParams.getPublicParams();
// Build group log
this.privChanges = {
groupChanges: [
{
groupState,
groupChange: null,
},
],
groupSendEndorsementsResponse: null,
};
}
public static fromConfig({
secretParams,
title,
members,
}: GroupFromConfigOptions): Group {
const cipher = new ClientZkGroupCipher(secretParams);
const groupState: Proto.Group.Params = {
publicKey: secretParams.getPublicParams().serialize(),
version: 0,
title: encryptBlob(cipher, { content: { title } }),
// TODO(indutny): make it configurable
accessControl: {
attributes: AccessRequired.MEMBER,
members: AccessRequired.MEMBER,
addFromInviteLink: AccessRequired.UNSATISFIABLE,
memberLabel: AccessRequired.MEMBER,
},
members: members.map(({ presentation }) => {
return {
role: Proto.Member.Role.ADMINISTRATOR,
presentation: presentation.serialize(),
userId: null,
profileKey: null,
joinedAtVersion: null,
labelEmoji: null,
labelString: null,
};
}),
avatarUrl: null,
disappearingMessagesTimer: null,
membersPendingProfileKey: null,
membersPendingAdminApproval: null,
inviteLinkPassword: null,
description: null,
announcementsOnly: null,
membersBanned: null,
terminated: null,
};
return new Group({
secretParams,
groupState,
});
}
public get masterKey(): Buffer<ArrayBuffer> {
return Buffer.from(this.secretParams.getMasterKey().serialize());
}
public toContext(): Proto.GroupContextV2.Params {
const masterKey = this.masterKey;
return {
masterKey,
revision: this.revision,
groupChange: null,
};
}
public encryptServiceId(serviceId: ServiceIdString): Buffer<ArrayBuffer> {
const cipher = new ClientZkGroupCipher(this.secretParams);
return Buffer.from(
cipher
.encryptServiceId(ServiceId.parseFromServiceIdString(serviceId))
.serialize(),
);
}
public decryptServiceId(
ciphertext: Uint8Array<ArrayBuffer>,
): ServiceIdString {
const cipher = new ClientZkGroupCipher(this.secretParams);
const uuidCiphertext = new UuidCiphertext(Buffer.from(ciphertext));
return cipher
.decryptServiceId(uuidCiphertext)
.getServiceIdString() as ServiceIdString;
}
public getMemberByServiceId(
serviceId: ServiceIdString,
): Proto.Member.Params | undefined {
return this.getMember(new UuidCiphertext(this.encryptServiceId(serviceId)));
}
public getPendingMemberByServiceId(
serviceId: ServiceIdString,
): Proto.MemberPendingProfileKey.Params | undefined {
return this.getPendingMember(
new UuidCiphertext(this.encryptServiceId(serviceId)),
);
}
public encryptBlob(
proto: Proto.GroupAttributeBlob.Params,
): Buffer<ArrayBuffer> {
return encryptBlob(new ClientZkGroupCipher(this.secretParams), proto);
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,823 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import assert from 'assert';
import crypto from 'crypto';
import { Buffer } from 'node:buffer';
import { signalservice as Proto } from '../../protos/compiled';
import { encryptStorageItem, encryptStorageManifest } from '../crypto';
import { Device } from '../data/device';
import { ServiceIdKind } from '../types';
import { Group } from './group';
import { PrimaryDevice } from './primary-device';
type RecordValue = NonNullable<Proto.StorageRecord.Params['record']>;
export type StorageStateRecord<Value extends RecordValue = RecordValue> =
Readonly<{
type: Proto.ManifestRecord.Identifier.Type;
key: Buffer<ArrayBuffer>;
record: Value;
}>;
export type StorageStateNewRecord = Readonly<{
type: Proto.ManifestRecord.Identifier.Type;
key?: Buffer<ArrayBuffer>;
record: RecordValue;
}>;
export type DiffResult = Readonly<{
added: ReadonlyArray<RecordValue>;
removed: ReadonlyArray<RecordValue>;
}>;
const KEY_SIZE = 16;
const IdentifierType = Proto.ManifestRecord.Identifier.Type;
type IdentifierType = Proto.ManifestRecord.Identifier.Type;
export type ToStorageItemOptions = Readonly<{
storageKey: Buffer<ArrayBuffer>;
recordIkm: Buffer<ArrayBuffer> | undefined;
}>;
export type CreateWriteOperationOptions = Readonly<{
storageKey: Buffer<ArrayBuffer>;
recordIkm: Buffer<ArrayBuffer> | undefined;
previous?: StorageState;
}>;
type StorageRecordPredicate<Value extends RecordValue> = (
record: StorageStateRecord,
) => record is StorageStateRecord<Value>;
type StorageRecordMapper<Value extends RecordValue> = (record: Value) => Value;
type StorageItemPredicate<Value extends RecordValue> = (
item: StorageStateItem,
index: number,
) => item is StorageStateItem<Value>;
class StorageStateItem<Value extends RecordValue = RecordValue> {
public readonly type: IdentifierType;
public readonly key: Buffer<ArrayBuffer>;
public readonly record: Value;
constructor({ type, key, record }: StorageStateRecord<Value>) {
this.type = type;
this.key = key;
this.record = record;
}
public getKeyString(): string {
return this.key.toString('base64');
}
public toStorageItem({
storageKey,
recordIkm,
}: ToStorageItemOptions): Proto.StorageItem.Params {
return encryptStorageItem({
storageKey,
recordIkm,
key: this.key,
record: {
record: this.record,
},
});
}
public toIdentifier(): Proto.ManifestRecord.Identifier.Params {
return {
type: this.type,
raw: this.key,
};
}
public isAccount(): this is StorageStateItem<
Extract<RecordValue, { account: unknown }>
> {
return this.type === IdentifierType.ACCOUNT && this.record.account != null;
}
public isGroup(
group: Group,
): this is StorageStateItem<Extract<RecordValue, { groupV2: unknown }>> {
if (this.type !== IdentifierType.GROUPV2) {
return false;
}
assert(this.record.groupV2 != null, 'consistency check');
const masterKey = this.record.groupV2.masterKey;
if (!masterKey) {
return false;
}
return group.masterKey.equals(masterKey);
}
public isContact(
device: Device,
serviceIdKind: ServiceIdKind,
): this is StorageStateItem<Extract<RecordValue, { contact: unknown }>> {
if (this.type !== IdentifierType.CONTACT) {
return false;
}
assert(this.record.contact != null, 'consistency check');
if (serviceIdKind === ServiceIdKind.ACI) {
const existingAci = this.record.contact.aciBinary;
if (!existingAci?.length) {
return false;
}
return Buffer.compare(existingAci, device.aciRawUuid) === 0;
}
const existingPni = this.record.contact.pniBinary;
if (!existingPni?.length) {
return false;
}
return Buffer.compare(existingPni, device.pniRawUuid) === 0;
}
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');
}
public toRecord(): StorageStateRecord<Value> {
return {
type: this.type,
key: this.key,
record: this.record,
};
}
}
const EMPTY_CONTACT: Proto.ContactRecord.Params = {
e164: null,
profileKey: null,
identityKey: null,
identityState: null,
givenName: null,
familyName: null,
username: null,
blocked: null,
whitelisted: null,
archived: null,
markedUnread: null,
mutedUntilTimestamp: null,
hideStory: null,
unregisteredAtTimestamp: null,
systemGivenName: null,
systemFamilyName: null,
systemNickname: null,
hidden: null,
pniSignatureVerified: null,
nickname: null,
note: null,
avatarColor: null,
aciBinary: null,
pniBinary: null,
};
const EMPTY_GROUP: Proto.GroupV2Record.Params = {
masterKey: null,
blocked: null,
whitelisted: null,
archived: null,
markedUnread: null,
mutedUntilTimestamp: null,
dontNotifyForMentionsIfMuted: null,
hideStory: null,
storySendMode: null,
avatarColor: null,
};
export class StorageState {
private readonly items: ReadonlyArray<StorageStateItem>;
constructor(
public readonly version: bigint,
items: ReadonlyArray<StorageStateRecord>,
) {
this.items = items.map((options) => new StorageStateItem(options));
}
public static getEmpty(): StorageState {
return new StorageState(0n, [
new StorageStateItem({
key: StorageState.createStorageID(),
type: IdentifierType.ACCOUNT,
record: {
record: 'account',
account: {
profileKey: null,
givenName: null,
familyName: null,
avatarUrlPath: null,
noteToSelfArchived: null,
readReceipts: null,
sealedSenderIndicators: null,
typingIndicators: null,
noteToSelfMarkedUnread: null,
linkPreviews: null,
phoneNumberSharingMode: null,
unlistedPhoneNumber: null,
pinnedConversations: null,
preferContactAvatars: null,
payments: null,
universalExpireTimer: null,
preferredReactionEmoji: null,
donorSubscriberId: null,
donorSubscriberCurrencyCode: null,
displayBadgesOnProfile: null,
donorSubscriptionManuallyCancelled: null,
keepMutedChatsArchived: null,
hasSetMyStoriesPrivacy: null,
hasViewedOnboardingStory: null,
storiesDisabled: null,
storyViewReceiptsEnabled: null,
hasSeenGroupStoryEducationSheet: null,
username: null,
hasCompletedUsernameOnboarding: null,
usernameLink: null,
hasBackup: null,
backupTier: null,
backupSubscriberData: null,
avatarColor: null,
notificationProfileManualOverride: null,
notificationProfileSyncDisabled: null,
},
},
}),
]);
}
//
// Account
//
public getAccountRecord(): Proto.AccountRecord.Params | undefined {
const item = this.items.find((item) => item.isAccount());
if (!item) {
return undefined;
}
return item.record.account;
}
public updateAccount(
diff: Partial<Proto.AccountRecord.Params>,
): StorageState {
return this.updateItem(
(item) => item.isAccount(),
(record) => {
return {
record: 'account',
account: {
...record.account,
...diff,
},
};
},
);
}
public updateManyAccounts(
diff: Partial<Proto.AccountRecord.Params>,
): StorageState {
return this.updateManyItems(
(item) => item.isAccount(),
(record) => {
return {
record: 'account',
account: {
...record.account,
...diff,
},
};
},
);
}
//
// Group
//
public getGroup(group: Group): Proto.GroupV2Record.Params | undefined {
const item = this.items.find((item) => item.isGroup(group));
if (!item) {
return undefined;
}
return item.record.groupV2;
}
public addGroup(
group: Group,
diff: Partial<Proto.GroupV2Record.Params> = {},
): StorageState {
return this.addItem({
type: IdentifierType.GROUPV2,
record: {
groupV2: {
...EMPTY_GROUP,
...diff,
masterKey: group.masterKey,
},
},
});
}
public updateGroup(
group: Group,
diff: Partial<Proto.GroupV2Record.Params>,
): StorageState {
return this.updateItem(
(item) => item.isGroup(group),
(record) => {
return {
groupV2: {
...record.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.identifier?.groupMasterKey == null) {
return false;
}
return group.masterKey.equals(convo.identifier.groupMasterKey);
});
}
//
// Contacts
//
public addContact(
{ device }: PrimaryDevice,
diff: Partial<Proto.ContactRecord.Params> = {},
serviceIdKind = ServiceIdKind.ACI,
): StorageState {
return this.addItem({
type: IdentifierType.CONTACT,
record: {
contact: {
...EMPTY_CONTACT,
aciBinary:
serviceIdKind === ServiceIdKind.ACI ? device.aciRawUuid : null,
pniBinary:
serviceIdKind === ServiceIdKind.PNI ? device.pniRawUuid : null,
e164: device.number,
...diff,
},
},
});
}
public updateContact(
{ device }: PrimaryDevice,
diff: Partial<Proto.ContactRecord.Params>,
serviceIdKind = ServiceIdKind.ACI,
): StorageState {
return this.updateItem(
(item) => item.isContact(device, serviceIdKind),
(record) => {
return {
record: 'contact',
contact: {
...record.contact,
...diff,
},
};
},
);
}
public getContact(
{ device }: PrimaryDevice,
serviceIdKind = ServiceIdKind.ACI,
): Proto.ContactRecord.Params | undefined {
const item = this.items.find((item) =>
item.isContact(device, serviceIdKind),
);
if (!item) {
return undefined;
}
return item.record.contact;
}
public removeContact(
{ device }: PrimaryDevice,
serviceIdKind = ServiceIdKind.ACI,
): StorageState {
return this.removeItem((item) => item.isContact(device, serviceIdKind));
}
public mergeContact(
primary: PrimaryDevice,
diff: Partial<Proto.ContactRecord.Params>,
): StorageState {
const { device } = primary;
return this.removeItem((item) => item.isContact(device, ServiceIdKind.ACI))
.removeItem((item) => item.isContact(device, ServiceIdKind.PNI))
.addContact(primary, {
pniBinary: device.pniRawUuid,
...diff,
})
.unpin(primary, ServiceIdKind.PNI);
}
public pin(
primary: PrimaryDevice,
serviceIdKind = ServiceIdKind.ACI,
): StorageState {
return this.changePin(primary, serviceIdKind, true);
}
public unpin(
primary: PrimaryDevice,
serviceIdKind = ServiceIdKind.ACI,
): StorageState {
return this.changePin(primary, serviceIdKind, false);
}
public isPinned({ device }: PrimaryDevice): boolean {
const account = this.getAccountRecord();
assert(account, 'No account record found');
return (account.pinnedConversations ?? []).some((convo) => {
if (convo.identifier?.contact == null) {
return false;
}
const existing = convo.identifier.contact.serviceIdBinary;
return existing && Buffer.compare(existing, device.aciRawUuid) === 0;
});
}
//
// Raw record access
//
public addRecord(newRecord: StorageStateNewRecord): StorageState {
return this.addItem(newRecord);
}
public findRecord<Value extends RecordValue>(
find: StorageRecordPredicate<Value>,
): StorageStateRecord<Value> | undefined {
const item = this.items.find((item): item is StorageStateItem<Value> => {
return find(item.toRecord());
});
return item?.toRecord();
}
public filterRecords<Value extends RecordValue>(
filter: StorageRecordPredicate<Value>,
): ReadonlyArray<StorageStateRecord<Value>> {
return this.items.filter((item): item is StorageStateItem<Value> =>
filter(item.toRecord()),
);
}
public hasRecord(find: (record: StorageStateRecord) => boolean): boolean {
return (
this.findRecord(find as StorageRecordPredicate<RecordValue>) !== undefined
);
}
public updateRecord<Value extends RecordValue>(
find: StorageRecordPredicate<Value>,
map: StorageRecordMapper<Value>,
): StorageState {
return this.updateItem(
(item): item is StorageStateItem<Value> => find(item.toRecord()),
map,
);
}
public updateManyRecords<Value extends RecordValue>(
filter: StorageRecordPredicate<Value>,
map: StorageRecordMapper<Value>,
): StorageState {
return this.updateManyItems(
(item): item is StorageStateItem<Value> => filter(item.toRecord()),
map,
);
}
public removeRecord(
find: (record: StorageStateRecord) => boolean,
): StorageState {
return this.removeItem((item) => find(item.toRecord()));
}
public removeManyRecords(
filter: (record: StorageStateRecord) => boolean,
): StorageState {
return this.removeManyItems((item) => filter(item.toRecord()));
}
public getAllGroupRecords(): ReadonlyArray<
StorageStateRecord<Extract<RecordValue, { groupV2: unknown }>>
> {
return this.items
.filter(
(
item,
): item is StorageStateItem<
Extract<RecordValue, { groupV2: unknown }>
> => item.type === IdentifierType.GROUPV2,
)
.map((item) => item.toRecord());
}
public hasKey(storageKey: Buffer<ArrayBuffer>): boolean {
return this.hasRecord((item) => item.key.equals(storageKey));
}
//
// General
//
public createWriteOperation({
storageKey,
recordIkm,
previous,
}: CreateWriteOperationOptions): Proto.WriteOperation.Params {
const newVersion = previous ? previous.version + 1n : this.version + 1n;
const keysToDelete = new Set(
(previous?.items ?? []).map((item) => {
return item.getKeyString();
}),
);
const insertItem = new Array<Proto.StorageItem.Params>();
for (const item of this.items) {
if (!keysToDelete.delete(item.getKeyString())) {
insertItem.push(item.toStorageItem({ storageKey, recordIkm }));
}
}
const manifest = encryptStorageManifest(storageKey, {
version: newVersion,
identifiers: this.items.map((item) => item.toIdentifier()),
recordIkm: recordIkm ?? null,
sourceDevice: null,
});
return {
manifest,
insertItem,
deleteKey: Array.from(keysToDelete).map((key) => {
return Buffer.from(key, 'base64');
}),
clearAll: null,
};
}
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, RecordValue>();
const removedIds = new Map<string, RecordValue>();
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 addItem(newRecord: StorageStateNewRecord): StorageState {
return this.replaceItem(this.items.length, newRecord);
}
private findItemIndex(
find: (record: StorageStateItem, index: number) => boolean,
): number {
const itemIndex = this.items.findIndex(find);
if (itemIndex === -1) {
throw new Error('Item not found');
}
const otherIndex = this.items.findLastIndex(find);
if (otherIndex !== itemIndex) {
throw new Error('Found multiple items');
}
return itemIndex;
}
private updateItem<Value extends RecordValue>(
find: StorageItemPredicate<Value>,
map: StorageRecordMapper<Value>,
): StorageState {
const itemIndex = this.findItemIndex(find);
const item = this.items[itemIndex] as StorageStateItem<Value> | undefined;
assert(item, 'consistency check');
return this.replaceItem(itemIndex, {
type: item.type,
record: map(item.record),
});
}
public updateManyItems<Value extends RecordValue>(
filter: StorageItemPredicate<Value>,
map: StorageRecordMapper<Value>,
): StorageState {
let updated = 0;
const newItems = this.items.map((item, index) => {
if (filter(item, index)) {
updated += 1;
return new StorageStateItem({
type: item.type,
key: StorageState.createStorageID(),
record: map(item.record),
});
} else {
return item;
}
});
if (updated === 0) {
throw new Error('No items updated');
}
return new StorageState(this.version, newItems);
}
private replaceItem(
index: number,
{
type,
record,
key = StorageState.createStorageID(),
}: StorageStateNewRecord,
): StorageState {
const newItems = [
...this.items.slice(0, index),
new StorageStateItem({ type, key, record }),
...this.items.slice(index + 1),
];
return new StorageState(this.version, newItems);
}
private removeItem(
find: (item: StorageStateItem, index: number) => boolean,
): StorageState {
const itemIndex = this.findItemIndex(find);
const newItems = [
...this.items.slice(0, itemIndex),
...this.items.slice(itemIndex + 1),
];
return new StorageState(this.version, newItems);
}
private removeManyItems(
filter: (item: StorageStateItem, index: number) => boolean,
): StorageState {
const newItems = this.items.filter((item, index) => {
return !filter(item, index);
});
if (newItems.length === this.items.length) {
throw new Error('No items removed');
}
return new StorageState(this.version, newItems);
}
private changePin(
{ device }: PrimaryDevice,
serviceIdKind: ServiceIdKind,
isPinned: boolean,
): StorageState {
const deviceServiceIdBinary =
device.getServiceIdBinaryByKind(serviceIdKind);
return this.updateItem(
(item) => item.isAccount(),
(record) => {
const { account } = record;
const { pinnedConversations } = account;
const newPinnedConversations = pinnedConversations?.slice() ?? [];
const existingIndex = newPinnedConversations.findIndex((convo) => {
if (convo.identifier?.contact == null) {
return false;
}
const existing = convo.identifier.contact.serviceIdBinary;
return (
existing && Buffer.compare(existing, deviceServiceIdBinary) === 0
);
});
if (isPinned && existingIndex === -1) {
newPinnedConversations.push({
identifier: {
contact: {
e164: null,
serviceIdBinary: deviceServiceIdBinary,
},
},
});
} 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(),
(record) => {
const { account } = record;
const { pinnedConversations } =
account satisfies Proto.AccountRecord.Params;
const newPinnedConversations = pinnedConversations?.slice() ?? [];
const existingIndex = newPinnedConversations.findIndex((convo) => {
if (convo.identifier?.groupMasterKey == null) {
return false;
}
return group.masterKey.equals(convo.identifier.groupMasterKey);
});
if (isPinned && existingIndex === -1) {
newPinnedConversations.push({
identifier: {
groupMasterKey: group.masterKey,
},
});
} else if (!isPinned && existingIndex !== -1) {
newPinnedConversations.splice(existingIndex, 1);
}
return {
account: {
...account,
pinnedConversations: newPinnedConversations,
},
};
},
);
}
private static createStorageID(): Buffer<ArrayBuffer> {
return crypto.randomBytes(KEY_SIZE);
}
}
+299
View File
@@ -0,0 +1,299 @@
// Copyright 2026 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import {
createHash,
createHmac,
randomBytes,
randomInt,
timingSafeEqual,
} from 'node:crypto';
import z from 'zod';
import { parseAuthHeader } from './util';
import { CallingPublicKey } from './sfu/crypto';
export type Uint32 = number & { Uint32: never };
const UINT32_MAX = 4_294_967_296; // 2 ** 32 1
function getRandomUint32(): Uint32 {
const minInclusive = 0;
const maxExclusive = UINT32_MAX + 1;
return randomInt(minInclusive, maxExclusive) as Uint32;
}
export type HexString = string & { HexString: never };
export function getRandomHexString(size: number): HexString {
return randomBytes(size).toString('hex') as HexString;
}
/**
* Calling IDs
* ----------------------------------------------------------------------------
*/
/**
* In multi-participant calls, the room id is the permanent ID
* equivalent to an ACI. It comes from a shared secret owned by the clients.
*/
export type CallingRoomId = string & { RoomId: never };
/**
* Ephemeral, generated by calling service.
* Only used for group/adhoc calls.
*/
export type CallingEraId = HexString & { EraId: never };
export function getRandomCallingEraId(): CallingEraId {
return getRandomHexString(16) as CallingEraId;
}
export type CallingDemuxId = Uint32 & { DemuxId: never };
export function getRandomCallingDemuxId(): CallingDemuxId {
return ((getRandomUint32() & ~0b1111) >>> 0) as CallingDemuxId;
}
export type CallingUserId = HexString & { UserId: never };
export enum CallType {
Group,
Adhoc,
}
export enum CallLinkRestrictions {
AdminApproval,
None,
}
/**
* Http
* ----------------------------------------------------------------------------
*/
export const CallingPublicKeySchema = z
.string()
.nonempty()
.transform((input) => {
return input as HexString;
});
export function decodeCallingPublicKey(input: HexString): CallingPublicKey {
return CallingPublicKey.fromBytes(Buffer.from(input, 'hex'));
}
export function encodeCallingPublicKey(key: CallingPublicKey): HexString {
return Buffer.from(key.toBytes()).toString('hex') as HexString;
}
/**
* Peek
* ----------------------------------------------------------------------------
*/
export type CallInfoClient = Readonly<{
demuxId: CallingDemuxId;
opaqueUserId: CallingUserId;
}>;
export type CallInfo = Readonly<{
eraId: CallingEraId;
maxClients: number;
creatorUserId: CallingUserId;
activeClients: ReadonlyArray<CallInfoClient>;
pendingClients: ReadonlyArray<CallInfoClient> | null;
}>;
/**
* Errors
* ----------------------------------------------------------------------------
*/
export enum CallingErrorCode {
AuthError,
CallNotFound,
TooManyClients,
NoPermissionToCreateCall,
DuplicateDemuxIdDetected,
InternalError,
}
export class CallingError extends Error {
#code: CallingErrorCode;
constructor(code: CallingErrorCode, message?: string) {
super(message);
this.#code = code;
}
get code(): CallingErrorCode {
return this.#code;
}
}
export const CallingErrorCodesToHttpStatus: Record<CallingErrorCode, number> = {
[CallingErrorCode.AuthError]: 401,
[CallingErrorCode.CallNotFound]: 404,
[CallingErrorCode.TooManyClients]: 413,
[CallingErrorCode.NoPermissionToCreateCall]: 500,
[CallingErrorCode.DuplicateDemuxIdDetected]: 500,
[CallingErrorCode.InternalError]: 500,
};
/**
* Auth
* ----------------------------------------------------------------------------
*/
export const CALLING_SERVICE_SECRET = randomBytes(32);
function parseNumberSafe(value: string): number | null {
const trimmed = value.trim();
if (trimmed === '') {
return null;
}
const parsed = Number(value);
if (!Number.isFinite(parsed)) {
return null;
}
return parsed;
}
export type CallingAuthToken = Readonly<{
userId: CallingUserId;
groupId: CallingRoomId;
time: number;
isAllowedToInitiateGroupCall: boolean;
macCiphertext: string;
macDigest: Uint8Array<ArrayBuffer>;
}>;
export function parseCallingAuthHeader(authHeader?: string): CallingAuthToken {
const { error, password } = parseAuthHeader(authHeader);
if (error != null) {
throw new CallingError(CallingErrorCode.AuthError, error);
}
const [
version = '',
userIdStr = '',
groupIdHex = '',
timeUnixSecsStr = '',
permissionStr = '',
macDigestHex = '',
] = password.split(':');
if (version !== '2') {
throw new CallingError(
CallingErrorCode.AuthError,
'Unsupported call auth signature',
);
}
const userId = userIdStr as CallingUserId;
if (userId.length === 0) {
throw new CallingError(CallingErrorCode.AuthError, 'Missing userId');
}
const groupId = Buffer.from(groupIdHex, 'hex').toString() as CallingRoomId;
if (groupId.length === 0) {
throw new CallingError(CallingErrorCode.AuthError, 'Missing groupId');
}
const timeUnixSecs = parseNumberSafe(timeUnixSecsStr);
if (timeUnixSecs == null) {
throw new CallingError(
CallingErrorCode.AuthError,
'Time not encoded correctly',
);
}
const time = timeUnixSecs * 1000;
const isAllowedToInitiateGroupCall = permissionStr === '1';
const macDigest = Buffer.from(macDigestHex, 'hex');
if (macDigest.length === 0) {
throw new CallingError(CallingErrorCode.AuthError, 'Missing macDigest');
}
const macCiphertext = password.slice(
0,
password.length - macDigestHex.length - 1,
);
const token: CallingAuthToken = {
userId,
groupId,
time,
isAllowedToInitiateGroupCall,
macCiphertext,
macDigest,
};
return token;
}
const GV2_AUTH_MATCH_LIMIT = 10;
const GV2_AUTH_MAX_HEADER_AGE = 30 * 60 * 60 * 1000;
export type CallingAuth = Readonly<{
userId: CallingUserId;
roomId: CallingRoomId;
isAllowedToInitiateGroupCall: boolean;
}>;
export function verifyCallingAuthToken(
token: CallingAuthToken,
key: Uint8Array<ArrayBuffer>,
): CallingAuth {
const expectedDigest = createHmac('sha256', key)
.update(token.macCiphertext)
.digest()
.subarray(0, GV2_AUTH_MATCH_LIMIT);
if (!timingSafeEqual(expectedDigest, token.macDigest)) {
throw new CallingError(CallingErrorCode.AuthError, 'Incorrect hmac digest');
}
if (Date.now() > token.time + GV2_AUTH_MAX_HEADER_AGE) {
throw new CallingError(CallingErrorCode.AuthError, 'Expired credentials');
}
const auth: CallingAuth = {
userId: token.userId,
roomId: token.groupId,
isAllowedToInitiateGroupCall: token.isAllowedToInitiateGroupCall,
};
return auth;
}
export type GenerateCallingAuthTokenOptions = Readonly<{
userId: Uint8Array<ArrayBuffer>;
groupId: string;
isAllowedToInitiateGroupCall: boolean;
key: Uint8Array<ArrayBuffer>;
}>;
export function generateCallingAuthToken(
options: GenerateCallingAuthTokenOptions,
): string {
let data = '';
data += '2';
data += ':';
data += createHash('sha256').update(options.userId).digest('hex');
data += ':';
data += Buffer.from(options.groupId).toString('hex');
data += ':';
data += `${Math.trunc(Date.now() / 1000)}`;
data += ':';
data += options.isAllowedToInitiateGroupCall ? '1' : '0';
const hmac = createHmac('sha256', options.key)
.update(data)
.digest()
.subarray(0, GV2_AUTH_MATCH_LIMIT)
.toString('hex');
return `${data}:${hmac}`;
}
+15
View File
@@ -0,0 +1,15 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
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;
export const DAY_IN_SECONDS = 24 * 3600;
export const PROFILE_KEY_CREDENTIAL_EXPIRATION = 7 * DAY_IN_SECONDS;
+454
View File
@@ -0,0 +1,454 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import crypto from 'crypto';
import { Buffer } from 'buffer';
import {
KEMPublicKey,
PrivateKey,
PublicKey,
SenderCertificate,
hkdf,
} from '@signalapp/libsignal-client';
import { signalservice as Proto } from '../protos/compiled';
import { Attachment } from './data/attachment';
import type { ServerPreKey, ServerSignedPreKey } from './data/schemas';
import { NEVER_EXPIRES, SERVER_CERTIFICATE_ID } from './constants';
import {
AciString,
DeviceId,
KyberPreKey,
PreKey,
SignedPreKey,
} from './types';
import { ReadonlyDeep } from 'type-fest';
const AES_KEY_SIZE = 32;
const MAC_KEY_SIZE = 32;
const AESGCM_IV_SIZE = 12;
const AUTH_TAG_SIZE = 16;
const MASTER_KEY_SIZE = 32;
export type EncryptedProvisionMessage = {
body: Buffer<ArrayBuffer>;
ephemeralKey: Buffer<ArrayBuffer>;
};
export type ServerCertificate = {
privateKey: PrivateKey;
certificate: Proto.ServerCertificate.Params;
};
export type Sender = {
readonly aci: AciString;
readonly number?: string;
readonly deviceId: DeviceId;
readonly identityKey: PublicKey;
readonly expires?: number;
};
export function encryptProvisionMessage(
data: Buffer<ArrayBuffer>,
remotePubKey: PublicKey,
): EncryptedProvisionMessage {
const privateKey = PrivateKey.generate();
const publicKey = privateKey.getPublicKey();
const agreement = privateKey.agree(remotePubKey);
const secrets = hkdf(
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: Buffer.from(publicKey.serialize()),
};
}
export type EncryptAttachmentOptions = Readonly<{
aesKey: Buffer<ArrayBuffer>;
macKey: Buffer<ArrayBuffer>;
iv: Buffer<ArrayBuffer>;
}>;
export function encryptAttachment(
cleartext: Buffer<ArrayBuffer>,
{ aesKey, macKey, iv }: EncryptAttachmentOptions = {
aesKey: crypto.randomBytes(32),
macKey: crypto.randomBytes(32),
iv: crypto.randomBytes(16),
},
): Attachment {
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(),
}),
);
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 ?? null,
senderUuid: sender.aci,
senderDevice: sender.deviceId,
expires: BigInt(sender.expires ?? NEVER_EXPIRES),
identityKey: sender.identityKey.serialize(),
signer: serverCert.certificate,
}),
);
const signature = serverCert.privateKey.sign(data);
const certificate = Buffer.from(
Proto.SenderCertificate.encode({
certificate: data,
signature,
}),
);
return SenderCertificate.deserialize(certificate);
}
export function deriveAccessKey(
profileKey: Uint8Array<ArrayBuffer>,
): Buffer<ArrayBuffer> {
const cipher = crypto.createCipheriv(
'aes-256-gcm',
profileKey,
Buffer.alloc(12),
);
return Buffer.concat([cipher.update(Buffer.alloc(16)), cipher.final()]);
}
export function deriveMasterKey(
accountEntropyPool: string,
): Buffer<ArrayBuffer> {
return Buffer.from(
hkdf(
MASTER_KEY_SIZE,
Buffer.from(accountEntropyPool),
Buffer.from('20240801_SIGNAL_SVR_MASTER_KEY'),
null,
),
);
}
export function deriveStorageKey(
masterKey: Buffer<ArrayBuffer>,
): Buffer<ArrayBuffer> {
const hash = crypto.createHmac('sha256', masterKey);
hash.update('Storage Service Encryption');
return hash.digest();
}
function deriveStorageManifestKey(
storageKey: Buffer<ArrayBuffer>,
version: bigint,
): Buffer<ArrayBuffer> {
const hash = crypto.createHmac('sha256', storageKey);
hash.update(`Manifest_${version.toString()}`);
return hash.digest();
}
const STORAGE_SERVICE_ITEM_KEY_INFO_PREFIX =
'20240801_SIGNAL_STORAGE_SERVICE_ITEM_';
const STORAGE_SERVICE_ITEM_KEY_LEN = 32;
export type DeriveStorageItemKeyOptions = Readonly<{
storageKey: Buffer<ArrayBuffer>;
recordIkm: Buffer<ArrayBuffer> | undefined;
key: Buffer<ArrayBuffer>;
}>;
export function deriveStorageItemKey({
storageKey,
recordIkm,
key,
}: DeriveStorageItemKeyOptions): Buffer<ArrayBuffer> {
if (recordIkm === undefined) {
const hash = crypto.createHmac('sha256', storageKey);
hash.update(`Item_${key.toString('base64')}`);
return hash.digest();
}
return Buffer.from(
hkdf(
STORAGE_SERVICE_ITEM_KEY_LEN,
recordIkm,
Buffer.concat([Buffer.from(STORAGE_SERVICE_ITEM_KEY_INFO_PREFIX), key]),
Buffer.alloc(0),
),
);
}
function decryptAESGCM(
ciphertext: Buffer<ArrayBuffer>,
key: Buffer<ArrayBuffer>,
): Buffer<ArrayBuffer> {
const iv = ciphertext.subarray(0, AESGCM_IV_SIZE);
const tag = ciphertext.subarray(ciphertext.length - AUTH_TAG_SIZE);
const rest = ciphertext.subarray(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: Uint8Array<ArrayBuffer>,
key: Uint8Array<ArrayBuffer>,
): Buffer<ArrayBuffer> {
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<ArrayBuffer>,
manifest: Proto.StorageManifest.Params,
): Proto.ManifestRecord {
if (!manifest.value?.length) {
throw new Error('Missing manifest.value');
}
const manifestKey = deriveStorageManifestKey(
storageKey,
manifest.version ?? 0n,
);
const decoded = Proto.ManifestRecord.decode(
decryptAESGCM(Buffer.from(manifest.value), manifestKey),
);
if (decoded.version !== manifest.version) {
throw new Error('manifestRecord.version != manifest.version');
}
return decoded;
}
export function encryptStorageManifest(
storageKey: Buffer<ArrayBuffer>,
manifestRecord: Proto.ManifestRecord.Params,
): Proto.StorageManifest.Params {
if (!manifestRecord.version) {
throw new Error('Missing manifest.version');
}
const manifestKey = deriveStorageManifestKey(
storageKey,
manifestRecord.version,
);
const encrypted = encryptAESGCM(
Buffer.from(Proto.ManifestRecord.encode(manifestRecord)),
manifestKey,
);
return {
version: manifestRecord.version,
value: encrypted,
};
}
export type DecryptStorageItemOptions = Readonly<{
storageKey: Buffer<ArrayBuffer>;
recordIkm: Buffer<ArrayBuffer> | undefined;
item: Proto.StorageItem.Params;
}>;
export function decryptStorageItem({
storageKey,
recordIkm,
item,
}: DecryptStorageItemOptions): Proto.StorageRecord {
if (!item.key) {
throw new Error('Missing item.key');
}
if (!item.value) {
throw new Error('Missing item.value');
}
const itemKey = deriveStorageItemKey({
storageKey,
recordIkm,
key: Buffer.from(item.key),
});
return Proto.StorageRecord.decode(
decryptAESGCM(Buffer.from(item.value), itemKey),
);
}
export type EncryptStorageItemOptions = Readonly<{
storageKey: Buffer<ArrayBuffer>;
key: Buffer<ArrayBuffer>;
recordIkm: Buffer<ArrayBuffer> | undefined;
record: Proto.StorageRecord.Params;
}>;
export function encryptStorageItem({
storageKey,
key,
recordIkm,
record,
}: EncryptStorageItemOptions): Proto.StorageItem.Params {
const itemKey = deriveStorageItemKey({
storageKey,
recordIkm,
key,
});
const encrypted = encryptAESGCM(
Buffer.from(Proto.StorageRecord.encode(record)),
itemKey,
);
return {
key,
value: encrypted,
};
}
export function encryptProfileName(
profileKey: Uint8Array<ArrayBuffer>,
name: string,
): Buffer<ArrayBuffer> {
const encrypted = encryptAESGCM(Buffer.from(name), profileKey);
return encrypted;
}
export function generateAccessKeyVerifier(
accessKey: Buffer<ArrayBuffer>,
): Buffer<ArrayBuffer> {
const zeroes = Buffer.alloc(32);
return crypto.createHmac('sha256', accessKey).update(zeroes).digest();
}
export function decodePreKey({ keyId, publicKey }: ServerPreKey): PreKey {
return {
keyId,
publicKey: PublicKey.deserialize(Buffer.from(publicKey, 'base64')),
};
}
export function decodeSignedPreKey({
keyId,
publicKey,
signature,
}: ServerSignedPreKey): SignedPreKey {
return {
keyId,
publicKey: PublicKey.deserialize(Buffer.from(publicKey, 'base64')),
signature: Buffer.from(signature, 'base64'),
};
}
export function decodeKyberPreKey({
keyId,
publicKey,
signature,
}: ServerSignedPreKey): KyberPreKey {
return {
keyId,
publicKey: KEMPublicKey.deserialize(Buffer.from(publicKey, 'base64')),
signature: Buffer.from(signature, 'base64'),
};
}
export function hashRemoteConfig(
config: ReadonlyDeep<Array<[string, string]>>,
): Buffer<ArrayBuffer> {
// Not necessarily secure, but this will let us detect changes. The exact
// format isn't important so long as it's deterministic.
const mac = crypto.createHmac('sha256', 'remoteConfig');
return config
.reduce(
(mac, [name, value], index) =>
mac
.update(index.toString())
.update(name.length.toString())
.update(name)
.update(value.length.toString())
.update(value),
mac,
)
.digest();
}
@@ -0,0 +1,39 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { signalservice as Proto } from '../../protos/compiled';
export type Attachment = {
key: Buffer<ArrayBuffer>;
blob: Buffer<ArrayBuffer>;
digest: Buffer<ArrayBuffer>;
size: number;
};
export function attachmentToPointer(
cdnKey: string,
attachment: Attachment,
): Proto.AttachmentPointer.Params {
return {
contentType: 'application/octet-stream',
attachmentIdentifier: {
cdnKey,
},
key: attachment.key,
size: attachment.size,
digest: attachment.digest,
clientUuid: null,
thumbnail: null,
incrementalMac: null,
chunkSize: null,
fileName: null,
flags: null,
width: null,
height: null,
caption: null,
blurHash: null,
uploadTimestamp: null,
cdnNumber: null,
};
}
+20
View File
@@ -0,0 +1,20 @@
// Copyright 2026 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { CallingRoomId } from '../calling';
export type CallDataOptions = Readonly<{
roomId: CallingRoomId | null;
}>;
export abstract class CallData {
#roomId: CallingRoomId | null;
constructor(options: CallDataOptions) {
this.#roomId = options.roomId;
}
public get roomId(): CallingRoomId | null {
return this.#roomId;
}
}
@@ -0,0 +1,57 @@
// 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;
genericServerPublicParams: string;
backupServerPublicParams: string;
serverPublicParams: string;
serverTrustRoots: ReadonlyArray<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,
genericServerPublicParams,
backupServerPublicParams,
serverPublicParams,
serverTrustRoot,
] = await Promise.all([
loadString('ca-cert.pem'),
loadJSONProperty('zk-params.json', 'genericPublicParams'),
loadJSONProperty('zk-params.json', 'backupPublicParams'),
loadJSONProperty('zk-params.json', 'publicParams'),
loadJSONProperty('trust-root.json', 'publicKey'),
]);
return {
certificateAuthority,
genericServerPublicParams,
backupServerPublicParams,
serverPublicParams,
serverTrustRoots: [serverTrustRoot],
};
}
+49
View File
@@ -0,0 +1,49 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { signalservice as Proto } from '../../protos/compiled';
export type Contact = Readonly<{
aciBinary: Uint8Array<ArrayBuffer>;
number: string;
profileName: string;
}>;
export function serializeContacts(
contacts: ReadonlyArray<Contact>,
): Buffer<ArrayBuffer> {
const chunks = contacts
.map((contact) => {
const { aciBinary, number, profileName: name } = contact;
return Buffer.from(
Proto.ContactDetails.encode({
aciBinary,
number,
name,
avatar: null,
expireTimer: null,
expireTimerVersion: null,
inboxPosition: null,
aci: null,
}),
);
})
.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());
}
+337
View File
@@ -0,0 +1,337 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { timingSafeEqual } from 'node:crypto';
import createDebug from 'debug';
import {
Aci,
Pni,
ProtocolAddress,
PublicKey,
} from '@signalapp/libsignal-client';
import {
BackupLevel,
ProfileKeyCommitment,
} from '@signalapp/libsignal-client/zkgroup';
import {
AciString,
DeviceId,
KyberPreKey,
PniString,
PreKey,
RegistrationId,
ServiceIdKind,
ServiceIdString,
SignedPreKey,
} from '../types';
const debug = createDebug('mock:device');
export type DeviceOptions = Readonly<{
aci: AciString;
pni: PniString;
number: string;
deviceId: DeviceId;
registrationId: RegistrationId;
pniRegistrationId: RegistrationId;
isProvisioned: boolean;
}>;
export type ChangeNumberOptions = Readonly<{
number: string;
pni: PniString;
pniRegistrationId: RegistrationId;
}>;
export type DeviceKeys = Readonly<{
identityKey: PublicKey;
preKeys?: ReadonlyArray<PreKey>;
kyberPreKeys?: ReadonlyArray<KyberPreKey>;
lastResortKey?: KyberPreKey;
signedPreKey?: SignedPreKey;
preKeyIterator?: AsyncIterator<PreKey, undefined>;
kyberPreKeyIterator?: AsyncIterator<KyberPreKey, undefined>;
}>;
export type SingleUseKey = Readonly<{
identityKey: PublicKey;
signedPreKey: SignedPreKey;
preKey: PreKey | undefined;
pqPreKey: KyberPreKey;
}>;
type InternalDeviceKeys = Readonly<{
identityKey: PublicKey;
signedPreKey: SignedPreKey;
lastResortKey: KyberPreKey;
preKeys: Array<PreKey>;
kyberPreKeys: Array<KyberPreKey>;
preKeyIterator?: AsyncIterator<PreKey, undefined>;
kyberPreKeyIterator?: AsyncIterator<KyberPreKey, undefined>;
}>;
// Technically, it is infinite.
const PRE_KEY_ITERATOR_COUNT = 100;
export class Device {
public readonly aci: AciString;
public readonly deviceId: DeviceId;
public readonly address: ProtocolAddress;
// If `true` - the device was provisioned and should receive messages over
// the websocket.
public readonly isProvisioned: boolean;
public capabilities: {
deleteSync: boolean;
versionedExpirationTimer: boolean;
ssre2: boolean;
usernameChangeSyncMessage: boolean;
};
public backupLevel = BackupLevel.Paid;
public accessKey?: Buffer<ArrayBuffer>;
public profileKeyCommitment?: ProfileKeyCommitment;
public profileName?: Buffer<ArrayBuffer>;
private keys = new Map<ServiceIdKind, InternalDeviceKeys>();
private privPni: PniString;
private privNumber: string;
private privPniAddress: ProtocolAddress;
private readonly registrationId: RegistrationId;
private pniRegistrationId: RegistrationId;
constructor(options: DeviceOptions) {
this.aci = options.aci;
this.deviceId = options.deviceId;
this.registrationId = options.registrationId;
this.privPni = options.pni;
this.privNumber = options.number;
this.pniRegistrationId = options.pniRegistrationId;
this.isProvisioned = options.isProvisioned;
this.address = ProtocolAddress.new(this.aci, this.deviceId);
this.privPniAddress = ProtocolAddress.new(this.pni, this.deviceId);
this.capabilities = {
deleteSync: true,
versionedExpirationTimer: true,
ssre2: true,
usernameChangeSyncMessage: true,
};
}
public get debugId(): string {
return `${this.aci}.${this.deviceId}`;
}
public getRegistrationId(serviceIdKind: ServiceIdKind): number {
switch (serviceIdKind) {
case ServiceIdKind.ACI:
return this.registrationId;
case ServiceIdKind.PNI:
return this.pniRegistrationId;
}
}
public get aciBinary(): Uint8Array<ArrayBuffer> {
return Aci.parseFromServiceIdString(this.aci).getServiceIdBinary();
}
public get pni(): PniString {
return this.privPni;
}
public get pniBinary(): Uint8Array<ArrayBuffer> {
return Pni.parseFromServiceIdString(this.pni).getServiceIdBinary();
}
public get aciRawUuid(): Uint8Array<ArrayBuffer> {
return Aci.parseFromServiceIdString(this.aci).getRawUuidBytes();
}
public get pniRawUuid(): Uint8Array<ArrayBuffer> {
return Pni.parseFromServiceIdString(this.pni).getRawUuidBytes();
}
public get number(): string {
return this.privNumber;
}
public get pniAddress(): ProtocolAddress {
return this.privPniAddress;
}
public async changeNumber({
number,
pni,
pniRegistrationId,
}: ChangeNumberOptions): Promise<void> {
this.privNumber = number;
this.privPni = pni;
this.pniRegistrationId = pniRegistrationId;
this.privPniAddress = ProtocolAddress.new(this.pni, this.deviceId);
}
public async setKeys(
serviceIdKind: ServiceIdKind,
keys: DeviceKeys,
): Promise<void> {
debug('setting %s keys for %s', serviceIdKind, this.debugId);
const existingKeys = this.keys.get(serviceIdKind);
const {
signedPreKey = existingKeys?.signedPreKey,
lastResortKey = existingKeys?.lastResortKey,
} = keys;
if (!signedPreKey) {
throw new Error('setKeys: Missing signedPreKey');
}
if (!lastResortKey) {
throw new Error('setKeys: Missing lastResortKey');
}
this.keys.set(serviceIdKind, {
identityKey: keys.identityKey,
signedPreKey,
preKeys: keys.preKeys?.slice() ?? [],
kyberPreKeys: keys.kyberPreKeys?.slice() ?? [],
lastResortKey,
preKeyIterator: keys.preKeyIterator,
kyberPreKeyIterator: keys.kyberPreKeyIterator,
});
}
public async getIdentityKey(
serviceIdKind = ServiceIdKind.ACI,
): Promise<PublicKey> {
const keys = this.keys.get(serviceIdKind);
if (!keys) {
throw new Error('No keys available for device');
}
return keys.identityKey;
}
public async popSingleUseKey(
serviceIdKind = ServiceIdKind.ACI,
): Promise<SingleUseKey> {
const keys = this.keys.get(serviceIdKind);
if (!keys) {
throw new Error('No keys available for device');
}
debug('popping single use key for %s', this.debugId);
let preKey: PreKey | undefined;
if (keys.preKeyIterator) {
const { value } = await keys.preKeyIterator.next();
preKey = value;
}
preKey ??= keys.preKeys.shift();
let pqPreKey: KyberPreKey | undefined;
if (keys.kyberPreKeyIterator) {
const { value } = await keys.kyberPreKeyIterator.next();
pqPreKey = value;
}
pqPreKey ??= keys.kyberPreKeys.shift();
pqPreKey ??= keys.lastResortKey;
if (!pqPreKey) {
throw new Error(
'popSingleUseKey: Missing pqPreKey; checked iterator/array/lastResort',
);
}
return {
identityKey: keys.identityKey,
signedPreKey: keys.signedPreKey,
preKey,
pqPreKey,
};
}
public async getPreKeyCount(
serviceIdKind = ServiceIdKind.ACI,
): Promise<number> {
const keys = this.keys.get(serviceIdKind);
if (!keys) {
throw new Error('No keys available for device');
}
if (keys.preKeyIterator) {
return PRE_KEY_ITERATOR_COUNT;
}
return keys.preKeys.length;
}
public async getKyberPreKeyCount(
serviceIdKind = ServiceIdKind.ACI,
): Promise<number> {
const keys = this.keys.get(serviceIdKind);
if (!keys) {
throw new Error('No keys available for device');
}
if (keys.kyberPreKeyIterator) {
return PRE_KEY_ITERATOR_COUNT;
}
return keys.kyberPreKeys.length;
}
public getServiceIdByKind(serviceIdKind: ServiceIdKind): ServiceIdString {
switch (serviceIdKind) {
case ServiceIdKind.ACI:
return this.aci;
case ServiceIdKind.PNI:
return this.pni;
}
}
public getServiceIdBinaryByKind(
serviceIdKind: ServiceIdKind,
): Uint8Array<ArrayBuffer> {
switch (serviceIdKind) {
case ServiceIdKind.ACI:
return this.aciBinary;
case ServiceIdKind.PNI:
return this.pniBinary;
}
}
public getServiceIdKind(serviceId: ServiceIdString): ServiceIdKind {
if (serviceId === this.aci) {
return ServiceIdKind.ACI;
}
if (serviceId === this.pni) {
return ServiceIdKind.PNI;
}
throw new Error(`Unknown serviceId: ${serviceId}`);
}
public getServiceIdBinaryKind(
serviceIdBinary: Uint8Array<ArrayBuffer>,
): ServiceIdKind {
if (timingSafeEqual(serviceIdBinary, this.aciBinary)) {
return ServiceIdKind.ACI;
}
if (timingSafeEqual(serviceIdBinary, this.pniBinary)) {
return ServiceIdKind.PNI;
}
throw new Error('Unknown serviceId');
}
public getAddressByKind(serviceIdKind: ServiceIdKind): ProtocolAddress {
switch (serviceIdKind) {
case ServiceIdKind.ACI:
return this.address;
case ServiceIdKind.PNI:
return this.pniAddress;
}
}
}
+82
View File
@@ -0,0 +1,82 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import assert from 'assert';
import {
GroupPublicParams,
UuidCiphertext,
} from '@signalapp/libsignal-client/zkgroup';
import { signalservice as Proto } from '../../protos/compiled';
export abstract class Group {
protected privChanges?: Proto.GroupChanges.Params;
protected privPublicParams?: GroupPublicParams;
public get changes(): Readonly<Proto.GroupChanges.Params> {
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 get state(): Readonly<Proto.Group.Params> {
const { groupChanges } = this.changes;
assert(groupChanges, 'Missing group changes in the group state');
const state = groupChanges.at(-1)?.groupState;
assert(state, 'Group must have the last state');
return state;
}
public get id(): string {
return Buffer.from(
this.publicParams.getGroupIdentifier().serialize(),
).toString('base64');
}
public get revision(): number {
return this.state.version ?? 0;
}
public getChangesSince(since: number): Readonly<Proto.GroupChanges.Params> {
return {
groupChanges: this.changes.groupChanges?.slice(since) ?? null,
groupSendEndorsementsResponse: null,
};
}
public getMember(
uuidCiphertext: UuidCiphertext,
): Proto.Member.Params | undefined {
const state = this.state;
const userId = Buffer.from(uuidCiphertext.serialize());
return (
state.members?.find((member) => {
if (!member.userId) {
return false;
}
return userId.equals(member.userId);
}) ?? undefined
);
}
public getPendingMember(
uuidCiphertext: UuidCiphertext,
): Proto.MemberPendingProfileKey.Params | undefined {
const state = this.state;
const userId = Buffer.from(uuidCiphertext.serialize());
return (
state.membersPendingProfileKey?.find(({ member }) => {
if (!member?.userId) {
return false;
}
return userId.equals(member.userId);
}) ?? undefined
);
}
}
+291
View File
@@ -0,0 +1,291 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import z from 'zod';
import {
AciString,
DeviceId,
PniString,
RegistrationId,
ServiceIdString,
} from '../types';
import { fromBase64, fromURLSafeBase64 } from '../util';
export const PositiveInt = z.coerce.number().int().nonnegative();
export const AciSchema = z.string().transform((x) => x as AciString);
export const PniSchema = z
.string()
.refine((x) => x.startsWith('PNI:'))
.transform((x) => x as PniString);
export const ServiceIdSchema = z
.string()
.transform((x) => x as ServiceIdString);
export const RegistrationIdSchema = z
.number()
.transform((x) => x as RegistrationId);
export const DeviceIdSchema = z.number().transform((x) => x as DeviceId);
const PreKeySchema = z.object({
keyId: z.number(),
publicKey: z.string(),
});
export type ServerPreKey = z.infer<typeof PreKeySchema>;
const SignedPreKeySchema = z.object({
keyId: z.number(),
publicKey: z.string(),
signature: z.string(),
});
export type ServerSignedPreKey = z.infer<typeof SignedPreKeySchema>;
export const DeviceKeysSchema = z.object({
preKeys: PreKeySchema.array(),
pqPreKeys: SignedPreKeySchema.array().optional(),
pqLastResortPreKey: SignedPreKeySchema.optional(),
signedPreKey: SignedPreKeySchema.optional(),
});
export type DeviceKeys = z.infer<typeof DeviceKeysSchema>;
export const MessageSchema = z.object({
// NOTE: Envelope.Type
type: z.number(),
destinationDeviceId: DeviceIdSchema,
destinationRegistrationId: RegistrationIdSchema,
content: z.string(),
});
export type Message = z.infer<typeof MessageSchema>;
export const MessageListSchema = z.object({
messages: MessageSchema.array(),
timestamp: z.number(),
});
export type MessageList = z.infer<typeof MessageListSchema>;
export const AtomicLinkingDataSchema = z.object({
verificationCode: z.string(),
accountAttributes: z.object({
fetchesMessages: z.boolean(),
registrationId: RegistrationIdSchema,
pniRegistrationId: RegistrationIdSchema,
name: z.string(),
}),
aciSignedPreKey: SignedPreKeySchema,
pniSignedPreKey: SignedPreKeySchema,
aciPqLastResortPreKey: SignedPreKeySchema,
pniPqLastResortPreKey: SignedPreKeySchema,
});
export const UpdateProfileSchema = z.object({
commitment: z.string(),
version: z.string(),
name: z.string(),
aboutEmoji: z.string().nullish(),
about: z.string().nullish(),
paymentAddress: z.string().nullish(),
avatar: z.boolean(),
sameAvatar: z.boolean(),
badgeIds: z.array(z.string()).nullish(),
phoneNumberSharing: z.string().nullish(),
});
export type UploadProfileResponse =
| string
| undefined
| {
acl: string;
algorithm: string;
credential: string;
date: string;
key: string;
policy: string;
signature: string;
};
export const RegisterAccountSchema = z.object({
sessionId: z.string(),
recoveryPassword: z.string().optional(),
accountAttributes: z.object({
fetchesMessages: z.boolean(),
registrationId: RegistrationIdSchema,
pniRegistrationId: RegistrationIdSchema,
name: z.string().optional(),
capabilities: z.object({
attachmentBackfill: z.boolean(),
spqr: z.boolean(),
usernameChangeSyncMessage: z.boolean(),
}),
registrationLock: z.string().optional(),
unidentifiedAccessKey: z.array(z.number()),
unrestrictedUnidentifiedAccess: z.boolean(),
discoverableByPhoneNumber: z.boolean(),
recoveryPassword: z.string(),
phoneNumberIdentityRegistrationId: z.string().optional(),
}),
skipDeviceTransfer: z.boolean(),
aciIdentityKey: z.string(),
pniIdentityKey: z.string(),
aciSignedPreKey: SignedPreKeySchema,
pniSignedPreKey: SignedPreKeySchema,
aciPqLastResortPreKey: SignedPreKeySchema,
pniPqLastResortPreKey: SignedPreKeySchema,
apnToken: z
.object({
apnRegistrationId: z.string(),
})
.optional(),
gcmToken: z
.object({
gcmRegistrationId: z.string(),
})
.optional(),
});
export type RegisterAccountResponse = {
uuid: string;
number: string;
pni: string;
usernameHash?: string;
usernameLinkHandle?: string;
storageCapable: boolean;
entitlements: {
badges: Array<{
id: string;
expirationSeconds: number;
visible: boolean;
}>;
backup?: {
backupLevel: number;
expirationSeconds: number;
};
};
reregistration: boolean;
};
export const TransportSchema = z.literal('sms').or(z.literal('voice'));
export type Transport = z.infer<typeof TransportSchema>;
export const ClientTypeSchema = z
.literal('desktop')
.or(z.literal('ios'))
.or(z.literal('android'));
export const ModifyVerificationSessionSchema = z.object({
pushToken: z.string().optional(),
pushTokenType: z.string().optional(),
pushChallenge: z.string().optional(),
captcha: z.string().optional(),
mcc: z.string().optional(),
mnc: z.string().optional(),
});
export const CreateVerificationSessionSchema = z.object({
number: z.string(),
...ModifyVerificationSessionSchema.shape,
});
export type VerificationSession = {
id: string;
nextSms: number | null;
nextCall: number | null;
nextVerificationAttempt: number | null;
allowedToRequestCode: boolean;
requestedInformation: Array<'pushChallenges' | 'captcha'>;
verified: boolean;
};
export type VerificationSessionStorage = {
number: string;
session: VerificationSession;
lastRequestedCode?: string;
lastRequestedTransport?: Transport;
};
export const RequestVerificationCodeSchema = z.object({
transport: TransportSchema,
client: ClientTypeSchema,
});
export const SubmitVerificationCodeSchema = z.object({
code: z.string(),
});
export const GroupStateSchema = z.object({
publicKey: z.instanceof(Uint8Array),
version: z.literal(0),
accessControl: z.object({
attributes: z.number(),
members: z.number(),
addFromInviteLink: z.number(),
}),
members: z.unknown().array().min(1),
});
export const CreateCallLinkAuthSchema = z.object({
createCallLinkCredentialRequest: z.string().transform(fromBase64),
});
export type CreateCallLinkAuth = z.infer<typeof CreateCallLinkAuthSchema>;
export const CreateCallLinkSchema = z.object({
adminPasskey: z.string().transform(fromBase64),
zkparams: z.string().transform(fromBase64),
});
export type CreateCallLink = z.infer<typeof CreateCallLinkSchema>;
export const UpdateCallLinkSchema = z.object({
adminPasskey: z.string().transform(fromBase64),
name: z.string().optional(),
restrictions: z.enum(['none', 'adminApproval']).optional(),
revoked: z.boolean().optional(),
});
export type UpdateCallLink = z.infer<typeof UpdateCallLinkSchema>;
export const DeleteCallLinkSchema = z.object({
adminPasskey: z.string().transform(fromBase64),
});
export type DeleteCallLink = z.infer<typeof DeleteCallLinkSchema>;
export const SetBackupIdSchema = z.object({
messagesBackupAuthCredentialRequest: z.string().transform(fromBase64),
mediaBackupAuthCredentialRequest: z.string().transform(fromBase64),
});
export type SetBackupId = z.infer<typeof SetBackupIdSchema>;
export const BackupHeadersSchema = z.object({
'x-signal-zk-auth': z.string().transform(fromBase64),
'x-signal-zk-auth-signature': z.string().transform(fromBase64),
});
export type BackupHeaders = z.infer<typeof BackupHeadersSchema>;
export const SetBackupKeySchema = z.object({
backupIdPublicKey: z.string().transform(fromBase64),
});
export type SetBackupKey = z.infer<typeof SetBackupKeySchema>;
export const BackupMediaBatchSchema = z.object({
items: z
.object({
sourceAttachment: z.object({
cdn: z.number(),
key: z.string(),
}),
objectLength: z.number(),
mediaId: z.string(),
hmacKey: z.string().transform(fromBase64),
encryptionKey: z.string().transform(fromBase64),
})
.array(),
});
export type BackupMediaBatch = z.infer<typeof BackupMediaBatchSchema>;
export const UsernameConfirmationSchema = z.object({
usernameHash: z.string().transform(fromURLSafeBase64),
zkProof: z.string().transform(fromURLSafeBase64),
encryptedUsername: z.string().transform(fromURLSafeBase64),
});
+25
View File
@@ -0,0 +1,25 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
export { Group } from './api/group';
export { StorageState, StorageStateRecord } from './api/storage-state';
export { Server, Config } from './api/server';
export {
EncryptOptions,
PrimaryDevice,
ReceiptOptions,
ReceiptType,
SyncReadMessage,
SyncReadOptions,
SyncSentOptions,
EMPTY_DATA_MESSAGE,
EMPTY_GROUP_ACTIONS,
} from './api/primary-device';
export { Device, SingleUseKey } from './data/device';
export { EnvelopeType } from './server/base';
export {
signalservice as Proto,
signaling as SignalingProto,
} from '../protos/compiled';
export { load as loadCertificates, Certificates } from './data/certificates';
export { ServiceIdKind } from './types';
File diff suppressed because it is too large Load Diff
+38
View File
@@ -0,0 +1,38 @@
// Copyright 2026 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { CallData, CallDataOptions } from '../data/call';
import { CallingEraId, CallingRoomId, CallingUserId } from '../calling';
export type ServerCallOptions = Readonly<
CallDataOptions & {
roomId: CallingRoomId;
eraId: CallingEraId;
creatorUserId: CallingUserId;
}
>;
export class ServerCall extends CallData {
#roomId: CallingRoomId;
#eraId: CallingEraId;
#creatorUserId: CallingUserId;
constructor(options: ServerCallOptions) {
super(options);
this.#roomId = options.roomId;
this.#eraId = options.eraId;
this.#creatorUserId = options.creatorUserId;
}
public override get roomId(): CallingRoomId {
return this.#roomId;
}
public get eraId(): CallingEraId {
return this.#eraId;
}
public get creatorUserId(): CallingUserId {
return this.#creatorUserId;
}
}
+37
View File
@@ -0,0 +1,37 @@
// Copyright 2026 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import createDebug from 'debug';
import type { ServerRequest, ServerResponse } from 'microrouter';
import { send } from 'micro';
import { ParseAuthHeaderResult, parseAuthHeader } from '../util';
import type { Server } from './base';
import type { Device } from '../data/device';
const debug = createDebug('mock:server:base');
export function parsePassword(req: ServerRequest): ParseAuthHeaderResult {
return parseAuthHeader(req.headers.authorization);
}
export async function auth(
server: Server,
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);
void 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);
void send(res, 401, { error: 'Need re-provisioning' });
return;
}
return device;
}
+503
View File
@@ -0,0 +1,503 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import {
GroupPublicParams,
GroupSendDerivedKeyPair,
GroupSendEndorsementsResponse,
ProfileKeyCredentialPresentation,
ServerSecretParams,
ServerZkProfileOperations,
UuidCiphertext,
} from '@signalapp/libsignal-client/zkgroup';
import assert from 'assert';
import { signalservice as Proto } from '../../protos/compiled';
import { Group } from '../data/group';
import { GroupStateSchema } from '../data/schemas';
import { daysToSeconds, fromBase64, getTodayInSeconds } from '../util';
export type ServerGroupOptions = Readonly<{
profileOps: ServerZkProfileOperations;
zkSecret: ServerSecretParams;
state: Proto.Group.Params;
}>;
export type ModifyGroupResult = Readonly<
| {
conflict: false;
signedChange: Proto.GroupChange.Params;
}
| {
conflict: true;
signedChange: undefined;
}
>;
const { AccessRequired } = Proto.AccessControl;
const { Role } = Proto.Member;
function getTodaysKey(zkSecret: ServerSecretParams): GroupSendDerivedKeyPair {
const startOfDay = getTodayInSeconds();
const expiration = startOfDay + daysToSeconds(2);
return GroupSendDerivedKeyPair.forExpiration(
new Date(1000 * expiration),
zkSecret,
);
}
export class ServerGroup extends Group {
private readonly profileOps: ServerZkProfileOperations;
private readonly zkSecret: ServerSecretParams;
constructor({ profileOps, zkSecret, state }: ServerGroupOptions) {
super();
const parsedState = GroupStateSchema.parse(state);
this.privPublicParams = new GroupPublicParams(
Buffer.from(parsedState.publicKey),
);
this.profileOps = profileOps;
this.zkSecret = zkSecret;
const unrolledState = { ...state };
unrolledState.members = (state.members ?? []).map((member) =>
this.unrollMember(member),
);
this.privChanges = {
groupChanges: [
{
groupState: unrolledState,
groupChange: null,
},
],
groupSendEndorsementsResponse: null,
};
}
public getGroupSendEndorsementResponse(
sourceAci: UuidCiphertext,
): Uint8Array<ArrayBuffer> | null {
const authMember = this.getMember(sourceAci);
if (!authMember) {
return null;
}
const members = this.state.members ?? [];
const groupCiphertexts = members.map((member) => {
assert(member.userId, 'Member must have a user ID');
return new UuidCiphertext(Buffer.from(member.userId));
});
const todaysKey = getTodaysKey(this.zkSecret);
return GroupSendEndorsementsResponse.issue(
groupCiphertexts,
todaysKey,
).serialize();
}
public modify(
sourceAci: UuidCiphertext,
sourcePni: UuidCiphertext,
actions: Proto.GroupChange.Actions.Params,
): ModifyGroupResult {
const appliedActions: Proto.GroupChange.Actions.Params = {
version: actions.version,
sourceUserId: sourceAci.serialize(),
groupId: fromBase64(this.id),
addMembers: null,
deleteMembers: null,
modifyMemberRoles: null,
modifyMemberProfileKeys: null,
addMembersPendingProfileKey: null,
deleteMembersPendingProfileKey: null,
promoteMembersPendingProfileKey: null,
modifyTitle: null,
modifyAvatar: null,
modifyDisappearingMessageTimer: null,
modifyAttributesAccess: null,
modifyMemberAccess: null,
modifyAddFromInviteLinkAccess: null,
addMembersPendingAdminApproval: null,
deleteMembersPendingAdminApproval: null,
promoteMembersPendingAdminApproval: null,
modifyInviteLinkPassword: null,
modifyDescription: null,
modifyAnnouncementsOnly: null,
addMembersBanned: null,
deleteMembersBanned: null,
promoteMembersPendingPniAciProfileKey: null,
modifyMemberLabels: null,
modifyMemberLabelAccess: null,
terminateGroup: null,
};
assert.ok(actions.version, 'Actions should have a new version');
const timestamp = BigInt(Date.now());
const newState = {
...this.state,
version: actions.version,
};
const authMember = this.getMember(sourceAci);
const { accessControl } = newState;
let changeEpoch = 1;
if (actions.modifyTitle) {
this.verifyAccess(
'title',
authMember,
accessControl?.attributes ?? AccessRequired.UNKNOWN,
);
appliedActions.modifyTitle = actions.modifyTitle;
newState.title = actions.modifyTitle.title;
}
const deleteMembers = actions.deleteMembers ?? [];
for (const { deletedUserId } of deleteMembers) {
assert.ok(deletedUserId, 'Missing deletedUserId');
this.verifyAccess(
'members',
authMember,
accessControl?.members ?? AccessRequired.UNKNOWN,
deletedUserId,
);
const member = this.getMember(
new UuidCiphertext(Buffer.from(deletedUserId)),
);
assert.ok(member, 'Pending member not found for deletion');
newState.members = (newState.members ?? []).filter(
(entry) => entry !== member,
);
appliedActions.deleteMembers = [
...(appliedActions.deleteMembers ?? []),
{ deletedUserId },
];
}
const addMembersPendingProfileKey =
actions.addMembersPendingProfileKey ?? [];
for (const { added } of addMembersPendingProfileKey) {
assert.ok(added, 'Missing addPendingMember.added');
const { member } = added;
assert.ok(member, 'Missing addMembersPendingProfileKey.added.member');
const { userId, role } = member;
assert.ok(
userId,
'Missing addMembersPendingProfileKey.added.member.userId',
);
assert.ok(
role != null,
'Missing addMembersPendingProfileKey.added.member.role',
);
this.verifyAccess(
'pendingMembers',
authMember,
accessControl?.members ?? AccessRequired.UNKNOWN,
);
const newPendingMember = {
member: {
userId,
role,
profileKey: null,
presentation: null,
joinedAtVersion: null,
labelEmoji: null,
labelString: null,
},
addedByUserId: sourceAci.serialize(),
timestamp,
};
newState.membersPendingProfileKey = [
...(newState.membersPendingProfileKey ?? []),
newPendingMember,
];
appliedActions.addMembersPendingProfileKey = [
...(appliedActions.addMembersPendingProfileKey ?? []),
{ added: newPendingMember },
];
}
const deleteMembersPendingProfileKey =
actions.deleteMembersPendingProfileKey ?? [];
for (const { deletedUserId } of deleteMembersPendingProfileKey) {
assert.ok(deletedUserId, 'Missing deletedUserId');
assert.ok(
Buffer.from(deletedUserId).equals(sourceAci.serialize()) ||
Buffer.from(deletedUserId).equals(sourcePni.serialize()),
'Not a pending member',
);
const pendingMember = this.getPendingMember(
new UuidCiphertext(Buffer.from(deletedUserId)),
);
assert.ok(pendingMember, 'Pending member not found for deletion');
newState.membersPendingProfileKey = (
newState.membersPendingProfileKey ?? []
).filter((entry) => entry !== pendingMember);
appliedActions.deleteMembersPendingProfileKey = [
...(appliedActions.deleteMembersPendingProfileKey ?? []),
{ deletedUserId },
];
}
const promoteMembersPendingProfileKey =
actions.promoteMembersPendingProfileKey ?? [];
for (const { presentation } of promoteMembersPendingProfileKey) {
assert.ok(
presentation,
'Missing presentation in deleteMembersPendingProfileKey',
);
const presentationFFI = new ProfileKeyCredentialPresentation(
Buffer.from(presentation),
);
this.profileOps.verifyProfileKeyCredentialPresentation(
this.publicParams,
presentationFFI,
);
assert.ok(
Buffer.from(presentationFFI.getUuidCiphertext().serialize()).equals(
sourceAci.serialize(),
),
'Not a pending member',
);
const pendingMember = this.getPendingMember(
presentationFFI.getUuidCiphertext(),
);
assert.ok(pendingMember, 'No pending member');
assert.ok(
!this.getMember(presentationFFI.getUuidCiphertext()),
'Member is both pending and active',
);
newState.membersPendingProfileKey = (
newState.membersPendingProfileKey ?? []
).filter((entry) => entry !== pendingMember);
const userId = presentationFFI.getUuidCiphertext().serialize();
const profileKey = presentationFFI.getProfileKeyCiphertext().serialize();
newState.members = [
...(newState.members ?? []),
{
role: Role.DEFAULT,
userId,
profileKey,
presentation: null,
joinedAtVersion: null,
labelEmoji: null,
labelString: null,
},
];
appliedActions.promoteMembersPendingProfileKey = [
...(appliedActions.promoteMembersPendingProfileKey ?? []),
{ userId, profileKey, presentation: null },
];
}
const promotePNIMembers = actions.promoteMembersPendingPniAciProfileKey;
for (const { presentation } of promotePNIMembers ?? []) {
assert.ok(
presentation,
'Missing presentation in promoteMembersPendingPniAciProfileKey',
);
const presentationFFI = new ProfileKeyCredentialPresentation(
Buffer.from(presentation),
);
this.profileOps.verifyProfileKeyCredentialPresentation(
this.publicParams,
presentationFFI,
);
const aci = presentationFFI.getUuidCiphertext();
const pni = sourcePni;
const profileKey = presentationFFI.getProfileKeyCiphertext();
assert.ok(
Buffer.from(aci.serialize()).equals(sourceAci.serialize()),
'Not a pending member',
);
const pendingMember = this.getPendingMember(pni);
assert.ok(pendingMember, 'No pending pni member');
assert.ok(!this.getMember(aci), 'ACI is already a member');
newState.membersPendingProfileKey = (
newState.membersPendingProfileKey ?? []
).filter((entry) => entry !== pendingMember);
newState.members = [
...(newState.members ?? []),
{
role: Role.DEFAULT,
userId: aci.serialize(),
profileKey: profileKey.serialize(),
presentation: null,
joinedAtVersion: null,
labelEmoji: null,
labelString: null,
},
];
changeEpoch = Math.max(changeEpoch, 5);
appliedActions.sourceUserId = sourcePni.serialize();
appliedActions.promoteMembersPendingPniAciProfileKey = [
...(appliedActions.promoteMembersPendingPniAciProfileKey ?? []),
{
userId: aci.serialize(),
pni: pni.serialize(),
profileKey: profileKey.serialize(),
presentation: null,
},
];
}
if (actions.terminateGroup !== null) {
this.verifyAccess('terminated', authMember, AccessRequired.ADMINISTRATOR);
appliedActions.terminateGroup = {};
newState.terminated = true;
}
if (actions.modifyDisappearingMessageTimer !== null) {
this.verifyAccess(
'disappearingMessagesTimer',
authMember,
accessControl?.attributes ?? AccessRequired.UNKNOWN,
);
appliedActions.modifyDisappearingMessageTimer =
actions.modifyDisappearingMessageTimer;
newState.disappearingMessagesTimer =
actions.modifyDisappearingMessageTimer.timer;
}
const { version: oldVersion } = this.state;
assert.ok(
typeof oldVersion === 'number',
'Group must have existing version',
);
if (actions.version !== oldVersion + 1) {
return { conflict: true, signedChange: undefined };
}
const encodedActions = Proto.GroupChange.Actions.encode(appliedActions);
const serverSignature = this.zkSecret
.sign(Buffer.from(encodedActions))
.serialize();
const groupChange: Proto.GroupChange.Params = {
actions: encodedActions,
changeEpoch,
serverSignature: null,
};
assert.ok(this.privChanges?.groupChanges, 'Must be initialized');
this.privChanges.groupChanges.push({
groupChange,
groupState: newState,
});
return {
conflict: false,
signedChange: {
...groupChange,
serverSignature,
},
};
}
//
// Private
//
private verifyAccess(
attribute: string,
member: Proto.Member.Params | undefined,
access: Proto.AccessControl['attributes'],
affectedUserId?: Uint8Array<ArrayBuffer>,
): void {
// Changing something about ourselves is always allowed
if (
member?.userId &&
affectedUserId &&
Buffer.from(member.userId).equals(affectedUserId)
) {
return;
}
switch (access) {
case AccessRequired.ANY:
break;
case AccessRequired.MEMBER:
assert.ok(member, `Must be a member to access: ${attribute}`);
break;
case AccessRequired.ADMINISTRATOR:
assert.strictEqual(
member?.role,
Role.ADMINISTRATOR,
`Must be an administrator to modify: ${attribute}`,
);
break;
case AccessRequired.UNSATISFIABLE:
throw new Error(`Unsatisfiable access attribute: ${attribute}`);
case AccessRequired.UNKNOWN:
throw new Error(`Unknown access for attribute: ${attribute}`);
}
}
private unrollMember({
role,
presentation,
}: Proto.Member.Params): Proto.Member.Params {
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,
);
return {
role,
userId: presentationFFI.getUuidCiphertext().serialize(),
profileKey: presentationFFI.getProfileKeyCiphertext().serialize(),
presentation: null,
joinedAtVersion: null,
labelEmoji: null,
labelString: null,
};
}
}
+621
View File
@@ -0,0 +1,621 @@
// Copyright 2026 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import assert from 'assert';
import type { ServerResponse } from 'http';
import { Buffer } from 'buffer';
import createDebug from 'debug';
import { stringify as stringifyUuid, v4 as uuidv4 } from 'uuid';
import { RequestHandler, buffer, send as sendRaw } from 'micro';
import {
AugmentedRequestHandler as RouteHandler,
del,
get,
head,
options,
patch,
post,
put,
router,
type ServerRequest,
} from 'microrouter';
import { ServiceId, Aci, Pni } from '@signalapp/libsignal-client';
import SealedSenderMultiRecipientMessage from '@signalapp/libsignal-client/dist/SealedSenderMultiRecipientMessage';
import { BackupHeaders, Message } from '../data/schemas';
import type { Device } from '../data/device';
import { DeviceId, RegistrationId, ServiceIdString } from '../types';
import { $services, org, signalservice as Proto } from '../../protos/compiled';
import { AttachmentUploadForm, BackupAuthError, Server } from './base';
import { parsePassword } from './common';
const debug = createDebug('mock:grpc');
async function auth(
server: Server,
req: ServerRequest,
): Promise<Device | undefined> {
const { username, password, error } = parsePassword(req);
if (error) {
debug('%s %s auth failed, error %j', req.method, req.url, error);
return;
}
const device = await server.auth(username ?? '', password ?? '');
if (!device) {
debug('%s %s auth failed, need re-provisioning', req.method, req.url);
return;
}
return device;
}
const ALL_METHODS = [get, post, put, patch, del, head, options] as const;
function toServiceIdentifier(
string: ServiceIdString,
): org.signal.chat.common.ServiceIdentifier.Params {
const object = ServiceId.parseFromServiceIdString(string);
if (object instanceof Pni) {
return {
identityType: org.signal.chat.common.IdentityType.IDENTITY_TYPE_PNI,
uuid: object.getRawUuidBytes(),
};
}
if (object instanceof Aci) {
return {
identityType: org.signal.chat.common.IdentityType.IDENTITY_TYPE_ACI,
uuid: object.getRawUuidBytes(),
};
}
throw new Error(`Invalid service id: ${string}`);
}
function toBackupHeaders(
signedPresentation: org.signal.chat.backup.SignedPresentation | null,
): BackupHeaders {
if (signedPresentation == null) {
throw new Error('Missing signedPresentation');
}
return {
'x-signal-zk-auth': Buffer.from(signedPresentation.presentation),
'x-signal-zk-auth-signature': Buffer.from(
signedPresentation.presentationSignature,
),
};
}
// gRPC status codes used by the mock.
const GRPC_STATUS_OK = 0;
const GRPC_STATUS_UNKNOWN = 2;
const GRPC_STATUS_UNAUTHENTICATED = 16;
class GrpcAuthError extends Error {}
// A gRPC response over HTTP/2 always uses HTTP status 200; the actual gRPC
// status code is carried in the trailing HEADERS frame (`grpc-status`). `micro`
// has no notion of trailers, so we emit them via the HTTP/2 compat API. (Driving
// the raw stream directly conflicts with the Http2ServerResponse that `micro`
// holds and throws ERR_HTTP2_TRAILERS_ALREADY_SENT.)
function sendGrpcResponse(
res: ServerResponse,
body: Buffer,
status: number,
message?: string,
): void {
const trailers: Record<string, string> = {
'grpc-status': String(status),
};
if (message !== undefined) {
// Per the gRPC spec, `grpc-message` is percent-encoded.
trailers['grpc-message'] = encodeURIComponent(message);
}
res.writeHead(200, { 'content-type': 'application/grpc' });
res.addTrailers(trailers);
res.end(body);
}
type GrpcRequest<Endpoint extends keyof typeof $services> = ReturnType<
(typeof $services)[Endpoint]['Request']['decode']
>;
type GrpcResponse<Endpoint extends keyof typeof $services> = Parameters<
(typeof $services)[Endpoint]['Response']['encode']
>[0];
export const createHandler = (server: Server): RequestHandler => {
function grpcRoute<Endpoint extends keyof typeof $services>(
endpoint: Endpoint,
handler: (
request: GrpcRequest<Endpoint>,
device: Device | undefined,
) => Promise<GrpcResponse<Endpoint>>,
) {
const definition = $services[endpoint];
// TODO(indutny): enforce on type level
if (definition.isRequestStream || definition.isResponseStream) {
throw new Error(`Request/response stream is not supported`);
}
return post(`/${endpoint}`, async (httpReq, res) => {
try {
const raw = await buffer(httpReq);
assert(Buffer.isBuffer(raw));
assert(raw.buffer instanceof ArrayBuffer);
if (raw.length < 5) {
throw new Error('gRPC request is too short');
}
if (raw[0] !== 0) {
throw new Error('Unsupported request compression');
}
const len = raw.readUint32BE(1);
if (raw.length !== 5 + len) {
throw new Error('Invalid gRPC request size');
}
const grpcRequest = definition.Request.decode(
raw.subarray(5, 5 + len) as Uint8Array<ArrayBuffer>,
);
const device = await auth(server, httpReq);
const response = await handler(
grpcRequest as Parameters<typeof handler>[0],
device,
);
const data = (
definition.Response.encode as (
params: unknown,
) => Uint8Array<ArrayBuffer>
)(response);
const header = Buffer.alloc(5);
header.writeUint32BE(data.length, 1);
sendGrpcResponse(res, Buffer.concat([header, data]), GRPC_STATUS_OK);
} catch (error) {
debug('gRPC handler error for %s', endpoint, error);
sendGrpcResponse(
res,
Buffer.alloc(0),
error instanceof GrpcAuthError
? GRPC_STATUS_UNAUTHENTICATED
: GRPC_STATUS_UNKNOWN,
error instanceof Error ? error.message : String(error),
);
}
});
}
function authenticatedGrpcRoute<Endpoint extends keyof typeof $services>(
endpoint: Endpoint,
handler: (
grpcRequest: GrpcRequest<Endpoint>,
device: Device,
) => Promise<GrpcResponse<Endpoint>>,
) {
return grpcRoute(endpoint, async (grpcRequest, device) => {
if (!device) {
throw new GrpcAuthError('incorrect credentials');
}
return handler(grpcRequest, device);
});
}
async function onMultiRecipientMessage(
request:
| org.signal.chat.messages.SendMultiRecipientMessageRequest
| org.signal.chat.messages.SendMultiRecipientStoryRequest,
): Promise<org.signal.chat.messages.SendMultiRecipientMessageResponse.Params> {
const {
message: givenMessage,
// TODO(indutny): check it at all?
// groupSendToken,
} = request;
if (givenMessage == null) {
throw new Error('Missing message');
}
const { timestamp, payload } = givenMessage;
const message = new SealedSenderMultiRecipientMessage(Buffer.from(payload));
const listByServiceId = new Map<ServiceIdString, Array<Message>>();
const recipients = message.recipientsByServiceIdString();
for (const [serviceId, recipient] of Object.entries(recipients)) {
let list: Array<Message> | undefined = listByServiceId.get(
serviceId as ServiceIdString,
);
if (!list) {
list = [];
listByServiceId.set(serviceId as ServiceIdString, list);
}
for (const [i, deviceId] of recipient.deviceIds.entries()) {
const registrationId = recipient.registrationIds.at(i);
list.push({
type: Proto.Envelope.Type.UNIDENTIFIED_SENDER,
destinationDeviceId: deviceId as DeviceId,
destinationRegistrationId: registrationId as RegistrationId,
content: Buffer.from(message.messageForRecipient(recipient)).toString(
'base64',
),
});
}
}
const results = await Promise.all(
Array.from(listByServiceId.entries()).map(
async ([serviceId, messages]) => {
return {
uuid: serviceId,
prepared: await server.prepareMultiDeviceMessage(
undefined,
serviceId,
messages,
timestamp,
),
};
},
),
);
const mismatchedDevices = results.filter(({ prepared }) => {
return prepared.status === 'incomplete' || prepared.status === 'stale';
});
if (mismatchedDevices.length > 0) {
return {
response: {
mismatchedDevices: {
mismatchedDevices: mismatchedDevices.map(({ uuid, prepared }) => {
if (prepared.status === 'incomplete') {
return {
serviceIdentifier: toServiceIdentifier(uuid),
missingDevices: prepared.missingDevices.slice(),
extraDevices: prepared.extraDevices.slice(),
staleDevices: null,
};
}
assert.ok(prepared.status === 'stale');
return {
serviceIdentifier: toServiceIdentifier(uuid),
missingDevices: null,
extraDevices: null,
staleDevices: prepared.staleDevices.slice(),
};
}),
},
},
};
}
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 server.handlePreparedMultiDeviceMessage(
undefined,
prepared.targetServiceId,
prepared.result,
);
}),
);
return {
response: {
success: {
unresolvedRecipients: uuids404.map(toServiceIdentifier),
},
},
};
}
const onSendMultiRecipientMessage = grpcRoute(
'org.signal.chat.messages.MessagesAnonymous/SendMultiRecipientMessage',
onMultiRecipientMessage,
);
const onSendMultiRecipientStory = grpcRoute(
'org.signal.chat.messages.MessagesAnonymous/SendMultiRecipientStory',
onMultiRecipientMessage,
);
const onLookupUsernameHash = grpcRoute(
'org.signal.chat.account.AccountsAnonymous/LookupUsernameHash',
async ({ usernameHash }) => {
const uuid = await server.lookupByUsernameHash(Buffer.from(usernameHash));
if (!uuid) {
return {
response: {
notFound: {},
},
};
}
return {
response: {
serviceIdentifier: toServiceIdentifier(uuid),
},
};
},
);
const onLookupUsernameLink = grpcRoute(
'org.signal.chat.account.AccountsAnonymous/LookupUsernameLink',
async ({ usernameLinkHandle }) => {
const usernameCiphertext = await server.lookupByUsernameLink(
stringifyUuid(usernameLinkHandle),
);
if (!usernameCiphertext) {
return {
response: {
notFound: {},
},
};
}
return {
response: {
usernameCiphertext,
},
};
},
);
const onGetUploadForm = grpcRoute(
'org.signal.chat.attachments.Attachments/GetUploadForm',
async () => {
const { cdn, key, headers, signedUploadLocation } =
await server.getAttachmentUploadForm('attachments', uuidv4());
return {
outcome: {
uploadForm: {
cdn,
key,
headers: new Map(Object.entries(headers)),
signedUploadLocation,
},
},
};
},
);
const onSetBackupPublicKey = grpcRoute(
'org.signal.chat.backup.BackupsAnonymous/SetPublicKey',
async ({ signedPresentation, publicKey }) => {
try {
await server.setBackupKey(toBackupHeaders(signedPresentation), {
backupIdPublicKey: Buffer.from(publicKey),
});
} catch (error) {
if (error instanceof BackupAuthError) {
return {
response: { failedAuthentication: { description: error.message } },
};
}
throw error;
}
return { response: { success: {} } };
},
);
const onRefreshBackup = grpcRoute(
'org.signal.chat.backup.BackupsAnonymous/Refresh',
async ({ signedPresentation }) => {
try {
await server.refreshBackup(toBackupHeaders(signedPresentation));
} catch (error) {
if (error instanceof BackupAuthError) {
return {
response: { failedAuthentication: { description: error.message } },
};
}
throw error;
}
return { response: { success: {} } };
},
);
const onGetBackupCdnCredentials = grpcRoute(
'org.signal.chat.backup.BackupsAnonymous/GetCdnCredentials',
async ({ signedPresentation, cdn }) => {
if (cdn !== 3) {
throw new Error(`Invalid cdn: ${cdn}`);
}
let cdnHeaders: Record<string, string>;
try {
cdnHeaders = await server.getBackupCDNAuth(
toBackupHeaders(signedPresentation),
);
} catch (error) {
if (error instanceof BackupAuthError) {
return {
response: { failedAuthentication: { description: error.message } },
};
}
throw error;
}
return {
response: {
cdnCredentials: { headers: new Map(Object.entries(cdnHeaders)) },
},
};
},
);
const onGetBackupUploadForm = grpcRoute(
'org.signal.chat.backup.BackupsAnonymous/GetUploadForm',
async ({ signedPresentation, uploadType }) => {
if (uploadType == null) {
throw new Error('Missing uploadType');
}
const headers = toBackupHeaders(signedPresentation);
let form: AttachmentUploadForm;
try {
if (uploadType.messages != null) {
form = await server.getBackupUploadForm(headers);
} else {
form = await server.getBackupMediaUploadForm(headers);
}
} catch (error) {
if (!(error instanceof BackupAuthError)) {
throw error;
}
return {
response: { failedAuthentication: { description: error.message } },
};
}
return {
response: {
uploadForm: {
cdn: form.cdn,
key: form.key,
headers: new Map(Object.entries(form.headers)),
signedUploadLocation: form.signedUploadLocation,
},
},
};
},
);
const onReserveUsername = authenticatedGrpcRoute(
'org.signal.chat.account.Accounts/ReserveUsernameHash',
async ({ usernameHashes }, device) => {
const usernameHash = await server.reserveUsername(device.aci, {
usernameHashes,
});
if (!usernameHash) {
return {
response: {
usernameNotAvailable: {},
},
};
}
return {
response: {
usernameHash,
},
};
},
);
const onConfirmUsername = authenticatedGrpcRoute(
'org.signal.chat.account.Accounts/ConfirmUsernameHash',
async (body, device) => {
const result = await server.confirmUsername(device.aci, body);
if (!result) {
return {
response: {
reservationNotFound: {
description:
"Given username hash doesn't match the reserved one or no reservation found.",
},
},
};
}
return {
response: {
confirmedUsernameHash: result,
},
};
},
);
const onDeleteUsername = authenticatedGrpcRoute(
'org.signal.chat.account.Accounts/DeleteUsernameHash',
async (_body, device) => {
await server.deleteUsername(device.aci);
return {};
},
);
const onSetUsernameLink = authenticatedGrpcRoute(
'org.signal.chat.account.Accounts/SetUsernameLink',
async ({ usernameCiphertext, keepLinkHandle }, device) => {
const usernameLinkHandle = await server.replaceUsernameLink(
device.aci,
usernameCiphertext,
{ keepLinkHandle },
);
return {
response: {
usernameLinkHandle,
},
};
},
);
const notFoundAfterAuth: RouteHandler = async (req, res) => {
const device = await auth(server, req);
if (!device) {
return sendRaw(res, 401, { error: 'Not authorized' });
}
debug('Unsupported request %s %s', req.method, req.url);
return sendRaw(res, 404, { error: 'Not supported yet' });
};
const routes = router(
// gRPC
onSendMultiRecipientMessage,
onSendMultiRecipientStory,
onLookupUsernameHash,
onLookupUsernameLink,
onGetUploadForm,
onReserveUsername,
onConfirmUsername,
onDeleteUsername,
onSetUsernameLink,
onSetBackupPublicKey,
onRefreshBackup,
onGetBackupCdnCredentials,
onGetBackupUploadForm,
...ALL_METHODS.map((method) => method('/*', notFoundAfterAuth)),
);
return (req, res) => {
debug('got request %s %s', req.method, req.url);
try {
res.once('finish', () => {
debug('response %s %s', req.method, req.url, res.statusCode);
});
return routes(req, res);
} catch (error) {
assert(error instanceof Error);
debug('request failure %s %s', req.method, req.url, error.stack);
return sendRaw(res, 500, error.message);
}
};
};
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,4 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
export { Connection } from './connection';
@@ -0,0 +1,162 @@
// 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 { JsonValue, PartialDeep } from 'type-fest';
import URLPattern from 'url-pattern';
import { assertJsonValue } from '../../util';
import assert from 'assert';
const debug = createDebug('mock:ws:router');
export type AbbreviatedResponse = Readonly<
| [number, PartialDeep<JsonValue>]
| [number, PartialDeep<JsonValue>, Record<string, string>]
>;
export type Handler = (
params: Record<string, string>,
body: Uint8Array<ArrayBuffer> | undefined,
headers: Record<string, string>,
query?: ParsedUrlQuery,
) => Promise<AbbreviatedResponse>;
type Route = Readonly<{
method: string;
pattern: URLPattern;
handler: Handler;
}>;
export type RouterOptions = Readonly<{
beforeRequest: (
verb: string,
path: string,
headers: Record<string, string>,
) => Promise<void>;
}>;
export class Router {
private readonly routes: Array<Route> = [];
private isAuthenticated = false;
constructor(private options: RouterOptions) {}
public register(method: string, pattern: string, handler: Handler): void {
this.routes.push({
method,
pattern: new URLPattern(pattern, {
segmentValueCharset: ':a-zA-Z0-9-_~ %',
}),
handler,
});
}
public get(pattern: string, handler: Handler): void {
this.register('GET', pattern, handler);
}
public patch(pattern: string, handler: Handler): void {
this.register('PATCH', pattern, handler);
}
public put(pattern: string, handler: Handler): void {
this.register('PUT', pattern, handler);
}
public post(pattern: string, handler: Handler): void {
this.register('POST', pattern, handler);
}
public del(pattern: string, handler: Handler): void {
this.register('DELETE', 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);
assert(field != null, 'Missing field name for header');
headers[field.toLowerCase()] = value;
}
let response: AbbreviatedResponse = [404, { error: 'Not found' }];
debug(
'got request %s %s %s',
this.isAuthenticated ? '(auth)' : '(unauth)',
request.verb,
request.path,
);
const { pathname, query } = parseURL(request.path ?? '');
await this.options.beforeRequest(
request.verb ?? '',
pathname ?? '',
headers,
);
for (const { method, pattern, handler } of this.routes) {
if (method !== request.verb) {
continue;
}
const params: unknown = pattern.match(pathname ?? '');
if (params == null) {
continue;
}
const decodedParams: Record<string, string> = {};
for (const [key, value] of Object.entries(params)) {
decodedParams[key] = decodeURIComponent(String(value));
}
response = await handler(
decodedParams,
request.body ?? undefined,
headers,
query === null ? undefined : parseQS(query),
);
break;
}
const [status, json, responseHeaders = {}] = response;
debug('response %s %s status=%d', request.verb, request.path, status);
const timestampHeader = `X-Signal-Timestamp:${Date.now()}`;
const replyHeaders = [timestampHeader].concat(
Object.entries(responseHeaders).map(
([name, value]) => `${name}:${value}`,
),
);
if (json instanceof Uint8Array) {
return {
id: request.id,
status,
message: null,
headers: ['Content-Type:application/x-protobuf'].concat(replyHeaders),
body: Buffer.from(json),
};
}
assertJsonValue(json);
return {
id: request.id,
status,
message: null,
headers: replyHeaders.concat(['Content-Type:application/json']),
body: Buffer.from(JSON.stringify(json)),
};
}
public setIsAuthenticated(value: boolean): void {
this.isAuthenticated = value;
}
}
@@ -0,0 +1,142 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import assert from 'assert';
import WebSocket from 'ws';
import createDebug from 'debug';
import { signalservice as SignalService } from '../../../protos/compiled';
export type WSRequest = SignalService.WebSocketRequestMessage.Params;
export type WSResponse = SignalService.WebSocketResponseMessage.Params;
const debug = createDebug('mock:ws:service');
const WSMessage = SignalService.WebSocketMessage;
interface RequestOptions {
readonly body?: Uint8Array<ArrayBuffer>;
readonly headers?: Array<string> | null;
}
export abstract class Service {
private readonly requests = new Map<bigint, (res: WSResponse) => void>();
private lastSentId = 0n;
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: {
headers: options.headers ?? null,
body: options.body ?? null,
verb,
path,
id,
},
response: null,
});
this.ws.send(packet);
return 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');
}
// @ts-expect-error -- Can't refine to Uint8Array<ArrayBuffer>
const message = WSMessage.decode(raw);
if (message.type === WSMessage.Type.RESPONSE) {
const response = message.response;
if (!response) {
throw new Error('Expected response in message');
}
const id = response.id ?? 0n;
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');
}
let response: WSResponse;
try {
response = await this.handleRequest(request);
} catch (error) {
assert(error instanceof Error);
console.error('handleRequest error', error.stack);
response = {
id: request.id,
status: 500,
message: null,
headers: null,
body: Buffer.from(
JSON.stringify({
error: error.stack,
}),
),
};
}
// Keepalive responses
const packet = WSMessage.encode({
type: WSMessage.Type.RESPONSE,
request: null,
response: {
...response,
id: request.id,
},
});
this.ws.send(packet);
} else {
debug('unsupported message', message);
}
}
private onClose(): void {
for (const [id, resolve] of this.requests.entries()) {
resolve({
id,
status: 500,
message: 'WebSocket is gone',
headers: null,
body: null,
});
}
}
protected abstract handleRequest(request: WSRequest): Promise<WSResponse>;
}
+222
View File
@@ -0,0 +1,222 @@
// Copyright 2026 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { CallData, CallDataOptions } from '../data/call';
import {
CallInfo,
CallInfoClient,
CallingDemuxId,
CallingEraId,
CallingUserId,
} from '../calling';
export enum SfuClientStatus {
Active = 'ACTIVE',
Pending = 'PENDING',
Blocked = 'BLOCKED',
Rejected = 'REJECTED',
}
export type SfuClient = Readonly<{
userId: CallingUserId;
demuxId: CallingDemuxId;
isAdmin: boolean;
}>;
function toCallInfoClients(
clients: ReadonlyArray<SfuClient>,
): ReadonlyArray<CallInfoClient> {
return clients.map((client) => {
return {
demuxId: client.demuxId,
opaqueUserId: client.userId,
};
});
}
type TakeClientsResult = Readonly<{
userId: CallingUserId;
matches: Array<SfuClient>;
remaining: Array<SfuClient>;
}>;
function takeClients(
existing: ReadonlyArray<SfuClient>,
demuxId: CallingDemuxId,
): TakeClientsResult | null {
const found = existing.find((client) => client.demuxId === demuxId);
if (found == null) {
return null;
}
const { userId } = found;
const matches: Array<SfuClient> = [];
const remaining: Array<SfuClient> = [];
for (const client of existing) {
if (client.demuxId === demuxId) {
matches.push(client);
} else {
remaining.push(client);
}
}
return { userId, matches, remaining };
}
export type SfuCallOptions = Readonly<
CallDataOptions & {
eraId: CallingEraId;
creatorUserId: CallingUserId;
maxClients: number;
newClientsRequireApproval: boolean;
persistApprovalForAllUsersWhoJoin: boolean;
approvedUsers: ReadonlyArray<CallingUserId> | null;
}
>;
export class SfuCall extends CallData {
#eraId: CallingEraId;
#creatorUserId: CallingUserId;
#maxClients: number;
#newClientsRequireApproval: boolean;
#persistApprovalForAllUsersWhoJoin: boolean;
#activeClients: Array<SfuClient> = [];
#pendingClients: Array<SfuClient> = [];
#removedClients: Array<SfuClient> = [];
#blockedUsers = new Set<string>();
#deniedUsers = new Set<string>();
#approvedUsers: Set<CallingUserId>;
constructor(options: SfuCallOptions) {
super(options);
this.#eraId = options.eraId;
this.#creatorUserId = options.creatorUserId;
this.#maxClients = options.maxClients;
this.#newClientsRequireApproval = options.newClientsRequireApproval;
this.#persistApprovalForAllUsersWhoJoin =
options.persistApprovalForAllUsersWhoJoin;
this.#approvedUsers = new Set<CallingUserId>(options.approvedUsers);
}
public get eraId(): CallingEraId {
return this.#eraId;
}
public getInfo(includePendingClients: boolean): CallInfo {
const activeClients = toCallInfoClients(this.#activeClients);
const pendingClients = includePendingClients
? toCallInfoClients(this.#pendingClients)
: null;
return {
eraId: this.eraId,
maxClients: this.#maxClients,
creatorUserId: this.#creatorUserId,
activeClients,
pendingClients,
};
}
public isAdmin(userId: string): boolean {
return this.#activeClients.some((client) => {
return client.userId === userId && client.isAdmin;
});
}
public addClient(client: SfuClient): SfuClientStatus {
const count = this.#activeClients.length + this.#pendingClients.length;
if (count >= this.#maxClients) {
return SfuClientStatus.Rejected;
}
if (this.#blockedUsers.has(client.userId)) {
this.#removedClients.push(client);
return SfuClientStatus.Blocked;
}
const canAutoJoin =
client.isAdmin ||
!this.#newClientsRequireApproval ||
this.#approvedUsers.has(client.userId);
if (canAutoJoin) {
if (this.#persistApprovalForAllUsersWhoJoin) {
this.#approvedUsers.add(client.userId);
}
this.#activeClients.push(client);
return SfuClientStatus.Active;
} else {
this.#pendingClients.push(client);
return SfuClientStatus.Pending;
}
}
public hasClient(demuxId: CallingDemuxId): boolean {
return (
this.#activeClients.some((client) => client.demuxId === demuxId) ||
this.#pendingClients.some((client) => client.demuxId === demuxId) ||
this.#removedClients.some((client) => client.demuxId === demuxId)
);
}
public approvePendingDemuxId(demuxId: CallingDemuxId): void {
const result = takeClients(this.#pendingClients, demuxId);
if (result != null) {
this.#pendingClients = result.remaining;
for (const client of result.matches) {
this.#activeClients.push(client);
}
this.#deniedUsers.delete(result.userId);
this.#approvedUsers.add(result.userId);
}
}
public denyPendingDemuxId(demuxId: CallingDemuxId): void {
const result = takeClients(this.#pendingClients, demuxId);
if (result != null) {
this.#pendingClients = result.remaining;
for (const client of result.matches) {
this.#removedClients.push(client);
}
const isDenied = this.#deniedUsers.has(result.userId);
if (isDenied) {
this.#blockedUsers.add(result.userId);
} else {
this.#deniedUsers.add(result.userId);
}
}
}
public dropDemuxId(demuxId: CallingDemuxId): void {
const activeClients = takeClients(this.#activeClients, demuxId);
const pendingClients = takeClients(this.#pendingClients, demuxId);
const removedClients = takeClients(this.#removedClients, demuxId);
if (activeClients != null) {
this.#activeClients = activeClients.remaining;
}
if (pendingClients != null) {
this.#pendingClients = pendingClients.remaining;
}
if (removedClients != null) {
this.#removedClients = removedClients.remaining;
}
}
public blockDemuxId(demuxId: CallingDemuxId): void {
const result = takeClients(this.#activeClients, demuxId);
if (result != null) {
this.#activeClients = result.remaining;
for (const client of result.matches) {
this.#removedClients.push(client);
}
this.#approvedUsers.delete(result.userId);
this.#blockedUsers.add(result.userId);
}
}
}
+18
View File
@@ -0,0 +1,18 @@
// Copyright 2026 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
export type IpAddress = string & { IpAddress: never };
export type Hostname = string & { Hostname: never };
export type Port = number & { Port: never };
export type MediaPorts = Readonly<{
udp: Port;
tcp: Port;
tls: Port | null;
}>;
export type ServerMediaAddress = Readonly<{
addresses: ReadonlyArray<IpAddress>;
ports: MediaPorts;
hostname: Hostname | null;
}>;
@@ -0,0 +1,75 @@
// Copyright 2026 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { CallingDemuxId, CallingEraId } from '../calling';
import { IcePassword } from './ice';
import { StrpKeyMaterial } from './srtp';
export type SfuConnectionId = `${CallingEraId}:${CallingDemuxId}` & {
SfuConnectionId: never;
};
export function getSfuConnectionId(params: {
eraId: CallingEraId;
demuxId: CallingDemuxId;
}): SfuConnectionId {
return `${params.eraId}:${params.demuxId}` as SfuConnectionId;
}
export type SfuConnectionOptions = Readonly<{
connectionId: SfuConnectionId;
demuxId: CallingDemuxId;
serverIceUsername: string;
clientIceUsername: string;
serverIcePassword: IcePassword;
clientIcePassword: IcePassword;
strpKeyMaterial: StrpKeyMaterial;
}>;
export class SfuConnection {
#connectionId: SfuConnectionId;
#demuxId: CallingDemuxId;
#serverIceUsername: string;
#clientIceUsername: string;
#serverIcePassword: IcePassword;
#clientIcePassword: IcePassword;
#strpKeyMaterial: StrpKeyMaterial;
constructor(options: SfuConnectionOptions) {
this.#connectionId = options.connectionId;
this.#demuxId = options.demuxId;
this.#serverIceUsername = options.serverIceUsername;
this.#clientIceUsername = options.clientIceUsername;
this.#serverIcePassword = options.serverIcePassword;
this.#clientIcePassword = options.clientIcePassword;
this.#strpKeyMaterial = options.strpKeyMaterial;
}
get connectionId(): SfuConnectionId {
return this.#connectionId;
}
get demuxId(): CallingDemuxId {
return this.#demuxId;
}
get serverIceUsername(): string {
return this.#serverIceUsername;
}
get clientIceUsername(): string {
return this.#clientIceUsername;
}
get serverIcePassword(): IcePassword {
return this.#serverIcePassword;
}
get clientIcePassword(): IcePassword {
return this.#clientIcePassword;
}
get strpKeyMaterial(): StrpKeyMaterial {
return this.#strpKeyMaterial;
}
}
+118
View File
@@ -0,0 +1,118 @@
// Copyright 2026 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import {
createPrivateKey,
createPublicKey,
diffieHellman,
generateKeyPair,
KeyObject,
} from 'node:crypto';
import { promisify } from 'node:util';
/**
* These classses are a reimplementation of libsignal's PublicKey/PrivateKey
* because they expect a tag at the start of their bytes.
*/
const generateKeyPairAsync = promisify(generateKeyPair);
export class CallingPrivateKey {
#privateKey: KeyObject;
constructor(privateKey: KeyObject) {
this.#privateKey = privateKey;
}
static getPrivateKeyObject(privateKey: CallingPrivateKey): KeyObject {
return privateKey.#privateKey;
}
static fromBytes(bytes: Uint8Array<ArrayBuffer>): CallingPrivateKey {
return new CallingPrivateKey(
// @ts-expect-error @types/node for 24 doesn't have raw-private
createPrivateKey({
key: bytes,
format: 'raw-private',
asymmetricKeyType: 'x25519',
}),
);
}
public toBytes(): Uint8Array<ArrayBuffer> {
return this.#privateKey.export({
// @ts-expect-error @types/node for 24 doesn't have raw-private
format: 'raw-private',
});
}
public agree(publicKey: CallingPublicKey): Uint8Array<ArrayBuffer> {
return diffieHellman({
privateKey: this.#privateKey,
publicKey: CallingPublicKey.getPublicKeyObject(publicKey),
});
}
}
export class CallingPublicKey {
#publicKey: KeyObject;
constructor(publicKey: KeyObject) {
this.#publicKey = publicKey;
}
static getPublicKeyObject(publicKey: CallingPublicKey): KeyObject {
return publicKey.#publicKey;
}
static fromBytes(bytes: Uint8Array<ArrayBuffer>): CallingPublicKey {
return new CallingPublicKey(
// @ts-expect-error @types/node for 24 doesn't have raw-public
createPublicKey({
key: bytes,
format: 'raw-public',
asymmetricKeyType: 'x25519',
}),
);
}
getKeyObject(): KeyObject {
return this.#publicKey;
}
toBytes(): Uint8Array<ArrayBuffer> {
return this.#publicKey.export({
// @ts-expect-error @types/node for 24 doesn't have raw-public
format: 'raw-public',
});
}
}
export class CallingKeyPair {
#privateKey: CallingPrivateKey;
#publicKey: CallingPublicKey;
private constructor(params: {
privateKey: CallingPrivateKey;
publicKey: CallingPublicKey;
}) {
this.#privateKey = params.privateKey;
this.#publicKey = params.publicKey;
}
get privateKey(): CallingPrivateKey {
return this.#privateKey;
}
get publicKey(): CallingPublicKey {
return this.#publicKey;
}
static async generate(): Promise<CallingKeyPair> {
const keys = await generateKeyPairAsync('x25519');
return new CallingKeyPair({
privateKey: new CallingPrivateKey(keys.privateKey),
publicKey: new CallingPublicKey(keys.publicKey),
});
}
}
+87
View File
@@ -0,0 +1,87 @@
// Copyright 2026 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import assert from 'node:assert';
import { randomBytes } from 'node:crypto';
import z from 'zod';
function getRandomBase64String(size: number): string {
assert(size % 4 === 0, 'Must be multiple of 4');
const byteLength = (size * 6) / 8;
const bytes = randomBytes(byteLength);
const base64 = bytes.toString('base64');
assert(base64.length === size, 'Must be exact length');
return base64;
}
export type IceUsernameFragment = string & {
IceUsernameFragment: never;
};
export const ICE_USERNAME_FRAGMENT_SIZE = 4;
export function getRandomIceUsernameFragment(): IceUsernameFragment {
return getRandomBase64String(
ICE_USERNAME_FRAGMENT_SIZE,
) as IceUsernameFragment;
}
export const IceUsernameFragmentSchema = z
.string()
.min(4)
.max(256)
.transform((input) => input as IceUsernameFragment);
export type IceUsername = `${IceUsernameFragment}:${IceUsernameFragment}` & {
IceUsername: never;
};
export type IceUsernamesParams = Readonly<{
serverIceUsernameFragment: IceUsernameFragment;
clientIceUsernameFragment: IceUsernameFragment;
}>;
export type IceUsernames = Readonly<{
serverIceUsername: IceUsername;
clientIceUsername: IceUsername;
}>;
function toIceUsername(
a: IceUsernameFragment,
b: IceUsernameFragment,
): IceUsername {
return `${a}:${b}` as IceUsername;
}
export function getIceUsernames(params: IceUsernamesParams): IceUsernames {
const serverIceUsername = toIceUsername(
params.serverIceUsernameFragment,
params.clientIceUsernameFragment,
);
const clientIceUsername = toIceUsername(
params.clientIceUsernameFragment,
params.serverIceUsernameFragment,
);
return { serverIceUsername, clientIceUsername };
}
export function getClientIceUsername(params: {
serverIceUsernameFragment: IceUsernameFragment;
clientIceUsernameFragment: IceUsernameFragment;
}): IceUsername {
return `${params.clientIceUsernameFragment}:${params.serverIceUsernameFragment}` as IceUsername;
}
export type IcePassword = string & { IcePassword: never };
export const ICE_PASSWORD_SIZE = 32;
export function getRandomIcePassword(): IcePassword {
return getRandomBase64String(ICE_PASSWORD_SIZE) as IcePassword;
}
export const IcePasswordSchema = z
.string()
.min(22)
.max(256)
.transform((input) => input as IcePassword);
+151
View File
@@ -0,0 +1,151 @@
// Copyright 2026 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { SfuCall, SfuClientStatus } from './call';
import {
CallInfo,
CallingDemuxId,
CallingEraId,
CallingError,
CallingErrorCode,
CallingRoomId,
CallingUserId,
CallType,
} from '../calling';
import {
getSfuConnectionId,
SfuConnection,
SfuConnectionId,
} from './connection';
import { getStrpKeyMaterial } from './srtp';
import { getIceUsernames, IcePassword, IceUsernameFragment } from './ice';
import { CallingKeyPair, CallingPublicKey } from './crypto';
export type SfuJoinCallRequest = Readonly<{
eraId: CallingEraId;
roomId: CallingRoomId | null;
userId: CallingUserId;
demuxId: CallingDemuxId;
clientIceUsernameFragment: IceUsernameFragment;
clientIcePassword: IcePassword;
clientPublicKey: CallingPublicKey;
clientHkdfExtraInfo: Uint8Array<ArrayBuffer> | null;
serverIceUsernameFragment: IceUsernameFragment;
serverIcePassword: IcePassword;
callType: CallType;
isAdmin: boolean;
newClientsRequireApproval: boolean;
approvedUsers: ReadonlyArray<CallingUserId> | null;
}>;
export type SfuJoinCallResponse = Readonly<{
serverPublicKey: CallingPublicKey;
clientStatus: SfuClientStatus;
}>;
export type SfuPeekCallRequest = Readonly<{
eraId: CallingEraId;
userId: CallingUserId;
}>;
export type SfuPeekCallResponse = Readonly<{
info: CallInfo;
}>;
/**
* Selective Forwarding Unit
*/
export class SfuService {
#calls = new Map<CallingEraId, SfuCall>();
#connections = new Map<SfuConnectionId, SfuConnection>();
public async joinCall(
request: SfuJoinCallRequest,
): Promise<SfuJoinCallResponse> {
let call = this.#calls.get(request.eraId);
if (call == null) {
call = new SfuCall({
creatorUserId: request.userId,
roomId: request.roomId,
eraId: request.eraId,
maxClients: 30,
newClientsRequireApproval: request.newClientsRequireApproval,
persistApprovalForAllUsersWhoJoin: true,
approvedUsers: request.approvedUsers,
});
this.#calls.set(call.eraId, call);
}
if (call.hasClient(request.demuxId)) {
throw new CallingError(CallingErrorCode.DuplicateDemuxIdDetected);
}
const { serverIceUsername, clientIceUsername } = getIceUsernames({
serverIceUsernameFragment: request.serverIceUsernameFragment,
clientIceUsernameFragment: request.clientIceUsernameFragment,
});
const clientStatus = call.addClient({
userId: request.userId,
demuxId: request.demuxId,
isAdmin: request.isAdmin,
});
if (clientStatus === SfuClientStatus.Rejected) {
throw new CallingError(CallingErrorCode.TooManyClients);
}
const serverKeys = await CallingKeyPair.generate();
const serverSecret = serverKeys.privateKey;
const serverPublicKey = serverKeys.publicKey;
const sharedSecret = serverSecret.agree(request.clientPublicKey);
const strpKeyMaterial = getStrpKeyMaterial({
sharedSecret,
clientHkdfExtraInfo: request.clientHkdfExtraInfo,
});
const connectionId = getSfuConnectionId({
eraId: call.eraId,
demuxId: request.demuxId,
});
const connection = new SfuConnection({
connectionId,
demuxId: request.demuxId,
serverIceUsername,
clientIceUsername,
serverIcePassword: request.serverIcePassword,
clientIcePassword: request.clientIcePassword,
strpKeyMaterial,
});
this.#connections.set(connectionId, connection);
return {
serverPublicKey,
clientStatus,
};
}
public async peekCall(
request: SfuPeekCallRequest,
): Promise<SfuPeekCallResponse> {
const call = this.#calls.get(request.eraId);
if (call == null) {
throw new CallingError(CallingErrorCode.CallNotFound);
}
const includePendingUserIds = call.isAdmin(request.userId);
const info = call.getInfo(includePendingUserIds);
return { info };
}
}
+89
View File
@@ -0,0 +1,89 @@
// Copyright 2026 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { hkdf } from '@signalapp/libsignal-client';
const KEY_LABEL = Buffer.from(
'Signal_Group_Call_20211105_SignallingDH_SRTPKey_KDF',
);
const KEY_LENGTH = 16;
const SALT_LENGTH = 12;
// In the order [client_key, client_salt, server_key, server_salt]
const KEY_MATERIAL_LENGTH = KEY_LENGTH + SALT_LENGTH + KEY_LENGTH + SALT_LENGTH;
export type StrpKeyMaterial = Uint8Array<ArrayBuffer> & {
StrpKeyMaterial: Uint8Array<ArrayBuffer>;
};
export function getStrpKeyMaterial(params: {
sharedSecret: Uint8Array<ArrayBuffer>;
clientHkdfExtraInfo: Uint8Array<ArrayBuffer> | null;
}): StrpKeyMaterial {
const clientHkdfExtraInfo = params.clientHkdfExtraInfo ?? Buffer.alloc(0);
const keyMaterial = hkdf(
KEY_MATERIAL_LENGTH,
params.sharedSecret,
Buffer.concat([KEY_LABEL, clientHkdfExtraInfo]),
null,
);
return keyMaterial as StrpKeyMaterial;
}
type Key = Uint8Array<ArrayBuffer> & { Key: never };
type Salt = Uint8Array<ArrayBuffer> & { Salt: never };
type KeyPair = Readonly<{
key: Key;
salt: Salt;
}>;
type KeyPairs = Readonly<{
rtp: KeyPair;
rtcp: KeyPair;
}>;
type ClientAndServer = Readonly<{
client: KeyPairs; // decrypt
server: KeyPairs; // encrypt
}>;
export function deriveStrpClientAndServer(
keyMaterial: StrpKeyMaterial,
): ClientAndServer {
const mid = KEY_LENGTH + SALT_LENGTH;
return {
client: deriveKeyPairs({
key: keyMaterial.subarray(0, KEY_LENGTH) as Key,
salt: keyMaterial.subarray(KEY_LENGTH, mid) as Salt,
}),
server: deriveKeyPairs({
key: keyMaterial.subarray(mid, mid + KEY_LENGTH) as Key,
salt: keyMaterial.subarray(mid + KEY_LENGTH) as Salt,
}),
};
}
function deriveKeyPairs(master: KeyPair): KeyPairs {
return {
rtp: {
key: deriveKey(master, 0),
salt: deriveSalt(master, 2),
},
rtcp: {
key: deriveKey(master, 3),
salt: deriveSalt(master, 5),
},
};
}
function deriveKey(_keyPair: KeyPair, _label: number): Key {
throw new Error('unimplemented');
}
function deriveSalt(_keyPair: KeyPair, _label: number): Salt {
throw new Error('unimplemented');
}
+46
View File
@@ -0,0 +1,46 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { KEMPublicKey, PublicKey } from '@signalapp/libsignal-client';
export type AciString = string & { __aci: never };
export type PniString = string & { __pni: never };
export type UntaggedPniString = string & { __untagged_pni: never };
export type ServiceIdString = AciString | PniString;
export type ProvisionIdString = string & { __provision_id: never };
export type ProvisioningCode = string & { __provisioning_code: never };
export type RegistrationId = number & { __reg_id: never };
export type DeviceId = number & { __device_id: never };
export type AttachmentId = string & { __attachment_id: never };
export enum ServiceIdKind {
ACI = 'ACI',
PNI = 'PNI',
}
export type SignedPreKey = Readonly<{
keyId: number;
publicKey: PublicKey;
signature: Buffer<ArrayBuffer>;
}>;
export type KyberPreKey = Readonly<{
keyId: number;
publicKey: KEMPublicKey;
signature: Buffer<ArrayBuffer>;
}>;
export type PreKey = Readonly<{
keyId: number;
publicKey: PublicKey;
}>;
export function untagPni(pni: PniString): UntaggedPniString {
return pni.replace(/^PNI:/, '') as UntaggedPniString;
}
export function tagPni(pni: UntaggedPniString): PniString {
return `PNI:${pni}` as PniString;
}

Some files were not shown because too many files have changed in this diff Show More