mirror of
https://github.com/signalapp/Signal-Desktop.git
synced 2026-08-20 13:17:43 +01:00
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:
co-authored by
trevor-signal
jamiebuilds-signal
parent
f1b086f96e
commit
ae68dc8b49
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@signalapp/mock-server': major
|
||||
---
|
||||
|
||||
gRPC support for username hash/link endpoints
|
||||
+37
-1
@@ -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
|
||||
{
|
||||
|
||||
@@ -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/**
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
24.11.1
|
||||
@@ -0,0 +1,3 @@
|
||||
**/*.js
|
||||
**/*.d.ts
|
||||
node_modules/**/*
|
||||
@@ -0,0 +1,7 @@
|
||||
// Copyright 2022 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
module.exports = {
|
||||
singleQuote: true,
|
||||
bracketSpacing: true,
|
||||
};
|
||||
@@ -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/>.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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-----
|
||||
@@ -0,0 +1 @@
|
||||
AB0BE03708DC8ADC
|
||||
@@ -0,0 +1,54 @@
|
||||
-----BEGIN ENCRYPTED PRIVATE KEY-----
|
||||
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
|
||||
V9gFj5TFNq1t3F9AX1YXyk58iOzcZSfJ
|
||||
-----END ENCRYPTED PRIVATE KEY-----
|
||||
@@ -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
|
||||
@@ -0,0 +1,41 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
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
|
||||
hDBJ4zQ=
|
||||
-----END CERTIFICATE-----
|
||||
@@ -0,0 +1,32 @@
|
||||
-----BEGIN CERTIFICATE REQUEST-----
|
||||
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
|
||||
n4DfIGS8gFGbaHf7EywBrGuWAXj9hAyikA==
|
||||
-----END CERTIFICATE REQUEST-----
|
||||
@@ -0,0 +1,74 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
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
|
||||
hDBJ4zQ=
|
||||
-----END CERTIFICATE-----
|
||||
-----BEGIN CERTIFICATE-----
|
||||
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
|
||||
-----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,
|
||||
),
|
||||
);
|
||||
@@ -0,0 +1,51 @@
|
||||
-----BEGIN RSA PRIVATE KEY-----
|
||||
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
|
||||
-----END RSA PRIVATE KEY-----
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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],
|
||||
};
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user