diff --git a/.changeset/chatty-snakes-knock.md b/.changeset/chatty-snakes-knock.md
new file mode 100644
index 0000000000..fa2c335e39
--- /dev/null
+++ b/.changeset/chatty-snakes-knock.md
@@ -0,0 +1,5 @@
+---
+'@signalapp/mock-server': major
+---
+
+gRPC support for username hash/link endpoints
diff --git a/.oxlintrc.json b/.oxlintrc.json
index e387fa8ee7..d35815bc02 100644
--- a/.oxlintrc.json
+++ b/.oxlintrc.json
@@ -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
{
diff --git a/.prettierignore b/.prettierignore
index 6b5222ec42..ccc7ad3c41 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -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/**
diff --git a/knip.js b/knip.js
index 80ae300e7a..ad58af304d 100644
--- a/knip.js
+++ b/knip.js
@@ -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}!',
diff --git a/package.json b/package.json
index b7f4de5bd3..097d23aee0 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/packages/mock-server/.github/workflows/publish.yaml b/packages/mock-server/.github/workflows/publish.yaml
new file mode 100644
index 0000000000..a1737755dc
--- /dev/null
+++ b/packages/mock-server/.github/workflows/publish.yaml
@@ -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
diff --git a/packages/mock-server/.github/workflows/test.yaml b/packages/mock-server/.github/workflows/test.yaml
new file mode 100644
index 0000000000..597f7e0d96
--- /dev/null
+++ b/packages/mock-server/.github/workflows/test.yaml
@@ -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
diff --git a/packages/mock-server/.gitignore b/packages/mock-server/.gitignore
new file mode 100644
index 0000000000..7d908fe302
--- /dev/null
+++ b/packages/mock-server/.gitignore
@@ -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
diff --git a/packages/mock-server/.nvmrc b/packages/mock-server/.nvmrc
new file mode 100644
index 0000000000..9e2934aa34
--- /dev/null
+++ b/packages/mock-server/.nvmrc
@@ -0,0 +1 @@
+24.11.1
diff --git a/packages/mock-server/.prettierignore b/packages/mock-server/.prettierignore
new file mode 100644
index 0000000000..bce2ad3756
--- /dev/null
+++ b/packages/mock-server/.prettierignore
@@ -0,0 +1,3 @@
+**/*.js
+**/*.d.ts
+node_modules/**/*
diff --git a/packages/mock-server/.prettierrc.js b/packages/mock-server/.prettierrc.js
new file mode 100644
index 0000000000..47eb1f00cf
--- /dev/null
+++ b/packages/mock-server/.prettierrc.js
@@ -0,0 +1,7 @@
+// Copyright 2022 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+
+module.exports = {
+ singleQuote: true,
+ bracketSpacing: true,
+};
diff --git a/packages/mock-server/LICENSE b/packages/mock-server/LICENSE
new file mode 100644
index 0000000000..710ccc0445
--- /dev/null
+++ b/packages/mock-server/LICENSE
@@ -0,0 +1,661 @@
+GNU AFFERO GENERAL PUBLIC LICENSE
+ Version 3, 19 November 2007
+
+Copyright (C) 2007 Free Software Foundation, Inc.
+Everyone is permitted to copy and distribute verbatim copies
+of this license document, but changing it is not allowed.
+
+ Preamble
+
+The GNU Affero General Public License is a free, copyleft license for
+software and other kinds of works, specifically designed to ensure
+cooperation with the community in the case of network server software.
+
+The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+our General Public Licenses are intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users.
+
+When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+Developers that use our General Public Licenses protect your rights
+with two steps: (1) assert copyright on the software, and (2) offer
+you this License which gives you legal permission to copy, distribute
+and/or modify the software.
+
+A secondary benefit of defending all users' freedom is that
+improvements made in alternate versions of the program, if they
+receive widespread use, become available for other developers to
+incorporate. Many developers of free software are heartened and
+encouraged by the resulting cooperation. However, in the case of
+software used on network servers, this result may fail to come about.
+The GNU General Public License permits making a modified version and
+letting the public access it on a server without ever releasing its
+source code to the public.
+
+The GNU Affero General Public License is designed specifically to
+ensure that, in such cases, the modified source code becomes available
+to the community. It requires the operator of a network server to
+provide the source code of the modified version running there to the
+users of that server. Therefore, public use of a modified version, on
+a publicly accessible server, gives the public access to the source
+code of the modified version.
+
+An older license, called the Affero General Public License and
+published by Affero, was designed to accomplish similar goals. This is
+a different license, not a version of the Affero GPL, but Affero has
+released a new version of the Affero GPL which permits relicensing under
+this license.
+
+The precise terms and conditions for copying, distribution and
+modification follow.
+
+ TERMS AND CONDITIONS
+
+0. Definitions.
+
+"This License" refers to version 3 of the GNU Affero General Public License.
+
+"Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+"The Program" refers to any copyrightable work licensed under this
+License. Each licensee is addressed as "you". "Licensees" and
+"recipients" may be individuals or organizations.
+
+To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy. The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy. Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+1. Source Code.
+
+The "source code" for a work means the preferred form of the work
+for making modifications to it. "Object code" means any non-source
+form of a work.
+
+A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+The Corresponding Source for a work in source code form is that
+same work.
+
+2. Basic Permissions.
+
+All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force. You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright. Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+Conveying under any other circumstances is permitted solely under
+the conditions stated below. Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+4. Conveying Verbatim Copies.
+
+You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+5. Conveying Modified Source Versions.
+
+You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+a) The work must carry prominent notices stating that you modified
+it, and giving a relevant date.
+
+b) The work must carry prominent notices stating that it is
+released under this License and any conditions added under section
+7. This requirement modifies the requirement in section 4 to
+"keep intact all notices".
+
+c) You must license the entire work, as a whole, under this
+License to anyone who comes into possession of a copy. This
+License will therefore apply, along with any applicable section 7
+additional terms, to the whole of the work, and all its parts,
+regardless of how they are packaged. This License gives no
+permission to license the work in any other way, but it does not
+invalidate such permission if you have separately received it.
+
+d) If the work has interactive user interfaces, each must display
+Appropriate Legal Notices; however, if the Program has interactive
+interfaces that do not display Appropriate Legal Notices, your
+work need not make them do so.
+
+A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit. Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+6. Conveying Non-Source Forms.
+
+You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+a) Convey the object code in, or embodied in, a physical product
+(including a physical distribution medium), accompanied by the
+Corresponding Source fixed on a durable physical medium
+customarily used for software interchange.
+
+b) Convey the object code in, or embodied in, a physical product
+(including a physical distribution medium), accompanied by a
+written offer, valid for at least three years and valid for as
+long as you offer spare parts or customer support for that product
+model, to give anyone who possesses the object code either (1) a
+copy of the Corresponding Source for all the software in the
+product that is covered by this License, on a durable physical
+medium customarily used for software interchange, for a price no
+more than your reasonable cost of physically performing this
+conveying of source, or (2) access to copy the
+Corresponding Source from a network server at no charge.
+
+c) Convey individual copies of the object code with a copy of the
+written offer to provide the Corresponding Source. This
+alternative is allowed only occasionally and noncommercially, and
+only if you received the object code with such an offer, in accord
+with subsection 6b.
+
+d) Convey the object code by offering access from a designated
+place (gratis or for a charge), and offer equivalent access to the
+Corresponding Source in the same way through the same place at no
+further charge. You need not require recipients to copy the
+Corresponding Source along with the object code. If the place to
+copy the object code is a network server, the Corresponding Source
+may be on a different server (operated by you or a third party)
+that supports equivalent copying facilities, provided you maintain
+clear directions next to the object code saying where to find the
+Corresponding Source. Regardless of what server hosts the
+Corresponding Source, you remain obligated to ensure that it is
+available for as long as needed to satisfy these requirements.
+
+e) Convey the object code using peer-to-peer transmission, provided
+you inform other peers where the object code and Corresponding
+Source of the work are being offered to the general public at no
+charge under subsection 6d.
+
+A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling. In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage. For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product. A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+"Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source. The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed. Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+7. Additional Terms.
+
+"Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law. If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+a) Disclaiming warranty or limiting liability differently from the
+terms of sections 15 and 16 of this License; or
+
+b) Requiring preservation of specified reasonable legal notices or
+author attributions in that material or in the Appropriate Legal
+Notices displayed by works containing it; or
+
+c) Prohibiting misrepresentation of the origin of that material, or
+requiring that modified versions of such material be marked in
+reasonable ways as different from the original version; or
+
+d) Limiting the use for publicity purposes of names of licensors or
+authors of the material; or
+
+e) Declining to grant rights under trademark law for use of some
+trade names, trademarks, or service marks; or
+
+f) Requiring indemnification of licensors and authors of that
+material by anyone who conveys the material (or modified versions of
+it) with contractual assumptions of liability to the recipient, for
+any liability that these contractual assumptions directly impose on
+those licensors and authors.
+
+All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+8. Termination.
+
+You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+9. Acceptance Not Required for Having Copies.
+
+You are not required to accept this License in order to receive or
+run a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+10. Automatic Licensing of Downstream Recipients.
+
+Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+11. Patents.
+
+A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients. "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License. You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+12. No Surrender of Others' Freedom.
+
+If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all. For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+13. Remote Network Interaction; Use with the GNU General Public License.
+
+Notwithstanding any other provision of this License, if you modify the
+Program, your modified version must prominently offer all users
+interacting with it remotely through a computer network (if your version
+supports such interaction) an opportunity to receive the Corresponding
+Source of your version by providing access to the Corresponding Source
+from a network server at no charge, through some standard or customary
+means of facilitating copying of software. This Corresponding Source
+shall include the Corresponding Source for any work covered by version 3
+of the GNU General Public License that is incorporated pursuant to the
+following paragraph.
+
+Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU General Public License into a single
+combined work, and to convey the resulting work. The terms of this
+License will continue to apply to the part which is the covered work,
+but the work with which it is combined will remain governed by version
+3 of the GNU General Public License.
+
+14. Revised Versions of this License.
+
+The Free Software Foundation may publish revised and/or new versions of
+the GNU Affero General Public License from time to time. Such new versions
+will be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+Each version is given a distinguishing version number. If the
+Program specifies that a certain numbered version of the GNU Affero General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation. If the Program does not specify a version number of the
+GNU Affero General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+If the Program specifies that a proxy can decide which future
+versions of the GNU Affero General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+15. Disclaimer of Warranty.
+
+THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+16. Limitation of Liability.
+
+IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+17. Interpretation of Sections 15 and 16.
+
+If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+ END OF TERMS AND CONDITIONS
+
+How to Apply These Terms to Your New Programs
+
+If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+
+Copyright (C)
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see .
+
+Also add information on how to contact you by electronic and paper mail.
+
+If your software can interact with users remotely through a computer
+network, you should also make sure that it provides a way for users to
+get its source. For example, if your program is a web application, its
+interface could display a "Source" link that leads users to an archive
+of the code. There are many ways you could offer source, and different
+solutions will be better for different programs; see section 13 for the
+specific requirements.
+
+You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU AGPL, see
+.
diff --git a/packages/mock-server/README.md b/packages/mock-server/README.md
new file mode 100644
index 0000000000..aa648b26de
--- /dev/null
+++ b/packages/mock-server/README.md
@@ -0,0 +1,25 @@
+
+
+
+# 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
diff --git a/packages/mock-server/certs/Makefile b/packages/mock-server/certs/Makefile
new file mode 100644
index 0000000000..63a1cf0e9e
--- /dev/null
+++ b/packages/mock-server/certs/Makefile
@@ -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
diff --git a/packages/mock-server/certs/README.md b/packages/mock-server/certs/README.md
new file mode 100644
index 0000000000..4228f1b8ae
--- /dev/null
+++ b/packages/mock-server/certs/README.md
@@ -0,0 +1,14 @@
+
+
+
+## Certificates
+
+This folder contains various certificates required to run the mock server.
+
+### Rebuilding
+
+There shouldn't be a reason for rebuilding certificates bcause they have very
+long expiration value, however if needed it could be done by:
+
+- Installing node.js (16 or later), make, and openssl
+- Run `make -B` in this folder
diff --git a/packages/mock-server/certs/ca-cert.pem b/packages/mock-server/certs/ca-cert.pem
new file mode 100644
index 0000000000..b757e8e21c
--- /dev/null
+++ b/packages/mock-server/certs/ca-cert.pem
@@ -0,0 +1,33 @@
+-----BEGIN CERTIFICATE-----
+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
+-----END CERTIFICATE-----
diff --git a/packages/mock-server/certs/ca-cert.srl b/packages/mock-server/certs/ca-cert.srl
new file mode 100644
index 0000000000..6757449a45
--- /dev/null
+++ b/packages/mock-server/certs/ca-cert.srl
@@ -0,0 +1 @@
+AB0BE03708DC8ADC
diff --git a/packages/mock-server/certs/ca-key.pem b/packages/mock-server/certs/ca-key.pem
new file mode 100644
index 0000000000..07b0074c05
--- /dev/null
+++ b/packages/mock-server/certs/ca-key.pem
@@ -0,0 +1,54 @@
+-----BEGIN ENCRYPTED PRIVATE KEY-----
+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
+V9gFj5TFNq1t3F9AX1YXyk58iOzcZSfJ
+-----END ENCRYPTED PRIVATE KEY-----
diff --git a/packages/mock-server/certs/ca.cnf b/packages/mock-server/certs/ca.cnf
new file mode 100644
index 0000000000..6e5d096fd6
--- /dev/null
+++ b/packages/mock-server/certs/ca.cnf
@@ -0,0 +1,22 @@
+[ req ]
+default_bits = 4096
+days = 9999
+distinguished_name = req_distinguished_name
+attributes = req_attributes
+prompt = no
+output_password = password
+x509_extensions = v3_ca
+
+[ req_distinguished_name ]
+C = US
+ST = CA
+L = LA
+O = Signal Mock
+OU = Signal Mock
+CN = Signal Mock CA
+emailAddress = indutny@signal.org
+
+[ req_attributes ]
+
+[ v3_ca ]
+basicConstraints = CA:TRUE
diff --git a/packages/mock-server/certs/cert.pem b/packages/mock-server/certs/cert.pem
new file mode 100644
index 0000000000..0aa0ad31dd
--- /dev/null
+++ b/packages/mock-server/certs/cert.pem
@@ -0,0 +1,41 @@
+-----BEGIN CERTIFICATE-----
+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
+hDBJ4zQ=
+-----END CERTIFICATE-----
diff --git a/packages/mock-server/certs/csr.pem b/packages/mock-server/certs/csr.pem
new file mode 100644
index 0000000000..18d567c34f
--- /dev/null
+++ b/packages/mock-server/certs/csr.pem
@@ -0,0 +1,32 @@
+-----BEGIN CERTIFICATE REQUEST-----
+MIIFhTCCA20CAQAwgZAxCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJDQTELMAkGA1UE
+BwwCTEExFDASBgNVBAoMC1NpZ25hbCBNb2NrMRQwEgYDVQQLDAtTaWduYWwgTW9j
+azEYMBYGA1UEAwwPbW9jay5zaWduYWwub3JnMSEwHwYJKoZIhvcNAQkBFhJpbmR1
+dG55QHNpZ25hbC5vcmcwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQC7
+cw94tAPPKS39Jikzmv221XQvNO1pT2JjPxXu6PPmGFYUXbAqOkny80QOrSkkD+4F
+tdJdZNYEPmLkeq/iUiWO/cmEIXWz93nyK384648owL3kBZK1xP2IfOtFJ7klXYli
+sc1NPSpzjrwQnrfEuWfkoBy7gT2aNrPQaHdRZSh/Wqf5cr35xI3tjfL61rlSrn5F
+QIPB+JoCKVvYpud+tgnXnPLSoHnMOd2plNxmwKfnc6qrWYrK579+/a10Ij+aaEQ5
+WJTtAuzxH7X/XX4r1rmHObxZ0TpdjX8E5FBAk+qswkB1ys1G5djFWhNPzc9hRon3
+jqnMkt/8GVsi/tNHTMuEssNTMNFdu5UjyLLD1+zj9zePfliS5jS7jlxgNRz7DpYv
+zvnmY/x4NIvM/KCJcECx0FFHEdAIW18ZyYAD9QYxes6CBjbAbPpInFIz15cIPmJ9
+DGy3nz8N7vTYh2thq5CntywFTBIi7OdXsodFgdToBn4JPsuL1H+uxkeULA128rj1
+u3fCURGcLw3G4m4PCqRL5SnBrrcDtFeigOVUIU+GxhKEsmyJcN46DyMlPr74Qzu9
+RkiPhwCPYflV9kKJ6n6qZxOyvOPi65Q0LhjaHc/UQSCHqxXr+0myXIvpdqVzWCgw
+H/qWCkMBx8RbNxcxmdlFDpoX/EidOjxDhtyk007FVQIDAQABoIGuMIGrBgkqhkiG
+9w0BCQ4xgZ0wgZowXwYIKwYBBQUHAQEEUzBRMCMGCCsGAQUFBzABhhdodHRwOi8v
+bW9jay5zaWduYWwub3JnLzAqBggrBgEFBQcwAoYeaHR0cDovL21vY2suc2lnbmFs
+Lm9yZy9jYS5jZXJ0MAkGA1UdEwQCMAAwLAYDVR0RBCUwI4cEfwAAAYcQAAAAAAAA
+AAAAAAAAAAAAAYIJbG9jYWxob3N0MA0GCSqGSIb3DQEBCwUAA4ICAQAvtrnRsa3c
+zS6ZeLrPR9vu7MeozLQtW2xCM42Ghuccnpr+zbszmTJXc2pZBORAYV7J8vBd55sS
+bNexx9BbopzIrzUwkyp4WzCtz8J60hVcVcF7jaXseXO3TyFV8Ju0RCITDz3fwxML
+r4NYAL3ihMemMk8DZ7GGpZHRAd6W5X8L+BdnYJbEWcuTXwzy370PDP4bgcNL9eF8
+fskvuUzXmESBDvwrXTnFGh4i9+3o7OC3g2bma2HJDaokkXMq0jgHLwsJN5WhUHFd
+Hhni3ftX2AELj6JalC/ATlS1Q96mV9D9wZXW5lZgXBZU4geVjkt49witJ9RWi0we
+wU0meZfN/Nz6MmXVz/BwE1i7n7eVW+hrkAMqTvlweKgmptjDgJjGjex1KW08ucxw
+8C4oIc9XRyV5fsYpS9TTu+5Y81XLAWDXgzgGDl1cwa4A5pHxgaWNT81JqJrzZIF6
+w2uMD//GYPXZI79wnFTk4oFEtCHD84fZNM1bLfXUqR90+mtm84wMVLFbB2jIEePM
+cFrgff29UA/DC9Ronp0S4aKDGQwet+PGVbs5J1TctwuYRZTdV68WAFnizMTBaiJv
+ddKvI1LoDzvvXM2bsbQVddRXjg1Gy0laidSuBfL5Ksv2xxrbt7qDNyxXFBzPd78P
+n4DfIGS8gFGbaHf7EywBrGuWAXj9hAyikA==
+-----END CERTIFICATE REQUEST-----
diff --git a/packages/mock-server/certs/full-cert.pem b/packages/mock-server/certs/full-cert.pem
new file mode 100644
index 0000000000..cb720d7da1
--- /dev/null
+++ b/packages/mock-server/certs/full-cert.pem
@@ -0,0 +1,74 @@
+-----BEGIN CERTIFICATE-----
+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
+hDBJ4zQ=
+-----END CERTIFICATE-----
+-----BEGIN CERTIFICATE-----
+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
+-----END CERTIFICATE-----
diff --git a/packages/mock-server/certs/generate-trust-root.js b/packages/mock-server/certs/generate-trust-root.js
new file mode 100644
index 0000000000..fc8fa9d8d2
--- /dev/null
+++ b/packages/mock-server/certs/generate-trust-root.js
@@ -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,
+ ),
+);
diff --git a/packages/mock-server/certs/generate-zk-params.js b/packages/mock-server/certs/generate-zk-params.js
new file mode 100644
index 0000000000..961a2f3f2f
--- /dev/null
+++ b/packages/mock-server/certs/generate-zk-params.js
@@ -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,
+ ),
+);
diff --git a/packages/mock-server/certs/key.pem b/packages/mock-server/certs/key.pem
new file mode 100644
index 0000000000..08063eba3a
--- /dev/null
+++ b/packages/mock-server/certs/key.pem
@@ -0,0 +1,51 @@
+-----BEGIN RSA PRIVATE KEY-----
+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
+16w3EwKCAQBh2Wtv5Bob9I9Tr/HuoaW0MHp2IqkbAQ94QKcB3w30B6AvUhjS8Aw4
+YJaFxd1jutscOD931X1c/1tQwErUC9lWqsNsrgqGUKC2uLlgVx42+sYSw2VpL8Wx
+LwI6da5UczSzbchK4t0zOP3Kw1Y+JCw7RuW9tbDFDHWqqo8Mhjyt891urnuBR6rI
+WP8n4fSedzpnMVv/j4shzrHVHD0PdmthDSumsk6mVbX8LFvuNlc8iFVxoe6jmXvJ
+1RyqexAxKpUeOmDb7tnj8ehclzahVTJBRtzho8ozV36slaxh5Djt9JoHmMOnHPjy
+ow5YYtHaodgOaeuGiryyZ43sry1Tj3BLAoIBAB8QRoM/G+XMAgww4uEVxSv4dmHY
+2Yp95VK/IHaOa91dT+oOLkiCq1a6fCjZJDH8nXxRpTfZYfSuf6hMJS124Uiw14r4
+qGcHHvtKEn7FxpSZN7qo6cpxyOerFdV1rudq9ulxcbUGuP0sE9aAYRLR5moFxmHJ
+3If2xlDRhbxSywMKFYHKBIioUnyZwr0qdK0ZYWU2pAV5FqzkVq7h3JOm9rqTUjeT
+qPsTEzVzhaJG1pH92cPpnetSixuxikrpnrgPQWfj04aooDLQIPGzMm3ICZMRSCC9
+wNY2D4qYaejt0nlTTGG4eSw2b9DnFy4l48z9DpoU1/1qBGEnS/+UJ5V/lP0=
+-----END RSA PRIVATE KEY-----
diff --git a/packages/mock-server/certs/main.cnf b/packages/mock-server/certs/main.cnf
new file mode 100644
index 0000000000..6e848e5aac
--- /dev/null
+++ b/packages/mock-server/certs/main.cnf
@@ -0,0 +1,27 @@
+[ req ]
+default_bits = 4096
+days = 9999
+distinguished_name = req_distinguished_name
+attributes = req_attributes
+prompt = no
+x509_extensions = v3_ca
+
+[ req_distinguished_name ]
+C = US
+ST = CA
+L = LA
+O = Signal Mock
+OU = Signal Mock
+CN = mock.signal.org
+emailAddress = indutny@signal.org
+
+[ req_attributes ]
+
+[ v3_ca ]
+authorityInfoAccess = @issuer_info
+basicConstraints = CA:FALSE
+subjectAltName = IP:127.0.0.1,IP:::1,DNS:localhost
+
+[ issuer_info ]
+OCSP;URI.0 = http://mock.signal.org/
+caIssuers;URI.0 = http://mock.signal.org/ca.cert
diff --git a/packages/mock-server/certs/trust-root.json b/packages/mock-server/certs/trust-root.json
new file mode 100644
index 0000000000..81e731469b
--- /dev/null
+++ b/packages/mock-server/certs/trust-root.json
@@ -0,0 +1,4 @@
+{
+ "privateKey": "IIAqba11mCp276QmhiTg4Dtfa/FsWcKUSdCPVt8LT0c=",
+ "publicKey": "BZ8zqn+/bbZcpoKqnvkHXvoTI+n9o/Iuc9kpVog2ZEYs"
+}
diff --git a/packages/mock-server/certs/zk-params.json b/packages/mock-server/certs/zk-params.json
new file mode 100644
index 0000000000..188529b433
--- /dev/null
+++ b/packages/mock-server/certs/zk-params.json
@@ -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"
+}
diff --git a/packages/mock-server/package.json b/packages/mock-server/package.json
new file mode 100644
index 0000000000..57910a4dfe
--- /dev/null
+++ b/packages/mock-server/package.json
@@ -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"
+ }
+ }
+}
diff --git a/packages/mock-server/patches/@types__ws.patch b/packages/mock-server/patches/@types__ws.patch
new file mode 100644
index 0000000000..23e283641f
--- /dev/null
+++ b/packages/mock-server/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;
diff --git a/packages/mock-server/protos/ContactDiscovery.proto b/packages/mock-server/protos/ContactDiscovery.proto
new file mode 100644
index 0000000000..d1f5e046b9
--- /dev/null
+++ b/packages/mock-server/protos/ContactDiscovery.proto
@@ -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;
+}
diff --git a/packages/mock-server/protos/CrashReports.proto b/packages/mock-server/protos/CrashReports.proto
new file mode 100644
index 0000000000..9542f53cbd
--- /dev/null
+++ b/packages/mock-server/protos/CrashReports.proto
@@ -0,0 +1,15 @@
+syntax = "proto3";
+
+// Copyright 2020-2021 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+
+package signalservice;
+
+message CrashReport {
+ string filename = 1;
+ bytes content = 2;
+}
+
+message CrashReportList {
+ repeated CrashReport reports = 1;
+}
diff --git a/packages/mock-server/protos/DeviceMessages.proto b/packages/mock-server/protos/DeviceMessages.proto
new file mode 100644
index 0000000000..0727979670
--- /dev/null
+++ b/packages/mock-server/protos/DeviceMessages.proto
@@ -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;
+}
diff --git a/packages/mock-server/protos/DeviceName.proto b/packages/mock-server/protos/DeviceName.proto
new file mode 100644
index 0000000000..af14c1bff2
--- /dev/null
+++ b/packages/mock-server/protos/DeviceName.proto
@@ -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;
+}
diff --git a/packages/mock-server/protos/Groups.proto b/packages/mock-server/protos/Groups.proto
new file mode 100644
index 0000000000..d9f085719e
--- /dev/null
+++ b/packages/mock-server/protos/Groups.proto
@@ -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;
+}
diff --git a/packages/mock-server/protos/LibSignal-Client.proto b/packages/mock-server/protos/LibSignal-Client.proto
new file mode 100644
index 0000000000..6055458d32
--- /dev/null
+++ b/packages/mock-server/protos/LibSignal-Client.proto
@@ -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;
+}
diff --git a/packages/mock-server/protos/README.md b/packages/mock-server/protos/README.md
new file mode 100644
index 0000000000..78fb88165e
--- /dev/null
+++ b/packages/mock-server/protos/README.md
@@ -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
diff --git a/packages/mock-server/protos/RingRTC.proto b/packages/mock-server/protos/RingRTC.proto
new file mode 100644
index 0000000000..d312343aba
--- /dev/null
+++ b/packages/mock-server/protos/RingRTC.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;
+}
diff --git a/packages/mock-server/protos/SignalService.proto b/packages/mock-server/protos/SignalService.proto
new file mode 100644
index 0000000000..b266476533
--- /dev/null
+++ b/packages/mock-server/protos/SignalService.proto
@@ -0,0 +1,1019 @@
+/*
+ * Copyright 2020-2022 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 = "SignalServiceProtos";
+
+message Envelope {
+ enum Type {
+ UNKNOWN = 0;
+
+ /**
+ * A double-ratchet message represents a "normal," "unsealed-sender" message
+ * encrypted using the Double Ratchet within an established Signal session.
+ * Double-ratchet messages include sender information in the plaintext
+ * portion of the `Envelope`.
+ */
+ DOUBLE_RATCHET = 1; // content => (version byte | SignalMessage{Content})
+
+ reserved 2;
+ reserved "KEY_EXCHANGE";
+
+ /**
+ * 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. Like double-ratchet messages,
+ * prekey messages contain sender information in the plaintext portion of
+ * the `Envelope`.
+ */
+ PREKEY_MESSAGE = 3; // content => (version byte | PreKeySignalMessage{Content})
+
+ /**
+ * Server delivery receipts are generated by the server when
+ * "unsealed-sender" messages are delivered to and acknowledged by the
+ * destination device. Server delivery receipts identify the sender in the
+ * plaintext portion of the `Envelope` and have no `content`. Note that
+ * receipts for sealed-sender messages are generated by clients as
+ * `UNIDENTIFIED_SENDER` messages.
+ *
+ * Note that, with server delivery receipts, the "client timestamp" on
+ * the envelope refers to the timestamp of the original message (i.e. the
+ * message the server just delivered) and not to the time of delivery. The
+ * "server timestamp" refers to the time of delivery.
+ */
+ SERVER_DELIVERY_RECEIPT = 5; // content => []
+
+ /**
+ * An unidentified sender message represents a message with no sender
+ * information in the plaintext portion of the `Envelope`. Unidentified
+ * sender messages always contain an additional `subtype` in their
+ * `content`. They may or may not be part of an existing Signal session
+ * (i.e. an unidentified sender message may have a "prekey message"
+ * subtype or may indicate an encryption error).
+ */
+ UNIDENTIFIED_SENDER = 6; // content => ((version byte | UnidentifiedSenderMessage) OR (version byte | Multi-Recipient Sealed Sender Format))
+
+ reserved 7;
+ reserved "SENDERKEY_MESSAGE";
+
+ /**
+ * 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 = 8; // content => (marker byte | Content)
+
+ // next: 9
+ }
+
+ optional Type type = 1;
+ reserved 2; // formerly optional string sourceE164 = 2;
+ optional string sourceServiceId = 11;
+ optional uint32 sourceDeviceId = 7;
+ optional string destinationServiceId = 13;
+ reserved 3; // formerly optional string relay = 3;
+ optional uint64 clientTimestamp = 5;
+ reserved 6; // formerly optional bytes legacyMessage = 6; // Contains an encrypted DataMessage; this field could have been set historically for type 1 or 3 messages; no longer in use
+ optional bytes content = 8; // Contains an encrypted Content
+ optional string serverGuid = 9;
+ optional uint64 serverTimestamp = 10;
+ optional bool ephemeral = 12; // indicates that the message should not be persisted if the recipient is offline
+ optional bool urgent = 14 [default = true]; // indicates that the content is considered timely by the sender; defaults to true so senders have to opt-out to say something isn't time critical
+ optional string updatedPni = 15; // for number-change synchronization messages, provides the new server-assigned phone number identifier associated with the changed number
+ optional bool story = 16; // indicates that the content is a story.
+ optional bytes report_spam_token = 17; // token sent when reporting spam
+ reserved 18; // internal server use
+ optional bytes sourceServiceIdBinary = 19; // service ID binary (i.e. 16 byte UUID for ACI, 1 byte prefix + 16 byte UUID for PNI)
+ optional bytes destinationServiceIdBinary = 20; // service ID binary (i.e. 16 byte UUID for ACI, 1 byte prefix + 16 byte UUID for PNI)
+ optional bytes serverGuidBinary = 21; // 16-byte UUID
+ optional bytes updatedPniBinary = 22; // 16-byte UUID
+ // next: 22
+}
+
+message Content {
+ oneof content {
+ DataMessage dataMessage = 1;
+ SyncMessage syncMessage = 2;
+ CallMessage callMessage = 3;
+ NullMessage nullMessage = 4;
+ ReceiptMessage receiptMessage = 5;
+ TypingMessage typingMessage = 6;
+ bytes /* DecryptionErrorMessage */ decryptionErrorMessage = 8;
+ StoryMessage storyMessage = 9;
+ EditMessage editMessage = 11;
+ }
+
+ optional bytes /* SenderKeyDistributionMessage */ senderKeyDistributionMessage = 7;
+ optional PniSignatureMessage pniSignatureMessage = 10;
+}
+
+message CallMessage {
+ message Offer {
+ enum Type {
+ OFFER_AUDIO_CALL = 0;
+ OFFER_VIDEO_CALL = 1;
+ reserved /* OFFER_NEED_PERMISSION */ 2; // removed
+ }
+ optional uint64 id = 1;
+ reserved /* sdp */ 2;
+ optional Type type = 3;
+ optional bytes opaque = 4;
+ }
+
+ message Answer {
+ optional uint64 id = 1;
+ reserved /* sdp */ 2;
+ optional bytes opaque = 3;
+ }
+
+ message IceUpdate {
+ optional uint64 id = 1;
+ reserved /* mid */ 2;
+ reserved /* line */ 3;
+ reserved /* sdp */ 4;
+ optional bytes opaque = 5;
+ }
+
+ message Busy {
+ optional uint64 id = 1;
+ }
+
+ message Hangup {
+ enum Type {
+ HANGUP_NORMAL = 0;
+ HANGUP_ACCEPTED = 1;
+ HANGUP_DECLINED = 2;
+ HANGUP_BUSY = 3;
+ HANGUP_NEED_PERMISSION = 4;
+ }
+ optional uint64 id = 1;
+ optional Type type = 2;
+ optional uint32 deviceId = 3;
+ }
+
+ message Opaque {
+ enum Urgency {
+ DROPPABLE = 0;
+ HANDLE_IMMEDIATELY = 1;
+ }
+ optional bytes data = 1;
+ optional Urgency urgency = 2; // If missing, treat as DROPPABLE.
+ }
+
+ optional Offer offer = 1;
+ optional Answer answer = 2;
+ repeated IceUpdate iceUpdate = 3;
+ reserved /* legacyHangup */ 4;
+ optional Busy busy = 5;
+ reserved /* profileKey */ 6;
+ optional Hangup hangup = 7;
+ reserved /* multiRing */ 8;
+ optional uint32 destinationDeviceId = 9;
+ optional Opaque opaque = 10;
+}
+
+message DataMessage {
+ enum Flags {
+ END_SESSION = 1;
+ EXPIRATION_TIMER_UPDATE = 2;
+ PROFILE_KEY_UPDATE = 4;
+ FORWARD = 8;
+ }
+
+ message Payment {
+ message Amount {
+ message MobileCoin {
+ optional uint64 picoMob = 1; // 1,000,000,000,000 picoMob per Mob
+ }
+
+ oneof Amount {
+ MobileCoin mobileCoin = 1;
+ }
+ }
+
+ message Notification {
+ message MobileCoin {
+ optional bytes receipt = 1;
+ }
+
+ oneof Transaction {
+ MobileCoin mobileCoin = 1;
+ }
+
+ // Optional, Refers to the PaymentRequest message, if any.
+ optional string note = 2;
+ reserved /*requestId*/ 1003;
+ }
+
+ message Activation {
+ enum Type {
+ REQUEST = 0;
+ ACTIVATED = 1;
+ }
+
+ optional Type type = 1;
+ }
+
+ oneof Item {
+ Notification notification = 1;
+ Activation activation = 2;
+ }
+
+ reserved /*request*/ 1002;
+ reserved /*cancellation*/ 1003;
+ }
+
+ message Quote {
+ enum Type {
+ NORMAL = 0;
+ GIFT_BADGE = 1;
+ POLL = 2;
+ }
+
+ message QuotedAttachment {
+ optional string contentType = 1;
+ optional string fileName = 2;
+ optional AttachmentPointer thumbnail = 3;
+ }
+
+ optional uint64 id = 1;
+ reserved /*authorE164*/ 2;
+ optional string authorAci = 5;
+ optional string text = 3;
+ repeated QuotedAttachment attachments = 4;
+ repeated BodyRange bodyRanges = 6;
+ optional Type type = 7;
+ optional bytes authorAciBinary = 8; // 16-byte UUID
+ }
+
+ message Contact {
+ message Name {
+ optional string givenName = 1;
+ optional string familyName = 2;
+ optional string prefix = 3;
+ optional string suffix = 4;
+ optional string middleName = 5;
+ reserved /*displayName*/ 6;
+ optional string nickname = 7;
+ }
+
+ message Phone {
+ enum Type {
+ HOME = 1;
+ MOBILE = 2;
+ WORK = 3;
+ CUSTOM = 4;
+ }
+
+ optional string value = 1;
+ optional Type type = 2;
+ optional string label = 3;
+ }
+
+ message Email {
+ enum Type {
+ HOME = 1;
+ MOBILE = 2;
+ WORK = 3;
+ CUSTOM = 4;
+ }
+
+ optional string value = 1;
+ optional Type type = 2;
+ optional string label = 3;
+ }
+
+ message PostalAddress {
+ enum Type {
+ HOME = 1;
+ WORK = 2;
+ CUSTOM = 3;
+ }
+
+ optional Type type = 1;
+ optional string label = 2;
+ optional string street = 3;
+ optional string pobox = 4;
+ optional string neighborhood = 5;
+ optional string city = 6;
+ optional string region = 7;
+ optional string postcode = 8;
+ optional string country = 9;
+ }
+
+ message Avatar {
+ optional AttachmentPointer avatar = 1;
+ optional bool isProfile = 2;
+ }
+
+ optional Name name = 1;
+ repeated Phone number = 3;
+ repeated Email email = 4;
+ repeated PostalAddress address = 5;
+ optional Avatar avatar = 6;
+ optional string organization = 7;
+ }
+
+ message Sticker {
+ optional bytes packId = 1;
+ optional bytes packKey = 2;
+ optional uint32 stickerId = 3;
+ optional AttachmentPointer data = 4;
+ optional string emoji = 5;
+ }
+
+ message Reaction {
+ optional string emoji = 1;
+ optional bool remove = 2;
+ reserved /* targetAuthorE164 */ 3;
+ optional string targetAuthorAci = 4;
+ optional uint64 targetSentTimestamp = 5;
+ optional bytes targetAuthorAciBinary = 6; // 16-byte UUID
+ }
+
+ message Delete {
+ optional uint64 targetSentTimestamp = 1;
+ }
+
+ message GroupCallUpdate {
+ optional string eraId = 1;
+ }
+
+ message StoryContext {
+ optional string authorAci = 1;
+ optional uint64 sentTimestamp = 2;
+ optional bytes authorAciBinary = 3; // 16-byte UUID
+ }
+
+ enum ProtocolVersion {
+ option allow_alias = true;
+
+ INITIAL = 0;
+ MESSAGE_TIMERS = 1;
+ VIEW_ONCE = 2;
+ VIEW_ONCE_VIDEO = 3;
+ REACTIONS = 4;
+ CDN_SELECTOR_ATTACHMENTS = 5;
+ MENTIONS = 6;
+ PAYMENTS = 7;
+ POLLS = 8;
+ CURRENT = 8;
+ }
+
+ message GiftBadge {
+ optional bytes receiptCredentialPresentation = 1;
+ }
+
+ message PollCreate {
+ optional string question = 1;
+ optional bool allowMultiple = 2;
+ repeated string options = 3;
+ }
+
+ message PollTerminate {
+ optional uint64 targetSentTimestamp = 1;
+ }
+
+ message PollVote {
+ optional bytes targetAuthorAciBinary = 1;
+ optional uint64 targetSentTimestamp = 2;
+ repeated uint32 optionIndexes = 3;
+ optional uint32 voteCount = 4;
+ }
+
+ message PinMessage {
+ optional bytes targetAuthorAciBinary = 1; // 16-byte UUID
+ optional uint64 targetSentTimestamp = 2;
+ oneof pinDuration {
+ uint32 pinDurationSeconds = 3;
+ bool pinDurationForever = 4;
+ }
+ }
+
+ message UnpinMessage {
+ optional bytes targetAuthorAciBinary = 1; // 16-byte UUID
+ optional uint64 targetSentTimestamp = 2;
+ }
+
+ message AdminDelete {
+ optional bytes targetAuthorAciBinary = 1; // 16-byte UUID
+ optional uint64 targetSentTimestamp = 2;
+ }
+
+ optional string body = 1;
+ repeated AttachmentPointer attachments = 2;
+ reserved /*groupV1*/ 3;
+ optional GroupContextV2 groupV2 = 15;
+ optional uint32 flags = 4;
+ optional uint32 expireTimer = 5;
+ optional uint32 expireTimerVersion = 23;
+ optional bytes profileKey = 6;
+ optional uint64 timestamp = 7;
+ optional Quote quote = 8;
+ repeated Contact contact = 9;
+ repeated Preview preview = 10;
+ optional Sticker sticker = 11;
+ optional uint32 requiredProtocolVersion = 12;
+ optional bool isViewOnce = 14;
+ optional Reaction reaction = 16;
+ optional Delete delete = 17;
+ repeated BodyRange bodyRanges = 18;
+ optional GroupCallUpdate groupCallUpdate = 19;
+ optional Payment payment = 20;
+ optional StoryContext storyContext = 21;
+ optional GiftBadge giftBadge = 22;
+ optional PollCreate pollCreate = 24;
+ optional PollTerminate pollTerminate = 25;
+ optional PollVote pollVote = 26;
+ optional PinMessage pinMessage = 27;
+ optional UnpinMessage unpinMessage = 28;
+ optional AdminDelete adminDelete = 29;
+ // NEXT ID: 30
+}
+
+message NullMessage {
+ optional bytes padding = 1;
+}
+
+message ReceiptMessage {
+ enum Type {
+ DELIVERY = 0;
+ READ = 1;
+ VIEWED = 2;
+ }
+
+ optional Type type = 1;
+ repeated uint64 timestamp = 2;
+}
+
+message TypingMessage {
+ enum Action {
+ STARTED = 0;
+ STOPPED = 1;
+ }
+
+ optional uint64 timestamp = 1;
+ optional Action action = 2;
+ optional bytes groupId = 3;
+}
+
+message StoryMessage {
+ optional bytes profileKey = 1;
+ optional GroupContextV2 group = 2;
+ oneof attachment {
+ AttachmentPointer fileAttachment = 3;
+ TextAttachment textAttachment = 4;
+ }
+ optional bool allowsReplies = 5;
+ repeated BodyRange bodyRanges = 6;
+}
+
+message Preview {
+ optional string url = 1;
+ optional string title = 2;
+ optional AttachmentPointer image = 3;
+ optional string description = 4;
+ optional uint64 date = 5;
+}
+
+message TextAttachment {
+ enum Style {
+ DEFAULT = 0;
+ REGULAR = 1;
+ BOLD = 2;
+ SERIF = 3;
+ SCRIPT = 4;
+ CONDENSED = 5;
+ }
+
+ message Gradient {
+ // Color ordering:
+ // 0 degrees: bottom-to-top
+ // 90 degrees: left-to-right
+ // 180 degrees: top-to-bottom
+ // 270 degrees: right-to-left
+
+ optional uint32 startColor = 1; // deprecated: this field will be removed in a future release.
+ optional uint32 endColor = 2; // deprecated: this field will be removed in a future release.
+ optional uint32 angle = 3; // degrees
+ repeated uint32 colors = 4;
+ repeated float positions = 5; // percent from 0 to 1
+ }
+
+ optional string text = 1;
+ optional Style textStyle = 2;
+ optional uint32 textForegroundColor = 3; // integer representation of hex color
+ optional uint32 textBackgroundColor = 4;
+ optional Preview preview = 5;
+ oneof background {
+ Gradient gradient = 6;
+ uint32 color = 7;
+ }
+}
+
+message Verified {
+ enum State {
+ DEFAULT = 0;
+ VERIFIED = 1;
+ UNVERIFIED = 2;
+ }
+
+ reserved /*destinationE164*/ 1;
+ optional string destinationAci = 5;
+ optional bytes identityKey = 2;
+ optional State state = 3;
+ optional bytes nullMessage = 4;
+ optional bytes destinationAciBinary = 6; // 16-byte UUID
+}
+
+message SyncMessage {
+ message Sent {
+ message UnidentifiedDeliveryStatus {
+ reserved /*destinationE164*/ 1;
+ optional string destinationServiceId = 3;
+ optional bool unidentified = 2;
+ reserved /*destinationPni */ 4;
+ optional bytes destinationPniIdentityKey = 5; // Only set for PNI destinations
+ optional bytes destinationServiceIdBinary = 6; // service ID binary (i.e. 16 byte UUID for ACI, 1 byte prefix + 16 byte UUID for PNI)
+ }
+
+ message StoryMessageRecipient {
+ optional string destinationServiceId = 1;
+ repeated string distributionListIds = 2;
+ optional bool isAllowedToReply = 3;
+ reserved /*destinationPni */ 4;
+ optional bytes destinationServiceIdBinary = 5; // service ID binary (i.e. 16 byte UUID for ACI, 1 byte prefix + 16 byte UUID for PNI)
+ }
+
+ optional string destinationE164 = 1;
+ optional string destinationServiceId = 7;
+ optional uint64 timestamp = 2;
+ optional DataMessage message = 3;
+ optional uint64 expirationStartTimestamp = 4;
+ repeated UnidentifiedDeliveryStatus unidentifiedStatus = 5;
+ optional bool isRecipientUpdate = 6 [default = false];
+ optional StoryMessage storyMessage = 8;
+ repeated StoryMessageRecipient storyMessageRecipients = 9;
+ optional EditMessage editMessage = 10;
+ reserved /*destinationPni */ 11;
+ optional bytes destinationServiceIdBinary = 12; // service ID binary (i.e. 16 byte UUID for ACI, 1 byte prefix + 16 byte UUID for PNI)
+ // Next ID: 13
+ }
+
+ message Contacts {
+ optional AttachmentPointer blob = 1;
+ optional bool complete = 2 [default = false];
+ }
+
+ message Blocked {
+ repeated string numbers = 1;
+ repeated string acis = 3;
+ repeated bytes groupIds = 2;
+ repeated bytes acisBinary = 4; // 16-byte UUID
+ }
+
+ message Request {
+ enum Type {
+ UNKNOWN = 0;
+ CONTACTS = 1;
+ reserved /*GROUPS*/ 2;
+ BLOCKED = 3;
+ CONFIGURATION = 4;
+ KEYS = 5;
+ reserved /*PNI_IDENTITY*/ 6;
+ }
+
+ optional Type type = 1;
+ }
+
+ message Read {
+ reserved /*senderE164*/ 1;
+ optional string senderAci = 3;
+ optional uint64 timestamp = 2;
+ optional bytes senderAciBinary = 4; // 16-byte UUID
+ }
+
+ message Viewed {
+ reserved /*senderE164*/ 1;
+ optional string senderAci = 3;
+ optional uint64 timestamp = 2;
+ optional bytes senderAciBinary = 4; // 16-byte UUID
+ }
+
+ message Configuration {
+ optional bool readReceipts = 1;
+ optional bool unidentifiedDeliveryIndicators = 2;
+ optional bool typingIndicators = 3;
+ reserved /* linkPreviews */ 4;
+ reserved /* provisioningVersion */ 5;
+ optional bool linkPreviews = 6;
+ }
+
+ message StickerPackOperation {
+ enum Type {
+ INSTALL = 0;
+ REMOVE = 1;
+ }
+
+ optional bytes packId = 1;
+ optional bytes packKey = 2;
+ optional Type type = 3;
+ }
+
+ message ViewOnceOpen {
+ reserved /*senderE164*/ 1;
+ optional string senderAci = 3;
+ optional uint64 timestamp = 2;
+ optional bytes senderAciBinary = 4; // 16-byte UUID
+ }
+
+ message FetchLatest {
+ enum Type {
+ UNKNOWN = 0;
+ LOCAL_PROFILE = 1;
+ STORAGE_MANIFEST = 2;
+ SUBSCRIPTION_STATUS = 3;
+ }
+
+ optional Type type = 1;
+ }
+
+ message Keys {
+ reserved /* storageService */ 1;
+ optional bytes master = 2; // deprecated: this field will be removed in a future release.
+ optional string accountEntropyPool = 3;
+ optional bytes mediaRootBackupKey = 4;
+ }
+
+ message PniIdentity {
+ optional bytes publicKey = 1;
+ optional bytes privateKey = 2;
+ }
+
+ message MessageRequestResponse {
+ enum Type {
+ UNKNOWN = 0;
+ ACCEPT = 1;
+ DELETE = 2;
+ BLOCK = 3;
+ BLOCK_AND_DELETE = 4;
+ SPAM = 5;
+ BLOCK_AND_SPAM = 6;
+ }
+
+ reserved /*threadE164*/ 1;
+ optional string threadAci = 2;
+ optional bytes groupId = 3;
+ optional Type type = 4;
+ optional bytes threadAciBinary = 5; // 16-byte UUID
+ }
+
+ message OutgoingPayment {
+ message MobileCoin {
+ optional bytes recipientAddress = 1;
+ optional uint64 amountPicoMob = 2;
+ optional uint64 feePicoMob = 3;
+ optional bytes receipt = 4;
+ optional uint64 ledgerBlockTimestamp = 5;
+ optional uint64 ledgerBlockIndex = 6;
+ repeated bytes spentKeyImages = 7;
+ repeated bytes outputPublicKeys = 8;
+ }
+ optional string recipientServiceId = 1;
+ optional string note = 2;
+ oneof attachment_identifier {
+ MobileCoin mobileCoin = 3;
+ }
+ }
+
+ message PniChangeNumber {
+ optional bytes identityKeyPair = 1; // Serialized libsignal-client IdentityKeyPair
+ optional bytes signedPreKey = 2; // Serialized libsignal-client SignedPreKeyRecord
+ optional bytes lastResortKyberPreKey = 5; // Serialized libsignal-client KyberPreKeyRecord
+ optional uint32 registrationId = 3;
+ optional string newE164 = 4; // The e164 we have changed our number to
+ // Next ID: 6
+ }
+
+ message CallEvent {
+ enum Type {
+ UNKNOWN_TYPE = 0;
+ AUDIO_CALL = 1;
+ VIDEO_CALL = 2;
+ GROUP_CALL = 3;
+ AD_HOC_CALL = 4;
+ }
+
+ enum Direction {
+ UNKNOWN_DIRECTION = 0;
+ INCOMING = 1;
+ OUTGOING = 2;
+ }
+
+ enum Event {
+ UNKNOWN_EVENT = 0;
+ ACCEPTED = 1;
+ NOT_ACCEPTED = 2;
+ DELETE = 3;
+ OBSERVED = 4;
+ }
+
+ /* Data identifying a conversation. The service ID for 1:1, the group ID for
+ * group, or the room ID for an ad-hoc call. See also
+ * `CallLogEvent/conversationId`. */
+ optional bytes conversationId = 1;
+ /* An identifier for a call. Generated directly for 1:1, or derived from
+ * the era ID for group and ad-hoc calls. See also `CallLogEvent/callId`. */
+ optional uint64 callId = 2;
+ optional uint64 timestamp = 3;
+ optional Type type = 4;
+ optional Direction direction = 5;
+ optional Event event = 6;
+ }
+
+ message CallLinkUpdate {
+ enum Type {
+ UPDATE = 0;
+ reserved 1; // was DELETE, superseded by storage service
+ }
+
+ optional bytes rootKey = 1;
+ optional bytes adminPasskey = 2;
+ optional Type type = 3; // defaults to UPDATE
+ reserved /*epoch*/ 4;
+ }
+
+ message CallLogEvent {
+ enum Type {
+ CLEAR = 0;
+ MARKED_AS_READ = 1;
+ MARKED_AS_READ_IN_CONVERSATION = 2;
+ CLEAR_IN_CONVERSATION = 3;
+ }
+
+ optional Type type = 1;
+ optional uint64 timestamp = 2;
+ /* Data identifying a conversation. The service ID for 1:1, the group ID for
+ * group, or the room ID for an ad-hoc call. See also
+ * `CallEvent/conversationId`. */
+ optional bytes conversationId = 3;
+ /* An identifier for a call. Generated directly for 1:1, or derived from
+ * the era ID for group and ad-hoc calls. See also `CallEvent/callId`. */
+ optional uint64 callId = 4;
+ }
+
+ message DeleteForMe {
+ message MessageDeletes {
+ optional ConversationIdentifier conversation = 1;
+ repeated AddressableMessage messages = 2;
+ }
+
+ message AttachmentDelete {
+ optional ConversationIdentifier conversation = 1;
+ optional AddressableMessage targetMessage = 2;
+ // The `clientUuid` from `AttachmentPointer`.
+ optional bytes clientUuid = 3;
+ // SHA256 hash of the (encrypted, padded, etc.) attachment blob on the CDN.
+ optional bytes fallbackDigest = 4;
+ // SHA256 hash of the plaintext content of the attachment.
+ optional bytes fallbackPlaintextHash = 5;
+ }
+
+ message ConversationDelete {
+ optional ConversationIdentifier conversation = 1;
+ repeated AddressableMessage mostRecentMessages = 2;
+ optional bool isFullDelete = 3;
+ repeated AddressableMessage mostRecentNonExpiringMessages = 4;
+ }
+
+ message LocalOnlyConversationDelete {
+ optional ConversationIdentifier conversation = 1;
+ }
+
+ repeated MessageDeletes messageDeletes = 1;
+ repeated ConversationDelete conversationDeletes = 2;
+ repeated LocalOnlyConversationDelete localOnlyConversationDeletes = 3;
+ repeated AttachmentDelete attachmentDeletes = 4;
+ }
+
+ message DeviceNameChange {
+ reserved /*name*/ 1;
+ optional uint32 deviceId = 2;
+ }
+
+ message AttachmentBackfillRequest {
+ optional AddressableMessage targetMessage = 1;
+ optional ConversationIdentifier targetConversation = 2;
+ }
+
+ message AttachmentBackfillResponse {
+ message AttachmentData {
+ enum Status {
+ PENDING = 0;
+ TERMINAL_ERROR = 1;
+ }
+
+ oneof data {
+ AttachmentPointer attachment = 1;
+ Status status = 2;
+ }
+ }
+
+ enum Error {
+ MESSAGE_NOT_FOUND = 0;
+ }
+
+ message AttachmentDataList {
+ repeated AttachmentData attachments = 1;
+ optional AttachmentData longText = 2;
+ }
+
+ optional AddressableMessage targetMessage = 1;
+ optional ConversationIdentifier targetConversation = 2;
+
+ oneof data {
+ AttachmentDataList attachments = 3;
+ Error error = 4;
+ }
+ }
+
+ message UsernameChange {}
+
+ oneof content {
+ Sent sent = 1;
+ Contacts contacts = 2;
+ Request request = 4;
+ Blocked blocked = 6;
+ Verified verified = 7;
+ Configuration configuration = 9;
+ ViewOnceOpen viewOnceOpen = 11;
+ FetchLatest fetchLatest = 12;
+ Keys keys = 13;
+ MessageRequestResponse messageRequestResponse = 14;
+ OutgoingPayment outgoingPayment = 15;
+ PniChangeNumber pniChangeNumber = 18;
+ CallEvent callEvent = 19;
+ CallLinkUpdate callLinkUpdate = 20;
+ CallLogEvent callLogEvent = 21;
+ DeleteForMe deleteForMe = 22;
+ DeviceNameChange deviceNameChange = 23;
+ AttachmentBackfillRequest attachmentBackfillRequest = 24;
+ AttachmentBackfillResponse attachmentBackfillResponse = 25;
+ UsernameChange usernameChange = 26;
+ }
+
+ reserved /*groups*/ 3;
+
+ // Protobufs don't allow `repeated` fields to be inside of `oneof` so while
+ // the fields below are mutually exclusive with the rest of the values above
+ // we have to place them outside of `oneof`.
+ repeated Read read = 5;
+ repeated StickerPackOperation stickerPackOperation = 10;
+ repeated Viewed viewed = 16;
+
+ reserved /*pniIdentity*/ 17;
+
+ optional bytes padding = 8;
+}
+
+message AttachmentPointer {
+ enum Flags {
+ VOICE_MESSAGE = 1;
+ BORDERLESS = 2;
+ reserved 4;
+ GIF = 8;
+ }
+
+ oneof attachment_identifier {
+ fixed64 cdnId = 1;
+ string cdnKey = 15;
+ }
+ // Cross-client identifier for this attachment among all attachments on the
+ // owning message.
+ optional bytes clientUuid = 20;
+ optional string contentType = 2;
+ optional bytes key = 3;
+ optional uint32 size = 4;
+ optional bytes thumbnail = 5;
+ optional bytes digest = 6;
+ reserved /* incrementalMac with implicit chunk sizing */ 16;
+ reserved /* incrementalMac for all attachment types */ 18;
+ optional bytes incrementalMac = 19;
+ optional uint32 chunkSize = 17;
+ optional string fileName = 7;
+ optional uint32 flags = 8;
+ optional uint32 width = 9;
+ optional uint32 height = 10;
+ optional string caption = 11;
+ optional string blurHash = 12;
+ optional uint64 uploadTimestamp = 13;
+ optional uint32 cdnNumber = 14;
+ // Next ID: 21
+}
+
+message GroupContextV2 {
+ optional bytes masterKey = 1;
+ optional uint32 revision = 2;
+ optional bytes groupChange = 3;
+}
+
+message ContactDetails {
+ message Avatar {
+ optional string contentType = 1;
+ optional uint32 length = 2;
+ }
+
+ optional string number = 1;
+ optional string aci = 9;
+ optional bytes aciBinary = 13; // 16-byte UUID
+ optional string name = 2;
+ optional Avatar avatar = 3;
+ reserved /* color */ 4;
+ reserved /* verified */ 5;
+ reserved /* profileKey */ 6;
+ reserved /* blocked */ 7;
+ optional uint32 expireTimer = 8;
+ optional uint32 expireTimerVersion = 12;
+ optional uint32 inboxPosition = 10;
+ reserved /* archived */ 11;
+ // NEXT ID: 14
+}
+
+message PaymentAddress {
+ message MobileCoin {
+ optional bytes publicAddress = 1;
+ optional bytes signature = 2;
+ }
+
+ oneof Address {
+ MobileCoin mobileCoin = 1;
+ }
+}
+
+message DecryptionErrorMessage {
+ optional bytes ratchetKey = 1; // set to the public ratchet key from the SignalMessage if a 1-1 payload fails to decrypt
+ optional uint64 timestamp = 2;
+ optional uint32 deviceId = 3;
+}
+
+message PniSignatureMessage {
+ optional bytes pni = 1;
+ // Signature *by* the PNI identity key *of* the ACI identity key
+ optional bytes signature = 2;
+}
+
+message EditMessage {
+ optional uint64 targetSentTimestamp = 1;
+ optional DataMessage dataMessage = 2;
+}
+
+message BodyRange {
+ enum Style {
+ NONE = 0;
+ BOLD = 1;
+ ITALIC = 2;
+ SPOILER = 3;
+ STRIKETHROUGH = 4;
+ MONOSPACE = 5;
+ }
+
+ optional uint32 start = 1; // Starting index in UTF-16 code units/raw string representation
+ optional uint32 length = 2; // Length of range in UTF-16 code units/raw string representation
+
+ oneof associatedValue {
+ string mentionAci = 3;
+ Style style = 4;
+ bytes mentionAciBinary = 5; // 16-byte UUID
+ }
+}
+
+message AddressableMessage {
+ oneof author {
+ string authorServiceId = 1;
+ string authorE164 = 2;
+ bytes authorServiceIdBinary = 4; // service ID binary (i.e. 16 byte UUID for ACI, 1 byte prefix + 16 byte UUID for PNI)
+ }
+ optional uint64 sentTimestamp = 3;
+}
+
+message ConversationIdentifier {
+ oneof identifier {
+ string threadServiceId = 1;
+ bytes threadGroupId = 2;
+ string threadE164 = 3;
+ bytes threadServiceIdBinary = 4; // service ID binary (i.e. 16 byte UUID for ACI, 1 byte prefix + 16 byte UUID for PNI)
+ }
+}
diff --git a/packages/mock-server/protos/SignalStorage.proto b/packages/mock-server/protos/SignalStorage.proto
new file mode 100644
index 0000000000..05f02e3b6a
--- /dev/null
+++ b/packages/mock-server/protos/SignalStorage.proto
@@ -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;
+}
diff --git a/packages/mock-server/protos/Stickers.proto b/packages/mock-server/protos/Stickers.proto
new file mode 100644
index 0000000000..a19bf745a9
--- /dev/null
+++ b/packages/mock-server/protos/Stickers.proto
@@ -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;
+}
diff --git a/packages/mock-server/protos/SubProtocol.proto b/packages/mock-server/protos/SubProtocol.proto
new file mode 100644
index 0000000000..f416073269
--- /dev/null
+++ b/packages/mock-server/protos/SubProtocol.proto
@@ -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;
+}
diff --git a/packages/mock-server/protos/UnidentifiedDelivery.proto b/packages/mock-server/protos/UnidentifiedDelivery.proto
new file mode 100644
index 0000000000..255ab6eeeb
--- /dev/null
+++ b/packages/mock-server/protos/UnidentifiedDelivery.proto
@@ -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;
+}
diff --git a/packages/mock-server/protos/server/CallQualitySurveyPubSub.proto b/packages/mock-server/protos/server/CallQualitySurveyPubSub.proto
new file mode 100644
index 0000000000..86a915ef52
--- /dev/null
+++ b/packages/mock-server/protos/server/CallQualitySurveyPubSub.proto
@@ -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;
+}
diff --git a/packages/mock-server/protos/server/DisconnectionRequests.proto b/packages/mock-server/protos/server/DisconnectionRequests.proto
new file mode 100644
index 0000000000..78ac8c076f
--- /dev/null
+++ b/packages/mock-server/protos/server/DisconnectionRequests.proto
@@ -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;
+}
diff --git a/packages/mock-server/protos/server/DonationsPubsub.proto b/packages/mock-server/protos/server/DonationsPubsub.proto
new file mode 100644
index 0000000000..4764f28fd6
--- /dev/null
+++ b/packages/mock-server/protos/server/DonationsPubsub.proto
@@ -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;
+}
diff --git a/packages/mock-server/protos/server/KeyTransparencyService.proto b/packages/mock-server/protos/server/KeyTransparencyService.proto
new file mode 100644
index 0000000000..ad0a461941
--- /dev/null
+++ b/packages/mock-server/protos/server/KeyTransparencyService.proto
@@ -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;
+}
diff --git a/packages/mock-server/protos/server/PubSubMessage.proto b/packages/mock-server/protos/server/PubSubMessage.proto
new file mode 100644
index 0000000000..de8dd31db3
--- /dev/null
+++ b/packages/mock-server/protos/server/PubSubMessage.proto
@@ -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;
+}
diff --git a/packages/mock-server/protos/server/RegistrationService.proto b/packages/mock-server/protos/server/RegistrationService.proto
new file mode 100644
index 0000000000..e1f9bc1f30
--- /dev/null
+++ b/packages/mock-server/protos/server/RegistrationService.proto
@@ -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;
+}
diff --git a/packages/mock-server/protos/server/TextSecure.proto b/packages/mock-server/protos/server/TextSecure.proto
new file mode 100644
index 0000000000..7f578dc8b7
--- /dev/null
+++ b/packages/mock-server/protos/server/TextSecure.proto
@@ -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;
+}
diff --git a/packages/mock-server/protos/server/WebSocketConnectionEvent.proto b/packages/mock-server/protos/server/WebSocketConnectionEvent.proto
new file mode 100644
index 0000000000..7d37cea5bc
--- /dev/null
+++ b/packages/mock-server/protos/server/WebSocketConnectionEvent.proto
@@ -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 {
+}
diff --git a/packages/mock-server/protos/server/google.proto b/packages/mock-server/protos/server/google.proto
new file mode 100644
index 0000000000..e133a6fdca
--- /dev/null
+++ b/packages/mock-server/protos/server/google.proto
@@ -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 {}
diff --git a/packages/mock-server/protos/server/org/signal/chat/account.proto b/packages/mock-server/protos/server/org/signal/chat/account.proto
new file mode 100644
index 0000000000..3318cbf3c7
--- /dev/null
+++ b/packages/mock-server/protos/server/org/signal/chat/account.proto
@@ -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"];
+ }
+}
diff --git a/packages/mock-server/protos/server/org/signal/chat/attachments.proto b/packages/mock-server/protos/server/org/signal/chat/attachments.proto
new file mode 100644
index 0000000000..3939002e20
--- /dev/null
+++ b/packages/mock-server/protos/server/org/signal/chat/attachments.proto
@@ -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"];
+ }
+}
diff --git a/packages/mock-server/protos/server/org/signal/chat/backups.proto b/packages/mock-server/protos/server/org/signal/chat/backups.proto
new file mode 100644
index 0000000000..01b09a6f6a
--- /dev/null
+++ b/packages/mock-server/protos/server/org/signal/chat/backups.proto
@@ -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 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 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 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"];
+ }
+}
diff --git a/packages/mock-server/protos/server/org/signal/chat/call_quality.proto b/packages/mock-server/protos/server/org/signal/chat/call_quality.proto
new file mode 100644
index 0000000000..e29ad89e3c
--- /dev/null
+++ b/packages/mock-server/protos/server/org/signal/chat/call_quality.proto
@@ -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 {
+}
diff --git a/packages/mock-server/protos/server/org/signal/chat/calling.proto b/packages/mock-server/protos/server/org/signal/chat/calling.proto
new file mode 100644
index 0000000000..7b566a4f10
--- /dev/null
+++ b/packages/mock-server/protos/server/org/signal/chat/calling.proto
@@ -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;
+}
diff --git a/packages/mock-server/protos/server/org/signal/chat/common.proto b/packages/mock-server/protos/server/org/signal/chat/common.proto
new file mode 100644
index 0000000000..21981c49a1
--- /dev/null
+++ b/packages/mock-server/protos/server/org/signal/chat/common.proto
@@ -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 headers = 3;
+
+ // The URL to upload to with the appropriate protocol
+ string signed_upload_location = 4;
+}
diff --git a/packages/mock-server/protos/server/org/signal/chat/credentials.proto b/packages/mock-server/protos/server/org/signal/chat/credentials.proto
new file mode 100644
index 0000000000..59ccb0cb09
--- /dev/null
+++ b/packages/mock-server/protos/server/org/signal/chat/credentials.proto
@@ -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 matches = 1;
+}
diff --git a/packages/mock-server/protos/server/org/signal/chat/device.proto b/packages/mock-server/protos/server/org/signal/chat/device.proto
new file mode 100644
index 0000000000..90eeb7ca1e
--- /dev/null
+++ b/packages/mock-server/protos/server/org/signal/chat/device.proto
@@ -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 {}
diff --git a/packages/mock-server/protos/server/org/signal/chat/errors.proto b/packages/mock-server/protos/server/org/signal/chat/errors.proto
new file mode 100644
index 0000000000..356f8aa994
--- /dev/null
+++ b/packages/mock-server/protos/server/org/signal/chat/errors.proto
@@ -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;
+}
diff --git a/packages/mock-server/protos/server/org/signal/chat/keys.proto b/packages/mock-server/protos/server/org/signal/chat/keys.proto
new file mode 100644
index 0000000000..cbc2b9bd92
--- /dev/null
+++ b/packages/mock-server/protos/server/org/signal/chat/keys.proto
@@ -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 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;
+}
diff --git a/packages/mock-server/protos/server/org/signal/chat/messages.proto b/packages/mock-server/protos/server/org/signal/chat/messages.proto
new file mode 100644
index 0000000000..ec5980f01e
--- /dev/null
+++ b/packages/mock-server/protos/server/org/signal/chat/messages.proto
@@ -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 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;
+}
diff --git a/packages/mock-server/protos/server/org/signal/chat/payments.proto b/packages/mock-server/protos/server/org/signal/chat/payments.proto
new file mode 100644
index 0000000000..901f746fb0
--- /dev/null
+++ b/packages/mock-server/protos/server/org/signal/chat/payments.proto
@@ -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 conversions = 2;
+ }
+
+ uint64 timestamp = 1;
+
+ repeated CurrencyConversionEntity currencies = 2;
+}
diff --git a/packages/mock-server/protos/server/org/signal/chat/profile.proto b/packages/mock-server/protos/server/org/signal/chat/profile.proto
new file mode 100644
index 0000000000..196d7fa376
--- /dev/null
+++ b/packages/mock-server/protos/server/org/signal/chat/profile.proto
@@ -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;
+}
diff --git a/packages/mock-server/protos/server/org/signal/chat/require.proto b/packages/mock-server/protos/server/org/signal/chat/require.proto
new file mode 100644
index 0000000000..071c12a2a8
--- /dev/null
+++ b/packages/mock-server/protos/server/org/signal/chat/require.proto
@@ -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;
+}
+
diff --git a/packages/mock-server/protos/server/org/signal/chat/tag.proto b/packages/mock-server/protos/server/org/signal/chat/tag.proto
new file mode 100644
index 0000000000..507a8056e5
--- /dev/null
+++ b/packages/mock-server/protos/server/org/signal/chat/tag.proto
@@ -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;
+}
diff --git a/packages/mock-server/src/api/group.ts b/packages/mock-server/src/api/group.ts
new file mode 100644
index 0000000000..52e613cab8
--- /dev/null
+++ b/packages/mock-server/src/api/group.ts
@@ -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;
+}>;
+
+function encryptBlob(
+ cipher: ClientZkGroupCipher,
+ proto: Proto.GroupAttributeBlob.Params,
+): Buffer {
+ const plaintext = Proto.GroupAttributeBlob.encode(proto);
+ return Buffer.from(cipher.encryptBlob(plaintext));
+}
+
+function decryptBlob(
+ cipher: ClientZkGroupCipher,
+ ciphertext: Uint8Array,
+): 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 {
+ 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 {
+ const cipher = new ClientZkGroupCipher(this.secretParams);
+ return Buffer.from(
+ cipher
+ .encryptServiceId(ServiceId.parseFromServiceIdString(serviceId))
+ .serialize(),
+ );
+ }
+
+ public decryptServiceId(
+ ciphertext: Uint8Array,
+ ): 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 {
+ return encryptBlob(new ClientZkGroupCipher(this.secretParams), proto);
+ }
+}
diff --git a/packages/mock-server/src/api/primary-device.ts b/packages/mock-server/src/api/primary-device.ts
new file mode 100644
index 0000000000..4f9f15820c
--- /dev/null
+++ b/packages/mock-server/src/api/primary-device.ts
@@ -0,0 +1,2449 @@
+// Copyright 2022 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import assert from 'assert';
+import crypto from 'crypto';
+import {
+ Aci,
+ CiphertextMessageType,
+ DecryptionErrorMessage,
+ IdentityKeyPair,
+ IdentityKeyStore,
+ KEMKeyPair,
+ KyberPreKeyRecord,
+ KyberPreKeyStore as KyberPreKeyStoreBase,
+ PlaintextContent,
+ Pni,
+ PreKeyBundle,
+ PreKeyRecord,
+ PreKeySignalMessage,
+ PreKeyStore as PreKeyStoreBase,
+ PrivateKey,
+ ProtocolAddress,
+ PublicKey,
+ SenderCertificate,
+ SenderKeyDistributionMessage,
+ SenderKeyRecord,
+ SenderKeyStore as SenderKeyStoreBase,
+ ServiceId,
+ SessionRecord,
+ SessionStore as SessionStoreBase,
+ SignalMessage,
+ SignedPreKeyRecord,
+ SignedPreKeyStore as SignedPreKeyStoreBase,
+ Uuid,
+} from '@signalapp/libsignal-client';
+import * as SignalClient from '@signalapp/libsignal-client';
+import { AccountEntropyPool } from '@signalapp/libsignal-client/dist/AccountKeys';
+import createDebug from 'debug';
+import {
+ ClientZkProfileOperations,
+ ExpiringProfileKeyCredentialResponse,
+ GroupMasterKey,
+ GroupSecretParams,
+ ProfileKey,
+ ProfileKeyCredentialPresentation,
+ ProfileKeyCredentialRequest,
+ ServerPublicParams,
+} from '@signalapp/libsignal-client/zkgroup';
+
+import { signalservice as Proto } from '../../protos/compiled';
+import {
+ AciString,
+ DeviceId,
+ KyberPreKey,
+ PniString,
+ PreKey,
+ ServiceIdKind,
+ ServiceIdString,
+} from '../types';
+import { Contact } from '../data/contacts';
+import { Group as GroupData } from '../data/group';
+import {
+ decryptStorageItem,
+ decryptStorageManifest,
+ deriveAccessKey,
+ deriveMasterKey,
+ deriveStorageKey,
+ encryptProfileName,
+} from '../crypto';
+import {
+ EnvelopeType,
+ ModifyGroupOptions,
+ ModifyGroupResult,
+ StorageWriteResult,
+} from '../server/base';
+import { ServerGroup } from '../server/group';
+import {
+ ChangeNumberOptions,
+ Device,
+ DeviceKeys,
+ SingleUseKey,
+} from '../data/device';
+import { PromiseQueue, addressToString, generateRegistrationId } from '../util';
+import { Group } from './group';
+import { StorageState } from './storage-state';
+
+const debug = createDebug('mock:primary-device');
+
+export type Config = Readonly<{
+ profileName: string;
+ contacts: Proto.AttachmentPointer.Params;
+ trustRoot: PublicKey;
+ serverPublicParams: ServerPublicParams;
+
+ // Server callbacks
+ generateNumber: () => Promise;
+ generatePni: () => Promise;
+ changeDeviceNumber: (
+ device: Device,
+ options: ChangeNumberOptions,
+ ) => Promise;
+ send: (device: Device, message: Buffer) => Promise;
+ getSenderCertificate: () => Promise;
+ getDeviceByServiceId: (
+ serviceId: ServiceIdString,
+ deviceId?: DeviceId,
+ ) => Promise;
+ issueExpiringProfileKeyCredential: (
+ device: Device,
+ request: ProfileKeyCredentialRequest,
+ ) => Promise | undefined>;
+
+ getGroup: (
+ publicParams: Uint8Array,
+ ) => Promise;
+ createGroup: (group: Proto.Group.Params) => Promise;
+ modifyGroup: (options: ModifyGroupOptions) => Promise;
+ waitForGroupUpdate: (group: GroupData) => Promise;
+
+ getStorageManifest: () => Promise;
+ getStorageItem: (
+ key: Buffer,
+ ) => Promise | undefined>;
+ getAllStorageKeys: () => Promise>>;
+ waitForStorageManifest: (afterVersion?: bigint) => Promise;
+ applyStorageWrite: (
+ operation: Proto.WriteOperation.Params,
+ shouldNotify?: boolean,
+ ) => Promise;
+}>;
+
+export type EncryptOptions = Readonly<{
+ timestamp?: number;
+ sealed?: boolean;
+ serviceIdKind?: ServiceIdKind;
+ updatedPni?: PniString;
+ // Sender Key
+ distributionId?: string;
+ group?: Group;
+ skipSkdmSend?: boolean;
+}>;
+
+export type EncryptTextOptions = EncryptOptions &
+ Readonly<{
+ withProfileKey?: boolean;
+ withPniSignature?: boolean;
+ }>;
+
+export type CreateGroupOptions = Readonly<{
+ title: string;
+ members: ReadonlyArray;
+}>;
+
+export type SendUpdateToList = ReadonlyArray<
+ Readonly<{
+ device: Device;
+ options?: EncryptOptions;
+ }>
+>;
+
+export type GroupActionsOptions = Readonly<{
+ timestamp?: number;
+ sendUpdateTo?: SendUpdateToList;
+}>;
+
+export type InviteToGroupOptions = Readonly<
+ GroupActionsOptions & {
+ serviceIdKind?: ServiceIdKind;
+ }
+>;
+
+export type AcceptPniInviteOptions = GroupActionsOptions;
+
+export type SyncSentOptions = Readonly<{
+ timestamp: number;
+ destinationServiceId: ServiceIdString;
+}>;
+
+export type FetchStorageOptions = Readonly<{
+ timestamp: number;
+}>;
+
+export type SendStickerPackSyncOptions = Readonly<{
+ type: 'install' | 'remove';
+ packId: Buffer;
+ packKey: Buffer;
+ timestamp?: number;
+}>;
+
+export type SyncReadMessage = Readonly<{
+ senderAci: AciString;
+ timestamp: number;
+}>;
+
+export type SyncReadOptions = Readonly<{
+ timestamp?: number;
+ messages: ReadonlyArray;
+}>;
+
+export enum ReceiptType {
+ Delivery = 'Delivery',
+ Read = 'Read',
+}
+
+export type ReceiptOptions = Readonly<{
+ timestamp?: number;
+
+ type: ReceiptType;
+ messageTimestamps: ReadonlyArray;
+}>;
+
+export type UnencryptedReceiptOptions = Readonly<{
+ timestamp?: number;
+ messageTimestamp: number;
+}>;
+
+export type ContentQueueEntry = Readonly<{
+ source: Device;
+ serviceIdKind: ServiceIdKind;
+ envelopeType: EnvelopeType;
+ content: Proto.Content;
+}>;
+
+export type DecryptionErrorQueueEntry = ContentQueueEntry &
+ Readonly<{
+ timestamp: number;
+ ratchetKey: PublicKey | undefined;
+ senderDevice: number;
+ }>;
+
+export type MessageQueueEntry = ContentQueueEntry &
+ Readonly<{
+ body: string;
+ dataMessage: Proto.DataMessage;
+ }>;
+
+export type ReceiptQueueEntry = ContentQueueEntry &
+ Readonly<{
+ receiptMessage: Proto.ReceiptMessage;
+ }>;
+
+export type StoryQueueEntry = ContentQueueEntry &
+ Readonly<{
+ storyMessage: Proto.StoryMessage;
+ }>;
+
+export type EditMessageQueueEntry = ContentQueueEntry &
+ Readonly<{
+ editMessage: Proto.EditMessage;
+ }>;
+
+export type SyncMessageQueueEntry = Readonly<{
+ source: Device;
+ syncMessage: Proto.SyncMessage;
+}>;
+
+export type PrepareChangeNumberEntry = Readonly<{
+ device: Device;
+ envelope: Buffer;
+}>;
+
+export type PrepareChangeNumberResult = ReadonlyArray;
+
+enum SyncState {
+ Empty = 0,
+ Contacts = 1 << 0,
+ Groups = 1 << 1,
+ Blocked = 1 << 2,
+ Configuration = 1 << 3,
+ Keys = 1 << 4,
+
+ Complete = Contacts | Blocked | Configuration,
+}
+
+type SyncEntry = {
+ state: SyncState;
+ onComplete: Promise;
+ complete: () => void;
+};
+
+type DecryptResult = Readonly<{
+ unsealedSource: Device;
+ content: Proto.Content;
+ envelopeType: EnvelopeType;
+}>;
+
+export const EMPTY_GROUP_ACTIONS: Proto.GroupChange.Actions.Params = {
+ sourceUserId: null,
+ version: null,
+ groupId: null,
+ 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,
+};
+
+export const EMPTY_DATA_MESSAGE: Proto.DataMessage.Params = {
+ body: null,
+ attachments: null,
+ groupV2: null,
+ flags: null,
+ expireTimer: null,
+ expireTimerVersion: null,
+ profileKey: null,
+ timestamp: null,
+ quote: null,
+ contact: null,
+ preview: null,
+ sticker: null,
+ requiredProtocolVersion: null,
+ isViewOnce: null,
+ reaction: null,
+ delete: null,
+ bodyRanges: null,
+ groupCallUpdate: null,
+ payment: null,
+ storyContext: null,
+ giftBadge: null,
+ pollCreate: null,
+ pollTerminate: null,
+ pollVote: null,
+ pinMessage: null,
+ unpinMessage: null,
+ adminDelete: null,
+};
+
+class SignedPreKeyStore extends SignedPreKeyStoreBase {
+ private lastId = 0;
+ private readonly records = new Map();
+
+ async saveSignedPreKey(
+ id: number,
+ record: SignedPreKeyRecord,
+ ): Promise {
+ this.records.set(id, record);
+ }
+
+ async getSignedPreKey(id: number): Promise {
+ const result = this.records.get(id);
+ if (!result) {
+ throw new Error(`Signed pre key not found: ${id}`);
+ }
+ return result;
+ }
+
+ public getNextId(): number {
+ this.lastId += 1;
+
+ // Note: intentionally starting from 1
+ return this.lastId;
+ }
+}
+
+class PreKeyStore extends PreKeyStoreBase {
+ private lastId = 0;
+ private readonly records = new Map();
+
+ async savePreKey(id: number, record: PreKeyRecord): Promise {
+ this.records.set(id, record);
+ }
+
+ async getPreKey(id: number): Promise {
+ const record = this.records.get(id);
+ if (!record) {
+ throw new Error(`Pre key not found: ${id}`);
+ }
+ return record;
+ }
+
+ async removePreKey(id: number): Promise {
+ this.records.delete(id);
+ }
+
+ public getNextId(): number {
+ this.lastId += 1;
+
+ // Note: intentionally starting from 1
+ return this.lastId;
+ }
+}
+
+class KyberPreKeyStore extends KyberPreKeyStoreBase {
+ private lastId = 0;
+ private readonly records = new Map<
+ number,
+ {
+ isLastResort: boolean;
+ record: KyberPreKeyRecord;
+ }
+ >();
+
+ async saveKyberPreKey(id: number, record: KyberPreKeyRecord): Promise {
+ if (this.records.get(id)) {
+ throw new Error(`saveKyberPreKey: id ${id} has already been used`);
+ }
+ this.records.set(id, { isLastResort: false, record });
+ }
+
+ async getKyberPreKey(id: number): Promise {
+ const item = this.records.get(id);
+ if (!item?.record) {
+ throw new Error(`Kyber pre key not found: ${id}`);
+ }
+ return item.record;
+ }
+
+ async markKyberPreKeyUsed(id: number): Promise {
+ const item = this.records.get(id);
+ if (!item || item.isLastResort) {
+ return;
+ }
+ this.records.delete(id);
+ }
+
+ async saveLastResortKey(
+ id: number,
+ record: KyberPreKeyRecord,
+ ): Promise {
+ if (this.records.get(id)) {
+ throw new Error(`saveLastResortKey: id ${id} has already been used`);
+ }
+ this.records.set(id, { isLastResort: true, record });
+ }
+
+ public getNextId(): number {
+ this.lastId += 1;
+
+ // Note: intentionally starting from 1
+ return this.lastId;
+ }
+}
+
+class IdentityStore extends IdentityKeyStore {
+ private knownIdentities = new Map();
+
+ constructor(
+ private privateKey: PrivateKey,
+ private registrationId: number,
+ ) {
+ super();
+ }
+
+ async getIdentityKey(): Promise {
+ return this.privateKey;
+ }
+
+ async getLocalRegistrationId(): Promise {
+ return this.registrationId;
+ }
+
+ async saveIdentity(
+ name: ProtocolAddress,
+ key: PublicKey,
+ ): Promise {
+ this.knownIdentities.set(addressToString(name), key);
+ return SignalClient.IdentityChange.ReplacedExisting;
+ }
+
+ async isTrustedIdentity(): Promise {
+ // We trust everyone
+ return true;
+ }
+
+ async getIdentity(name: ProtocolAddress): Promise {
+ return this.knownIdentities.get(addressToString(name)) ?? null;
+ }
+
+ // Not part of IdentityKeyStore API
+
+ async updateIdentityKey(privateKey: PrivateKey): Promise {
+ this.privateKey = privateKey;
+ }
+
+ async updateLocalRegistrationId(registrationId: number): Promise {
+ this.registrationId = registrationId;
+ }
+}
+
+export class SessionStore extends SessionStoreBase {
+ private readonly sessions = new Map();
+
+ async saveSession(
+ name: ProtocolAddress,
+ record: SessionRecord,
+ ): Promise {
+ this.sessions.set(addressToString(name), record);
+ }
+
+ async getSession(name: ProtocolAddress): Promise {
+ return this.sessions.get(addressToString(name)) ?? null;
+ }
+
+ async getExistingSessions(
+ addresses: Array,
+ ): Promise> {
+ return addresses.map((name) => {
+ const existing = this.sessions.get(addressToString(name));
+ if (!existing) {
+ throw new Error('Existing session not found');
+ }
+ return existing;
+ });
+ }
+}
+
+export class SenderKeyStore extends SenderKeyStoreBase {
+ private readonly keys = new Map();
+
+ async saveSenderKey(
+ sender: ProtocolAddress,
+ distributionId: Uuid,
+ record: SenderKeyRecord,
+ ): Promise {
+ this.keys.set(`${sender.toString()}.${distributionId}`, record);
+ }
+ async getSenderKey(
+ sender: ProtocolAddress,
+ distributionId: Uuid,
+ ): Promise {
+ const key = this.keys.get(`${sender.toString()}.${distributionId}`);
+ return key ?? null;
+ }
+}
+
+export class PrimaryDevice {
+ private isInitialized = false;
+ private lockPromise: Promise | undefined;
+
+ private readonly syncStates = new WeakMap();
+ private readonly storageKey: Buffer;
+ private readonly privateKey = PrivateKey.generate();
+ private pniPrivateKey = PrivateKey.generate();
+ private readonly contactsBlob: Proto.AttachmentPointer.Params;
+ private privSenderCertificate: SenderCertificate | undefined;
+ private readonly decryptionErrorQueue =
+ new PromiseQueue({
+ name: 'PrimaryDevice.decryptionErrorQueue',
+ });
+ private readonly messageQueue = new PromiseQueue({
+ name: 'PrimaryDevice.messageQueue',
+ });
+ private readonly receiptQueue = new PromiseQueue({
+ name: 'PrimaryDevice.receiptQueue',
+ });
+ private readonly storyQueue = new PromiseQueue({
+ name: 'PrimaryDevice.storyQueue',
+ });
+ private readonly editMessageQueue = new PromiseQueue({
+ name: 'PrimaryDevice.editMessageQueue',
+ });
+ private readonly syncMessageQueue = new PromiseQueue({
+ name: 'PrimaryDevice.syncMessageQueue',
+ });
+ private privPniPublicKey = this.pniPrivateKey.getPublicKey();
+
+ // Various stores
+ private readonly signedPreKeys = new Map();
+ private readonly preKeys = new Map();
+ private readonly kyberPreKeys = new Map();
+ private readonly sessions = new SessionStore();
+ private readonly senderKeys = new Map();
+ private readonly identity = new Map();
+
+ public readonly publicKey = this.privateKey.getPublicKey();
+ public readonly profileKey: ProfileKey;
+ public readonly profileName: string;
+ public readonly secondaryDevices = new Array();
+ public readonly accountEntropyPool = AccountEntropyPool.generate();
+ public readonly masterKey = deriveMasterKey(this.accountEntropyPool);
+ public readonly mediaRootBackupKey = crypto.randomBytes(32);
+
+ // Forwarded in provisioning envelope
+ public ephemeralBackupKey: Buffer | undefined;
+
+ // Overridable to test legacy encryption modes
+ public storageRecordIkm: Buffer | undefined =
+ crypto.randomBytes(32);
+
+ // TODO(indutny): make primary device type configurable
+ public readonly userAgent = 'OWI';
+
+ constructor(
+ public readonly device: Device,
+ private readonly config: Config,
+ ) {
+ for (const serviceIdKind of [ServiceIdKind.ACI, ServiceIdKind.PNI]) {
+ this.identity.set(
+ serviceIdKind,
+ new IdentityStore(
+ serviceIdKind === ServiceIdKind.ACI
+ ? this.privateKey
+ : this.pniPrivateKey,
+ this.device.getRegistrationId(serviceIdKind),
+ ),
+ );
+
+ this.preKeys.set(serviceIdKind, new PreKeyStore());
+ this.kyberPreKeys.set(serviceIdKind, new KyberPreKeyStore());
+ this.signedPreKeys.set(serviceIdKind, new SignedPreKeyStore());
+ this.senderKeys.set(serviceIdKind, new SenderKeyStore());
+ }
+
+ this.contactsBlob = this.config.contacts;
+ this.profileName = config.profileName;
+
+ this.profileKey = new ProfileKey(crypto.randomBytes(32));
+ this.storageKey = deriveStorageKey(this.masterKey);
+
+ this.device.profileName = encryptProfileName(
+ this.profileKey.serialize(),
+ this.profileName,
+ );
+ }
+
+ public async init(): Promise {
+ if (this.isInitialized) {
+ throw new Error('Already initialized');
+ }
+
+ for (const serviceIdKind of [ServiceIdKind.ACI, ServiceIdKind.PNI]) {
+ const identity = this.identity.get(serviceIdKind);
+ assert.ok(identity);
+ await identity.saveIdentity(
+ this.device.getAddressByKind(serviceIdKind),
+ this.getPublicKey(serviceIdKind),
+ );
+ await this.device.setKeys(
+ serviceIdKind,
+ await this.generateKeys(this.device, serviceIdKind),
+ );
+ }
+
+ this.privSenderCertificate = await this.config.getSenderCertificate();
+
+ this.device.profileKeyCommitment = this.profileKey.getCommitment(
+ Aci.parseFromServiceIdString(this.device.aci),
+ );
+ this.device.accessKey = deriveAccessKey(this.profileKey.serialize());
+
+ this.isInitialized = true;
+ }
+
+ public toContact(): Contact {
+ return {
+ aciBinary: this.device.aciRawUuid,
+ number: this.device.number,
+ profileName: this.profileName,
+ };
+ }
+
+ public addSecondaryDevice(device: Device): void {
+ this.secondaryDevices.push(device);
+
+ device.profileName = this.device.profileName;
+ device.profileKeyCommitment = this.device.profileKeyCommitment;
+ device.accessKey = this.device.accessKey;
+ }
+
+ //
+ // Keys
+ //
+
+ public async generateKeys(
+ device: Device,
+ serviceIdKind: ServiceIdKind,
+ ): Promise<
+ DeviceKeys & {
+ // Note: these records are only used in the PNP change number scenario
+ signedPreKeyRecord: SignedPreKeyRecord;
+ lastResortKeyRecord: KyberPreKeyRecord;
+ }
+ > {
+ const isPrimary = device === this.device;
+
+ const signedPreKey = PrivateKey.generate();
+ const signedPreKeySig = this.getPrivateKey(serviceIdKind).sign(
+ signedPreKey.getPublicKey().serialize(),
+ );
+ const signedPreKeyId =
+ this.signedPreKeys.get(serviceIdKind)?.getNextId() ?? 1;
+ const signedPreKeyRecord = SignedPreKeyRecord.new(
+ signedPreKeyId,
+ Date.now(),
+ signedPreKey.getPublicKey(),
+ signedPreKey,
+ signedPreKeySig,
+ );
+ if (isPrimary) {
+ await this.signedPreKeys
+ .get(serviceIdKind)
+ ?.saveSignedPreKey(signedPreKeyId, signedPreKeyRecord);
+ }
+
+ const lastResortKeyId =
+ this.kyberPreKeys.get(serviceIdKind)?.getNextId() ?? 1;
+ const lastResortKeyRecord = this.generateKyberPreKey(
+ lastResortKeyId,
+ serviceIdKind,
+ );
+ if (isPrimary) {
+ await this.kyberPreKeys
+ .get(serviceIdKind)
+ ?.saveLastResortKey(lastResortKeyId, lastResortKeyRecord);
+ }
+
+ return {
+ identityKey: this.getPublicKey(serviceIdKind),
+ signedPreKey: {
+ keyId: signedPreKeyId,
+ publicKey: signedPreKey.getPublicKey(),
+ signature: Buffer.from(signedPreKeySig),
+ },
+ lastResortKey: {
+ keyId: lastResortKeyId,
+ publicKey: lastResortKeyRecord.publicKey(),
+ signature: Buffer.from(lastResortKeyRecord.signature()),
+ },
+ preKeyIterator: this.getPreKeyIterator(device, serviceIdKind),
+ kyberPreKeyIterator: this.getKyberPreKeyIterator(device, serviceIdKind),
+
+ signedPreKeyRecord,
+ lastResortKeyRecord,
+ };
+ }
+
+ private async *getPreKeyIterator(
+ device: Device,
+ serviceIdKind: ServiceIdKind,
+ ): AsyncIterator {
+ const preKeyStore = this.preKeys.get(serviceIdKind);
+ assert.ok(preKeyStore, 'Missing preKey store');
+
+ const isPrimary = device === this.device;
+ if (!isPrimary) {
+ return;
+ }
+
+ while (true) {
+ const preKey = PrivateKey.generate();
+ const publicKey = preKey.getPublicKey();
+ const keyId = preKeyStore.getNextId();
+
+ const record = PreKeyRecord.new(keyId, publicKey, preKey);
+ await preKeyStore.savePreKey(keyId, record);
+
+ yield { keyId, publicKey };
+ }
+ }
+
+ private generateKyberPreKey(
+ keyId: number,
+ serviceIdKind: ServiceIdKind,
+ ): KyberPreKeyRecord {
+ const kyberPreKey = KEMKeyPair.generate();
+ const kyberPreKeySig = this.getPrivateKey(serviceIdKind).sign(
+ kyberPreKey.getPublicKey().serialize(),
+ );
+ const kyberPreKeyRecord = KyberPreKeyRecord.new(
+ keyId,
+ Date.now(),
+ kyberPreKey,
+ kyberPreKeySig,
+ );
+
+ return kyberPreKeyRecord;
+ }
+
+ private async *getKyberPreKeyIterator(
+ device: Device,
+ serviceIdKind: ServiceIdKind,
+ ): AsyncIterator {
+ const kyberPreKeyStore = this.kyberPreKeys.get(serviceIdKind);
+ assert.ok(kyberPreKeyStore, 'Missing kyberPreKeyStore store');
+
+ const isPrimary = device === this.device;
+ if (!isPrimary) {
+ return;
+ }
+
+ while (true) {
+ const keyId = kyberPreKeyStore.getNextId();
+ const record = this.generateKyberPreKey(keyId, serviceIdKind);
+
+ await kyberPreKeyStore.saveKyberPreKey(keyId, record);
+
+ yield {
+ keyId,
+ publicKey: record.publicKey(),
+ signature: Buffer.from(record.signature()),
+ };
+ }
+ }
+
+ public async getIdentityKey(
+ serviceIdKind: ServiceIdKind,
+ ): Promise {
+ const identity = this.identity.get(serviceIdKind);
+ assert.ok(identity);
+ return identity.getIdentityKey();
+ }
+
+ public getPublicKey(serviceIdKind: ServiceIdKind): PublicKey {
+ switch (serviceIdKind) {
+ case ServiceIdKind.ACI:
+ return this.publicKey;
+ case ServiceIdKind.PNI:
+ return this.privPniPublicKey;
+ }
+ }
+
+ public async addSingleUseKey(
+ target: Device,
+ key: SingleUseKey,
+ serviceIdKind = ServiceIdKind.ACI,
+ ): Promise {
+ assert.ok(this.isInitialized, 'Not initialized');
+ debug('adding singleUseKey for', target.debugId);
+
+ // Outgoing stores
+ const identity = this.identity.get(ServiceIdKind.ACI);
+ assert(identity, 'Should have an ACI identity');
+
+ await identity.saveIdentity(
+ target.getAddressByKind(serviceIdKind),
+ key.identityKey,
+ );
+
+ const bundle = PreKeyBundle.new(
+ target.getRegistrationId(serviceIdKind),
+ target.deviceId,
+ key.preKey === undefined ? null : key.preKey.keyId,
+ key.preKey === undefined ? null : key.preKey.publicKey,
+ key.signedPreKey.keyId,
+ key.signedPreKey.publicKey,
+ key.signedPreKey.signature,
+ key.identityKey,
+ key.pqPreKey.keyId,
+ key.pqPreKey.publicKey,
+ key.pqPreKey.signature,
+ );
+ await SignalClient.processPreKeyBundle(
+ bundle,
+ target.getAddressByKind(serviceIdKind),
+ this.sessions,
+ identity,
+ );
+ }
+
+ //
+ // Groups
+ //
+
+ public async getAllGroups(
+ storage: StorageState,
+ ): Promise> {
+ const records = storage.getAllGroupRecords();
+
+ return Promise.all(
+ records.map(async ({ record }) => {
+ const { groupV2 } = record;
+ const { masterKey } = groupV2;
+ assert.ok(masterKey, 'Group v2 record without master key');
+
+ const secretParams = GroupSecretParams.deriveFromMasterKey(
+ new GroupMasterKey(Buffer.from(masterKey)),
+ );
+ const publicParams = secretParams.getPublicParams().serialize();
+
+ const serverGroup = await this.config.getGroup(publicParams);
+ assert.ok(
+ serverGroup,
+ `Group not found: ${Buffer.from(publicParams).toString('base64')}`,
+ );
+
+ return new Group({
+ secretParams,
+ groupState: serverGroup.state,
+ });
+ }),
+ );
+ }
+
+ public async createGroup({
+ title,
+ members: memberDevices,
+ }: CreateGroupOptions): Promise {
+ const groupParams = GroupSecretParams.generate();
+
+ const members = await Promise.all(
+ memberDevices.map(async (member) => {
+ const presentation =
+ await member.getProfileKeyPresentation(groupParams);
+
+ return {
+ aci: member.device.aci,
+ profileKey: member.profileKey,
+ presentation,
+ joinedAtVersion: 0n,
+ };
+ }),
+ );
+
+ const clientGroup = Group.fromConfig({
+ secretParams: groupParams,
+
+ title,
+ members,
+ });
+
+ await this.config.createGroup(clientGroup.state);
+
+ return clientGroup;
+ }
+
+ public async waitForGroupUpdate(group: Group): Promise {
+ await this.config.waitForGroupUpdate(group);
+
+ const publicParams = group.publicParams.serialize();
+ const serverGroup = await this.config.getGroup(publicParams);
+ assert.ok(serverGroup, `Group not found: ${group.id}`);
+
+ return new Group({
+ secretParams: group.secretParams,
+ groupState: serverGroup.state,
+ });
+ }
+
+ async #modifyGroup(options: {
+ group: Group;
+ actions: Proto.GroupChange.Actions.Params;
+ timestamp: number;
+ sendUpdateTo: SendUpdateToList;
+ }) {
+ const { group, actions, timestamp, sendUpdateTo } = options;
+
+ const serverGroup = await this.config.getGroup(
+ group.publicParams.serialize(),
+ );
+ assert(serverGroup !== undefined, 'Group does not exist on server');
+
+ const modifyResult = await this.config.modifyGroup({
+ group: serverGroup,
+ actions: {
+ ...actions,
+ version: group.revision + 1,
+ },
+ aciCiphertext: group.encryptServiceId(this.device.aci),
+ pniCiphertext: group.encryptServiceId(this.device.pni),
+ });
+
+ assert(!modifyResult.conflict, 'Group update conflict!');
+
+ const updatedGroup = new Group({
+ secretParams: group.secretParams,
+ groupState: serverGroup.state,
+ });
+
+ if (sendUpdateTo.length) {
+ const groupV2 = {
+ ...updatedGroup.toContext(),
+ groupChange: Proto.GroupChange.encode(modifyResult.signedChange),
+ };
+
+ await Promise.all(
+ sendUpdateTo.map(async ({ device, options }) => {
+ const sync = device.aci === this.device.aci;
+
+ const encryptOptions = {
+ timestamp,
+ ...options,
+ };
+
+ const dataMessage: Proto.DataMessage.Params = {
+ ...EMPTY_DATA_MESSAGE,
+ groupV2,
+ timestamp: BigInt(encryptOptions.timestamp),
+ };
+
+ const syncMessage: Proto.SyncMessage.Params = {
+ content: {
+ sent: {
+ timestamp: BigInt(timestamp),
+ message: dataMessage,
+ destinationServiceIdBinary: device.aciBinary,
+ destinationE164: null,
+ destinationServiceId: null,
+ expirationStartTimestamp: null,
+ unidentifiedStatus: null,
+ isRecipientUpdate: null,
+ storyMessage: null,
+ storyMessageRecipients: null,
+ editMessage: null,
+ },
+ },
+ read: null,
+ stickerPackOperation: null,
+ viewed: null,
+ padding: null,
+ };
+
+ const content: Proto.Content.Params = {
+ content: sync ? { syncMessage } : { dataMessage },
+ pniSignatureMessage: null,
+ senderKeyDistributionMessage: null,
+ };
+
+ const envelope = await this.encryptContent(
+ device,
+ content,
+ encryptOptions,
+ );
+ await this.config.send(device, envelope);
+ }),
+ );
+ }
+
+ return updatedGroup;
+ }
+
+ public async inviteToGroup(
+ group: Group,
+ invitee: Device,
+ {
+ timestamp = Date.now(),
+ serviceIdKind = ServiceIdKind.ACI,
+ sendUpdateTo = [{ device: invitee, options: { serviceIdKind } }],
+ }: InviteToGroupOptions = {},
+ ): Promise {
+ const targetServiceId = invitee.getServiceIdByKind(serviceIdKind);
+ const userId = group.encryptServiceId(targetServiceId);
+
+ return this.#modifyGroup({
+ group,
+ actions: {
+ ...EMPTY_GROUP_ACTIONS,
+ addMembersPendingProfileKey: [
+ {
+ added: {
+ member: {
+ userId,
+ role: Proto.Member.Role.DEFAULT,
+ profileKey: null,
+ presentation: null,
+ joinedAtVersion: null,
+ labelEmoji: null,
+ labelString: null,
+ },
+ addedByUserId: null,
+ timestamp: null,
+ },
+ },
+ ],
+ },
+ timestamp,
+ sendUpdateTo,
+ });
+ }
+
+ public async acceptPniInvite(
+ group: Group,
+ { timestamp = Date.now(), sendUpdateTo = [] }: AcceptPniInviteOptions = {},
+ ): Promise {
+ const presentation = await this.getProfileKeyPresentation(
+ group.secretParams,
+ );
+
+ return this.#modifyGroup({
+ group,
+ actions: {
+ ...EMPTY_GROUP_ACTIONS,
+ promoteMembersPendingPniAciProfileKey: [
+ {
+ presentation: presentation.serialize(),
+ userId: null,
+ pni: null,
+ profileKey: null,
+ },
+ ],
+ },
+ timestamp,
+ sendUpdateTo,
+ });
+ }
+
+ public async modifyGroupDisappearingMessageTimer(
+ group: Group,
+ disappearingMessagesDuration: number,
+ { timestamp = Date.now(), sendUpdateTo = [] }: GroupActionsOptions = {},
+ ): Promise {
+ return this.#modifyGroup({
+ group,
+ actions: {
+ ...EMPTY_GROUP_ACTIONS,
+ modifyDisappearingMessageTimer: {
+ timer: group.encryptBlob({
+ content: { disappearingMessagesDuration },
+ }),
+ },
+ },
+ timestamp,
+ sendUpdateTo,
+ });
+ }
+
+ //
+ // Storage Service
+ //
+
+ public async waitForStorageState({
+ after,
+ predicate,
+ }: {
+ after?: StorageState;
+ // Note: predicate runs on the current state, not on previous intermediate states
+ predicate?: (state: StorageState) => boolean;
+ } = {}): Promise {
+ let afterVersion = after?.version;
+
+ while (true) {
+ debug(
+ 'waiting for storage manifest for device=%s after version=%d predicate=%s',
+ this.device.debugId,
+ afterVersion,
+ predicate !== undefined,
+ );
+
+ await this.config.waitForStorageManifest(afterVersion);
+
+ const state = await this.getStorageState();
+ assert(state, 'Missing storage state');
+
+ if (predicate !== undefined && !predicate(state)) {
+ debug(
+ 'storage manifest for device=%s version=%d did not match predicate',
+ this.device.debugId,
+ state.version,
+ );
+ afterVersion = state.version;
+ continue;
+ }
+
+ debug(
+ 'got storage manifest for device=%s version=%d',
+ this.device.debugId,
+ state.version,
+ );
+
+ return state;
+ }
+ }
+
+ public async getStorageState(): Promise {
+ const manifest = await this.config.getStorageManifest();
+ if (!manifest) {
+ return undefined;
+ }
+
+ return this.convertManifestToStorageState(manifest);
+ }
+
+ public async expectStorageState(reason: string): Promise {
+ const state = await this.getStorageState();
+ if (!state) {
+ throw new Error(`expectStorageState: no storage state, ${reason}`);
+ }
+
+ return state;
+ }
+
+ public async setStorageState(
+ state: StorageState,
+ previousState?: StorageState,
+ ): Promise {
+ const writeOperation = state.createWriteOperation({
+ storageKey: this.storageKey,
+ recordIkm: this.storageRecordIkm,
+ previous: previousState,
+ });
+ assert(writeOperation.manifest, 'write operation without manifest');
+
+ const { updated, error } = await this.config.applyStorageWrite(
+ writeOperation,
+ false,
+ );
+ if (!updated) {
+ // eslint-disable-next-line @typescript-eslint/restrict-template-expressions
+ throw new Error(`setStorageState: failed to update, ${error}`);
+ }
+
+ return this.convertManifestToStorageState(writeOperation.manifest);
+ }
+
+ public async getOrphanedStorageKeys(): Promise>> {
+ const manifest = await this.config.getStorageManifest();
+ if (!manifest) {
+ return [];
+ }
+
+ const state = await this.convertManifestToStorageState(manifest);
+ const keys = await this.config.getAllStorageKeys();
+
+ return keys.filter((key) => !state.hasKey(key));
+ }
+
+ //
+ // Sync
+ //
+
+ // TODO(indutny): timeout
+ public async waitForSync(secondaryDevice: Device): Promise {
+ debug('waiting for sync with %s', secondaryDevice.debugId);
+ const { onComplete } = this.getSyncState(secondaryDevice);
+
+ await onComplete;
+ }
+
+ public resetSyncState(secondaryDevice: Device): void {
+ this.syncStates.delete(secondaryDevice);
+ }
+
+ //
+ // Receive/Send
+ //
+
+ public async handleEnvelope(
+ source: Device | undefined,
+ serviceIdKind: ServiceIdKind,
+ envelopeType: EnvelopeType,
+ encrypted: Buffer,
+ ): Promise {
+ const {
+ unsealedSource,
+ content,
+ envelopeType: unsealedType,
+ } = await this.lock(async () => {
+ return this.decrypt(source, serviceIdKind, envelopeType, encrypted);
+ });
+
+ let handled = true;
+ if (content.content?.decryptionErrorMessage) {
+ assert.strictEqual(
+ serviceIdKind,
+ ServiceIdKind.ACI,
+ 'Got sync message on PNI',
+ );
+ this.handleResendRequest(
+ unsealedSource,
+ serviceIdKind,
+ unsealedType,
+ content,
+ content.content.decryptionErrorMessage,
+ );
+ } else if (content.content?.syncMessage) {
+ assert.strictEqual(
+ serviceIdKind,
+ ServiceIdKind.ACI,
+ 'Got sync message on PNI',
+ );
+ await this.handleSync(unsealedSource, content.content.syncMessage);
+ } else if (content.content?.dataMessage) {
+ this.handleDataMessage(
+ unsealedSource,
+ serviceIdKind,
+ unsealedType,
+ content,
+ content.content.dataMessage,
+ );
+ } else if (content.content?.storyMessage) {
+ this.handleStoryMessage(
+ unsealedSource,
+ serviceIdKind,
+ unsealedType,
+ content,
+ content.content.storyMessage,
+ );
+ } else if (content.content?.editMessage) {
+ this.handleEditMessage(
+ unsealedSource,
+ serviceIdKind,
+ unsealedType,
+ content,
+ content.content.editMessage,
+ );
+ } else if (content.content?.receiptMessage) {
+ this.handleReceiptMessage(
+ unsealedSource,
+ serviceIdKind,
+ unsealedType,
+ content,
+ content.content.receiptMessage,
+ );
+ } else {
+ handled = false;
+ }
+
+ const { senderKeyDistributionMessage } = content;
+ if (
+ senderKeyDistributionMessage != null &&
+ senderKeyDistributionMessage.length > 0
+ ) {
+ handled = true;
+ await this.processSenderKeyDistribution(
+ unsealedSource,
+ senderKeyDistributionMessage,
+ );
+ }
+
+ if (!handled) {
+ debug('unsupported message', content);
+ }
+ }
+
+ public async encryptText(
+ target: Device,
+ text: string,
+ options: EncryptTextOptions = {},
+ ): Promise> {
+ const encryptOptions = {
+ timestamp: Date.now(),
+ ...options,
+ };
+
+ let pniSignatureMessage: Proto.PniSignatureMessage.Params | null = null;
+ if (options.withPniSignature) {
+ const pniPrivate = this.getPrivateKey(ServiceIdKind.PNI);
+ const pniPublic = this.getPublicKey(ServiceIdKind.PNI);
+ const aciPublic = this.getPublicKey(ServiceIdKind.ACI);
+
+ const pniIdentity = new IdentityKeyPair(pniPublic, pniPrivate);
+
+ const signature = pniIdentity.signAlternateIdentity(aciPublic);
+
+ pniSignatureMessage = {
+ pni: Pni.parseFromServiceIdString(this.device.pni).getRawUuidBytes(),
+ signature,
+ };
+ }
+
+ const content: Proto.Content.Params = {
+ content: {
+ dataMessage: {
+ ...EMPTY_DATA_MESSAGE,
+ groupV2: options.group?.toContext() ?? null,
+ body: text,
+ profileKey: options.withProfileKey
+ ? this.profileKey.serialize()
+ : null,
+ timestamp: BigInt(encryptOptions.timestamp),
+ },
+ },
+ pniSignatureMessage,
+ senderKeyDistributionMessage: null,
+ };
+ return this.encryptContent(target, content, encryptOptions);
+ }
+
+ public async encryptSyncSent(
+ target: Device,
+ text: string,
+ options: SyncSentOptions,
+ ): Promise> {
+ const dataMessage: Proto.DataMessage.Params = {
+ ...EMPTY_DATA_MESSAGE,
+ body: text,
+ timestamp: BigInt(options.timestamp),
+ };
+
+ const content: Proto.Content.Params = {
+ content: {
+ syncMessage: {
+ content: {
+ sent: {
+ destinationServiceIdBinary: ServiceId.parseFromServiceIdString(
+ options.destinationServiceId,
+ ).getServiceIdBinary(),
+ timestamp: BigInt(options.timestamp),
+ message: dataMessage,
+ destinationE164: null,
+ unidentifiedStatus: null,
+ isRecipientUpdate: null,
+ expirationStartTimestamp: null,
+ storyMessage: null,
+ storyMessageRecipients: null,
+ editMessage: null,
+
+ destinationServiceId: null,
+ },
+ },
+ stickerPackOperation: null,
+ read: null,
+ viewed: null,
+ padding: null,
+ },
+ },
+ pniSignatureMessage: null,
+ senderKeyDistributionMessage: null,
+ };
+ return this.encryptContent(target, content, options);
+ }
+
+ public async encryptSyncRead(
+ target: Device,
+ options: SyncReadOptions,
+ ): Promise> {
+ const content: Proto.Content.Params = {
+ content: {
+ syncMessage: {
+ content: null,
+ stickerPackOperation: null,
+ read: options.messages.map(({ senderAci, timestamp }) => {
+ return {
+ senderAciBinary:
+ Aci.parseFromServiceIdString(senderAci).getRawUuidBytes(),
+ timestamp: BigInt(timestamp),
+
+ // Deprecated string field
+ senderAci: null,
+ };
+ }),
+ viewed: null,
+ padding: null,
+ },
+ },
+ pniSignatureMessage: null,
+ senderKeyDistributionMessage: null,
+ };
+ return this.encryptContent(target, content, options);
+ }
+
+ public async sendFetchStorage(options: FetchStorageOptions): Promise {
+ const content: Proto.Content.Params = {
+ content: {
+ syncMessage: {
+ content: {
+ fetchLatest: {
+ type: Proto.SyncMessage.FetchLatest.Type.STORAGE_MANIFEST,
+ },
+ },
+ stickerPackOperation: null,
+ read: null,
+ viewed: null,
+ padding: null,
+ },
+ },
+ pniSignatureMessage: null,
+ senderKeyDistributionMessage: null,
+ };
+
+ return this.broadcast('fetch storage', content, options);
+ }
+
+ public async sendStickerPackSync(
+ options: SendStickerPackSyncOptions,
+ ): Promise {
+ const Type = Proto.SyncMessage.StickerPackOperation.Type;
+
+ const content: Proto.Content.Params = {
+ content: {
+ syncMessage: {
+ content: null,
+ stickerPackOperation: [
+ {
+ packId: options.packId,
+ packKey: options.packKey,
+ type: options.type === 'install' ? Type.INSTALL : Type.REMOVE,
+ },
+ ],
+ read: null,
+ viewed: null,
+ padding: null,
+ },
+ },
+ pniSignatureMessage: null,
+ senderKeyDistributionMessage: null,
+ };
+
+ return this.broadcast('sticker pack sync', content, options);
+ }
+
+ public async encryptReceipt(
+ target: Device,
+ options: ReceiptOptions,
+ ): Promise> {
+ let type: Proto.ReceiptMessage.Type;
+ if (options.type === ReceiptType.Delivery) {
+ type = Proto.ReceiptMessage.Type.DELIVERY;
+ } else {
+ assert.strictEqual(options.type, ReceiptType.Read);
+ type = Proto.ReceiptMessage.Type.READ;
+ }
+
+ const content: Proto.Content.Params = {
+ content: {
+ receiptMessage: {
+ type,
+ timestamp: options.messageTimestamps.map((timestamp) =>
+ BigInt(timestamp),
+ ),
+ },
+ },
+ pniSignatureMessage: null,
+ senderKeyDistributionMessage: null,
+ };
+ return this.encryptContent(target, content, options);
+ }
+
+ public async sendReceipt(
+ target: Device,
+ options: ReceiptOptions,
+ ): Promise {
+ const receipt = await this.encryptReceipt(target, options);
+ return this.config.send(target, receipt);
+ }
+
+ public async sendUnencryptedReceipt(
+ target: Device,
+ { messageTimestamp, timestamp = Date.now() }: UnencryptedReceiptOptions,
+ ): Promise {
+ const envelope: Proto.Envelope.Params = {
+ type: Proto.Envelope.Type.SERVER_DELIVERY_RECEIPT,
+ clientTimestamp: BigInt(messageTimestamp),
+ serverTimestamp: BigInt(timestamp),
+ sourceServiceIdBinary: this.device.aciBinary,
+ sourceDeviceId: this.device.deviceId,
+ destinationServiceIdBinary: target.aciBinary,
+ serverGuid: null,
+ ephemeral: null,
+ urgent: null,
+ story: null,
+ reportSpamToken: null,
+ serverGuidBinary: null,
+ content: null,
+ updatedPniBinary: null,
+
+ // Deprecated string fields
+ sourceServiceId: null,
+ destinationServiceId: null,
+ updatedPni: null,
+ };
+ return this.config.send(
+ target,
+ Buffer.from(Proto.Envelope.encode(envelope)),
+ );
+ }
+
+ public async prepareChangeNumber(
+ options: EncryptOptions = {},
+ ): Promise {
+ const { timestamp = Date.now() } = options;
+
+ const newNumber = await this.config.generateNumber();
+ const newPni = await this.config.generatePni();
+ const newPniRegistrationId = generateRegistrationId();
+ const newPniIdentity = IdentityKeyPair.generate();
+
+ debug(
+ 'sending change number to %d linked devices timestamp=%d newPni=%s',
+ this.secondaryDevices.length,
+ timestamp,
+ newPni,
+ );
+
+ this.pniPrivateKey = newPniIdentity.privateKey;
+ this.privPniPublicKey = newPniIdentity.publicKey;
+
+ const allDevices = [this.device, ...this.secondaryDevices];
+
+ // Update PNI
+ await Promise.all(
+ allDevices.map(async (device) => {
+ await this.config.changeDeviceNumber(device, {
+ pni: newPni,
+ number: newNumber,
+ pniRegistrationId: newPniRegistrationId,
+ });
+ }),
+ );
+
+ const identity = this.identity.get(ServiceIdKind.PNI);
+ assert(identity, 'Should have a PNI identity');
+ await identity.updateIdentityKey(newPniIdentity.privateKey);
+ await identity.updateLocalRegistrationId(newPniRegistrationId);
+ await identity.saveIdentity(
+ this.device.getAddressByKind(ServiceIdKind.PNI),
+ this.getPublicKey(ServiceIdKind.PNI),
+ );
+
+ // Update all keys and prepare sync message
+ const results = await Promise.all(
+ allDevices.map(async (device) => {
+ const isPrimary = device === this.device;
+ const keys = await this.generateKeys(device, ServiceIdKind.PNI);
+ await device.setKeys(ServiceIdKind.PNI, keys);
+
+ if (isPrimary) {
+ return;
+ }
+
+ // Send sync message
+ const { signedPreKeyRecord, lastResortKeyRecord } = keys;
+
+ const content: Proto.Content.Params = {
+ content: {
+ syncMessage: {
+ content: {
+ pniChangeNumber: {
+ identityKeyPair: newPniIdentity.serialize(),
+ lastResortKyberPreKey: lastResortKeyRecord.serialize(),
+ signedPreKey: signedPreKeyRecord.serialize(),
+ registrationId: newPniRegistrationId,
+ newE164: newNumber,
+ },
+ },
+ read: null,
+ stickerPackOperation: null,
+ viewed: null,
+ padding: null,
+ },
+ },
+ pniSignatureMessage: null,
+ senderKeyDistributionMessage: null,
+ };
+
+ const envelope = await this.encryptContent(device, content, {
+ ...options,
+ timestamp,
+ updatedPni: this.device.pni,
+ });
+
+ return { device, envelope };
+ }),
+ );
+
+ return results.filter((entry): entry is PrepareChangeNumberEntry => {
+ return entry !== undefined;
+ });
+ }
+
+ public async sendChangeNumber(
+ result: PrepareChangeNumberResult,
+ ): Promise {
+ await Promise.all(
+ result.map(({ device, envelope }) => {
+ return this.config.send(device, envelope);
+ }),
+ );
+ }
+
+ public async changeNumber(options: EncryptOptions = {}): Promise {
+ const result = await this.prepareChangeNumber(options);
+ await this.sendChangeNumber(result);
+ }
+
+ public async sendText(
+ target: Device,
+ text: string,
+ options?: EncryptTextOptions,
+ ): Promise {
+ await this.config.send(
+ target,
+ await this.encryptText(target, text, options),
+ );
+ }
+
+ public async sendRaw(
+ target: Device,
+ content: Proto.Content.Params,
+ options?: EncryptOptions,
+ ): Promise {
+ await this.config.send(
+ target,
+ await this.encryptContent(target, content, options),
+ );
+ }
+
+ public async sendSenderKey(
+ target: Device,
+ options?: EncryptOptions,
+ ): Promise {
+ const distributionId = crypto.randomUUID();
+
+ const senderKeys = this.senderKeys.get(ServiceIdKind.ACI);
+ assert(senderKeys, 'Should have a sender key store');
+
+ const skdm = await SenderKeyDistributionMessage.create(
+ target.address,
+ distributionId,
+ senderKeys,
+ );
+
+ if (!options?.skipSkdmSend) {
+ void this.sendRaw(
+ target,
+ {
+ content: null,
+ pniSignatureMessage: null,
+ senderKeyDistributionMessage: skdm.serialize(),
+ },
+ options,
+ );
+ }
+
+ return distributionId;
+ }
+
+ public async unlink(device: Device): Promise {
+ const index = this.secondaryDevices.indexOf(device);
+ if (index === -1) {
+ throw new Error('Device was not linked');
+ }
+ this.secondaryDevices.splice(index, 1);
+ }
+
+ public async receive(
+ source: Device,
+ encrypted: Buffer,
+ ): Promise {
+ const envelope = Proto.Envelope.decode(encrypted);
+
+ if (
+ envelope.sourceServiceIdBinary != null &&
+ source.getServiceIdBinaryKind(envelope.sourceServiceIdBinary) !==
+ ServiceIdKind.ACI
+ ) {
+ throw new Error(
+ `Invalid envelope source. Expected: ${source.aci}, got PNI`,
+ );
+ }
+
+ let envelopeType: EnvelopeType;
+ if (envelope.type === Proto.Envelope.Type.DOUBLE_RATCHET) {
+ envelopeType = EnvelopeType.CipherText;
+ } else if (envelope.type === Proto.Envelope.Type.PREKEY_MESSAGE) {
+ envelopeType = EnvelopeType.PreKey;
+ } else if (envelope.type === Proto.Envelope.Type.UNIDENTIFIED_SENDER) {
+ envelopeType = EnvelopeType.SealedSender;
+ } else {
+ throw new Error('Unsupported envelope type');
+ }
+
+ const serviceIdKind = envelope.destinationServiceIdBinary?.length
+ ? this.device.getServiceIdBinaryKind(envelope.destinationServiceIdBinary)
+ : ServiceIdKind.ACI;
+
+ return this.handleEnvelope(
+ source,
+ serviceIdKind,
+ envelopeType,
+ envelope.content ? Buffer.from(envelope.content) : Buffer.alloc(0),
+ );
+ }
+
+ public async waitForMessage(): Promise {
+ return this.messageQueue.shift();
+ }
+ public getMessageQueueSize(): number {
+ return this.messageQueue.size;
+ }
+
+ public async waitForDecryptionError(): Promise {
+ return this.decryptionErrorQueue.shift();
+ }
+ public getDecryptionErrorQueueSize(): number {
+ return this.decryptionErrorQueue.size;
+ }
+
+ public async waitForReceipt(): Promise {
+ return this.receiptQueue.shift();
+ }
+ public getReceiptQueueSize(): number {
+ return this.receiptQueue.size;
+ }
+
+ public async waitForStory(): Promise {
+ return this.storyQueue.shift();
+ }
+ public getStoryQueueSize(): number {
+ return this.storyQueue.size;
+ }
+
+ public async waitForEditMessage(): Promise {
+ return this.editMessageQueue.shift();
+ }
+ public getEditQueueSize(): number {
+ return this.editMessageQueue.size;
+ }
+
+ public async waitForSyncMessage(
+ predicate: (entry: SyncMessageQueueEntry) => boolean = () => true,
+ ): Promise {
+ for (;;) {
+ const entry = await this.syncMessageQueue.shift();
+ if (!predicate(entry)) {
+ continue;
+ }
+ return entry;
+ }
+ }
+
+ //
+ // Private
+ //
+
+ private async getProfileKeyPresentation(
+ groupParams: GroupSecretParams,
+ ): Promise {
+ const ops = new ClientZkProfileOperations(this.config.serverPublicParams);
+
+ const ctx = ops.createProfileKeyCredentialRequestContext(
+ Aci.parseFromServiceIdString(this.device.aci),
+ this.profileKey,
+ );
+ const response = await this.config.issueExpiringProfileKeyCredential(
+ this.device,
+ ctx.getRequest(),
+ );
+ assert.ok(response, `Member device ${this.device.aci} not initialized`);
+
+ const credential = ops.receiveExpiringProfileKeyCredential(
+ ctx,
+ new ExpiringProfileKeyCredentialResponse(response),
+ );
+
+ return ops.createExpiringProfileKeyCredentialPresentation(
+ groupParams,
+ credential,
+ );
+ }
+
+ private getPrivateKey(serviceIdKind: ServiceIdKind): PrivateKey {
+ switch (serviceIdKind) {
+ case ServiceIdKind.ACI:
+ return this.privateKey;
+ case ServiceIdKind.PNI:
+ return this.pniPrivateKey;
+ }
+ }
+
+ private async encryptContent(
+ target: Device,
+ content: Proto.Content.Params,
+ options?: EncryptOptions,
+ ): Promise> {
+ const encoded = Buffer.from(Proto.Content.encode(content));
+
+ return this.lock(async () => {
+ return this.encrypt(target, encoded, options);
+ });
+ }
+
+ private async broadcast(
+ type: string,
+ content: Proto.Content.Params,
+ options?: EncryptOptions,
+ ): Promise {
+ debug(
+ 'broadcasting %s to %d linked devices',
+ type,
+ this.secondaryDevices.length,
+ );
+
+ await Promise.all(
+ this.secondaryDevices.map(async (device) => {
+ const envelope = await this.encryptContent(device, content, options);
+
+ await this.config.send(device, envelope);
+ }),
+ );
+ }
+
+ private getSyncState(secondaryDevice: Device): SyncEntry {
+ const existing = this.syncStates.get(secondaryDevice);
+ if (existing) {
+ return existing;
+ }
+
+ let complete: (() => void) | undefined;
+ const onComplete = new Promise((resolve) => {
+ complete = resolve;
+ });
+
+ if (!complete) {
+ throw new Error('Failed to obtain resolve callback');
+ }
+
+ const entry = {
+ state: SyncState.Empty,
+ onComplete,
+ complete,
+ };
+ this.syncStates.set(secondaryDevice, entry);
+
+ return entry;
+ }
+
+ private async handleSync(
+ source: Device,
+ sync: Proto.SyncMessage,
+ ): Promise {
+ if (sync.content?.request == null) {
+ debug('got generic sync message');
+ this.syncMessageQueue.push({
+ source,
+ syncMessage: sync,
+ });
+ return;
+ }
+
+ const {
+ content: { request },
+ } = sync;
+
+ let stateChange: SyncState;
+ let response: Proto.SyncMessage.Params;
+ if (request.type === Proto.SyncMessage.Request.Type.CONTACTS) {
+ debug('got sync contacts request');
+ response = {
+ content: {
+ contacts: {
+ blob: this.contactsBlob,
+ complete: true,
+ },
+ },
+ stickerPackOperation: null,
+ read: null,
+ viewed: null,
+ padding: null,
+ };
+ stateChange = SyncState.Contacts;
+ } else if (request.type === Proto.SyncMessage.Request.Type.BLOCKED) {
+ debug('got sync blocked request');
+ response = {
+ content: {
+ blocked: {
+ numbers: null,
+ groupIds: null,
+ acisBinary: null,
+
+ // Deprecated string field
+ acis: null,
+ },
+ },
+ stickerPackOperation: null,
+ read: null,
+ viewed: null,
+ padding: null,
+ };
+ stateChange = SyncState.Blocked;
+ } else if (request.type === Proto.SyncMessage.Request.Type.CONFIGURATION) {
+ debug('got sync configuration request');
+ response = {
+ content: {
+ configuration: {
+ readReceipts: true,
+ unidentifiedDeliveryIndicators: false,
+ typingIndicators: false,
+ linkPreviews: false,
+ },
+ },
+ stickerPackOperation: null,
+ read: null,
+ viewed: null,
+ padding: null,
+ };
+ stateChange = SyncState.Configuration;
+ } else if (request.type === Proto.SyncMessage.Request.Type.KEYS) {
+ debug('got sync keys request');
+ response = {
+ content: {
+ keys: {
+ master: this.masterKey,
+ mediaRootBackupKey: this.mediaRootBackupKey,
+ accountEntropyPool: this.accountEntropyPool,
+ },
+ },
+ stickerPackOperation: null,
+ read: null,
+ viewed: null,
+ padding: null,
+ };
+ stateChange = SyncState.Keys;
+ } else {
+ debug('Unsupported sync request', request);
+ return;
+ }
+
+ const encrypted = await this.encryptContent(source, {
+ content: {
+ syncMessage: response,
+ },
+ pniSignatureMessage: null,
+ senderKeyDistributionMessage: null,
+ });
+
+ // Intentionally not awaiting since the device might be offline or
+ // not responding.
+ void this.config.send(source, encrypted);
+
+ const syncEntry = this.getSyncState(source);
+ syncEntry.state |= stateChange;
+
+ if ((syncEntry.state & SyncState.Complete) === SyncState.Complete) {
+ debug('sync with %s complete', source.debugId);
+ syncEntry.complete();
+ }
+ }
+
+ private handleResendRequest(
+ source: Device,
+ serviceIdKind: ServiceIdKind,
+ envelopeType: EnvelopeType,
+ content: Proto.Content,
+ decryptionErrorMessage: Uint8Array,
+ ): void {
+ const request = DecryptionErrorMessage.deserialize(
+ Buffer.from(decryptionErrorMessage),
+ );
+
+ this.decryptionErrorQueue.push({
+ source,
+ serviceIdKind,
+ envelopeType,
+ content,
+ timestamp: request.timestamp(),
+ ratchetKey: request.ratchetKey(),
+ senderDevice: request.deviceId(),
+ });
+ }
+
+ private handleDataMessage(
+ source: Device,
+ serviceIdKind: ServiceIdKind,
+ envelopeType: EnvelopeType,
+ content: Proto.Content,
+ dataMessage: Proto.DataMessage,
+ ): void {
+ const { body } = dataMessage;
+ this.messageQueue.push({
+ source,
+ serviceIdKind,
+ body: body ?? '',
+ envelopeType,
+ dataMessage,
+ content,
+ });
+ }
+
+ private handleReceiptMessage(
+ source: Device,
+ serviceIdKind: ServiceIdKind,
+ envelopeType: EnvelopeType,
+ content: Proto.Content,
+ receiptMessage: Proto.ReceiptMessage,
+ ): void {
+ this.receiptQueue.push({
+ source,
+ serviceIdKind,
+ envelopeType,
+ receiptMessage,
+ content,
+ });
+ }
+
+ private handleStoryMessage(
+ source: Device,
+ serviceIdKind: ServiceIdKind,
+ envelopeType: EnvelopeType,
+ content: Proto.Content,
+ storyMessage: Proto.StoryMessage,
+ ): void {
+ this.storyQueue.push({
+ source,
+ serviceIdKind,
+ envelopeType,
+ storyMessage,
+ content,
+ });
+ }
+
+ private handleEditMessage(
+ source: Device,
+ serviceIdKind: ServiceIdKind,
+ envelopeType: EnvelopeType,
+ content: Proto.Content,
+ editMessage: Proto.EditMessage,
+ ): void {
+ this.editMessageQueue.push({
+ source,
+ serviceIdKind,
+ envelopeType,
+ editMessage,
+ content,
+ });
+ }
+
+ private async encrypt(
+ target: Device,
+ message: Buffer,
+ {
+ timestamp = Date.now(),
+ sealed = false,
+ serviceIdKind = ServiceIdKind.ACI,
+ updatedPni,
+ distributionId,
+ group,
+ }: EncryptOptions = {},
+ ): Promise> {
+ assert.ok(this.isInitialized, 'Not initialized');
+
+ // "Pad"
+ const paddedMessage = Buffer.concat([message, Buffer.from([0x80])]);
+
+ let envelopeType: Proto.Envelope.Type;
+ let content: Uint8Array;
+
+ // Outgoing stores
+ const identity = this.identity.get(ServiceIdKind.ACI);
+ assert(identity, 'Should have an ACI identity');
+
+ if (sealed) {
+ assert(
+ serviceIdKind === ServiceIdKind.ACI,
+ "Can't send sealed sender to PNI",
+ );
+
+ if (distributionId) {
+ const senderKey = this.senderKeys.get(ServiceIdKind.ACI);
+ assert(senderKey, 'Should have an ACI sender keys');
+
+ const ciphertext = await SignalClient.groupEncrypt(
+ target.address,
+ distributionId,
+ senderKey,
+ paddedMessage,
+ );
+
+ const usmc = SignalClient.UnidentifiedSenderMessageContent.new(
+ ciphertext,
+ this.senderCertificate,
+ SignalClient.ContentHint.Implicit,
+ group?.publicParams.getGroupIdentifier().serialize() ?? null,
+ );
+ const multiRecipient =
+ await SignalClient.sealedSenderMultiRecipientEncrypt(
+ usmc,
+ [target.getAddressByKind(serviceIdKind)],
+ identity,
+ this.sessions,
+ );
+
+ content =
+ SignalClient.sealedSenderMultiRecipientMessageForSingleRecipient(
+ multiRecipient,
+ );
+ } else {
+ content = await SignalClient.sealedSenderEncryptMessage(
+ paddedMessage,
+ target.getAddressByKind(serviceIdKind),
+ this.senderCertificate,
+ this.sessions,
+ identity,
+ );
+ }
+
+ envelopeType = Proto.Envelope.Type.UNIDENTIFIED_SENDER;
+ } else {
+ const ciphertext = await SignalClient.signalEncrypt(
+ paddedMessage,
+ target.getAddressByKind(serviceIdKind),
+ this.device.getAddressByKind(ServiceIdKind.ACI),
+ this.sessions,
+ identity,
+ );
+ content = ciphertext.serialize();
+
+ if (ciphertext.type() === CiphertextMessageType.Whisper) {
+ assert(
+ serviceIdKind === ServiceIdKind.ACI,
+ "Can't send non-prekey messages to PNI",
+ );
+
+ envelopeType = Proto.Envelope.Type.DOUBLE_RATCHET;
+ debug('encrypting ciphertext envelope');
+ } else {
+ assert.strictEqual(ciphertext.type(), CiphertextMessageType.PreKey);
+ envelopeType = Proto.Envelope.Type.PREKEY_MESSAGE;
+ debug('encrypting prekeyBundle envelope');
+ }
+ }
+
+ const envelope = Buffer.from(
+ Proto.Envelope.encode({
+ type: envelopeType,
+ sourceServiceIdBinary: sealed ? null : this.device.aciBinary,
+ sourceDeviceId: sealed ? null : this.device.deviceId,
+ destinationServiceIdBinary:
+ target.getServiceIdBinaryByKind(serviceIdKind),
+ updatedPniBinary:
+ updatedPni === undefined
+ ? null
+ : Pni.parseFromServiceIdString(updatedPni).getRawUuidBytes(),
+ serverTimestamp: BigInt(timestamp),
+ clientTimestamp: BigInt(timestamp),
+ content,
+ serverGuid: null,
+ ephemeral: null,
+ urgent: null,
+ story: null,
+ reportSpamToken: null,
+ serverGuidBinary: null,
+
+ // Deprecated string fields
+ sourceServiceId: null,
+ destinationServiceId: null,
+ updatedPni: null,
+ }),
+ );
+
+ debug('encrypting envelope finish');
+
+ return envelope;
+ }
+
+ private async decrypt(
+ source: Device | undefined,
+ serviceIdKind: ServiceIdKind,
+ envelopeType: EnvelopeType,
+ encrypted: Uint8Array,
+ ): Promise {
+ debug('decrypting envelope type=%s start', envelopeType);
+
+ const identity = this.identity.get(serviceIdKind);
+ const preKeys = this.preKeys.get(serviceIdKind);
+ const kyberPreKeys = this.kyberPreKeys.get(serviceIdKind);
+ const signedPreKeys = this.signedPreKeys.get(serviceIdKind);
+ const senderKeys = this.senderKeys.get(serviceIdKind);
+ assert(
+ identity && preKeys && signedPreKeys && kyberPreKeys && senderKeys,
+ 'Should have identity, prekey/kyber/signed/senderkey stores',
+ );
+
+ let decrypted: Uint8Array;
+
+ if (envelopeType === EnvelopeType.Plaintext) {
+ assert(source !== undefined, 'Plaintext must have source');
+
+ const plaintext = PlaintextContent.deserialize(encrypted);
+ decrypted = plaintext.body();
+ } else if (envelopeType === EnvelopeType.CipherText) {
+ assert(source !== undefined, 'CipherText must have source');
+
+ decrypted = await SignalClient.signalDecrypt(
+ SignalMessage.deserialize(encrypted),
+ source.getAddressByKind(ServiceIdKind.ACI),
+ this.sessions,
+ identity,
+ );
+ } else if (envelopeType === EnvelopeType.PreKey) {
+ assert(source !== undefined, 'PreKey must have source');
+
+ decrypted = await SignalClient.signalDecryptPreKey(
+ PreKeySignalMessage.deserialize(encrypted),
+ source.getAddressByKind(ServiceIdKind.ACI),
+ this.device.getAddressByKind(serviceIdKind),
+ this.sessions,
+ identity,
+ preKeys,
+ signedPreKeys,
+ kyberPreKeys,
+ );
+ } else if (envelopeType === EnvelopeType.SenderKey) {
+ assert(source !== undefined, 'SenderKey must have source');
+
+ decrypted = await SignalClient.groupDecrypt(
+ source.getAddressByKind(serviceIdKind),
+ senderKeys,
+ encrypted,
+ );
+ } else if (envelopeType === EnvelopeType.SealedSender) {
+ assert(source === undefined, 'Sealed sender must have no source');
+
+ const usmc = await SignalClient.sealedSenderDecryptToUsmc(
+ encrypted,
+ identity,
+ );
+
+ const unsealedType = usmc.msgType();
+ const certificate = usmc.senderCertificate();
+
+ const sender = await this.config.getDeviceByServiceId(
+ certificate.senderUuid() as ServiceIdString,
+ certificate.senderDeviceId() as DeviceId,
+ );
+ assert(sender !== undefined, 'Unsealed sender not found');
+
+ let subType: EnvelopeType;
+ switch (unsealedType) {
+ case CiphertextMessageType.PreKey:
+ subType = EnvelopeType.PreKey;
+ break;
+ case CiphertextMessageType.Whisper:
+ subType = EnvelopeType.CipherText;
+ break;
+ case CiphertextMessageType.SenderKey:
+ subType = EnvelopeType.SenderKey;
+ break;
+ case CiphertextMessageType.Plaintext:
+ subType = EnvelopeType.Plaintext;
+ break;
+ default:
+ throw new Error(`Unsupported usmc type: ${unsealedType}`);
+ }
+
+ // TODO(indutny): use sealedSenderDecryptMessage once it will support
+ // sender key.
+ const result = await this.decrypt(
+ sender,
+ serviceIdKind,
+ subType,
+ usmc.contents(),
+ );
+
+ if (serviceIdKind === ServiceIdKind.PNI) {
+ debug('sealed message on PNI', result);
+ throw new Error('Got sealed message on PNI');
+ }
+
+ return result;
+ } else {
+ throw new Error(`Unsupported envelope type: ${envelopeType}`);
+ }
+
+ // Remove padding
+ let padding = 1;
+ while (decrypted[decrypted.length - padding] !== 0x80) {
+ assert.strictEqual(decrypted[decrypted.length - padding], 0);
+ padding++;
+ }
+
+ const content = Proto.Content.decode(decrypted.slice(0, -padding));
+ debug('decrypting envelope type=%s finish', envelopeType);
+ return { unsealedSource: source, content, envelopeType };
+ }
+
+ private async lock(callback: () => Promise): Promise {
+ while (this.lockPromise) {
+ await this.lockPromise;
+ }
+
+ let unlock: (() => void) | undefined;
+ this.lockPromise = new Promise((resolve) => {
+ unlock = resolve;
+ });
+
+ try {
+ return await callback();
+ } finally {
+ this.lockPromise = undefined;
+ assert.ok(unlock);
+ unlock();
+ }
+ }
+
+ private get senderCertificate(): SenderCertificate {
+ if (!this.privSenderCertificate) {
+ throw new Error('Sender certificate not set');
+ }
+ return this.privSenderCertificate;
+ }
+
+ private async processSenderKeyDistribution(
+ source: Device,
+ rawMessage: Uint8Array,
+ ): Promise {
+ const message = SenderKeyDistributionMessage.deserialize(
+ Buffer.from(rawMessage),
+ );
+
+ const senderKeys = this.senderKeys.get(ServiceIdKind.ACI);
+ assert(senderKeys, 'Should have a sender key store');
+
+ debug('received SKDM from', source.debugId);
+ await SignalClient.processSenderKeyDistributionMessage(
+ source.address,
+ message,
+ senderKeys,
+ );
+ }
+
+ private async convertManifestToStorageState(
+ manifest: Proto.StorageManifest.Params,
+ ): Promise {
+ const decryptedManifest = decryptStorageManifest(this.storageKey, manifest);
+ assert(decryptedManifest.version, 'Consistency check');
+
+ const version = decryptedManifest.version;
+ const items = await Promise.all(
+ decryptedManifest.identifiers.map(async ({ type, raw: key }) => {
+ const keyBuffer = Buffer.from(key);
+ const item = await this.config.getStorageItem(keyBuffer);
+ if (!item) {
+ throw new Error(`Missing item ${keyBuffer.toString('base64')}`);
+ }
+
+ const decrypted = decryptStorageItem({
+ storageKey: this.storageKey,
+ recordIkm: this.storageRecordIkm,
+ item: {
+ key,
+ value: item,
+ },
+ });
+ if (!decrypted.record) {
+ throw new Error(
+ `Missing item record ${keyBuffer.toString('base64')}`,
+ );
+ }
+ return {
+ type: type as Proto.ManifestRecord.Identifier.Type,
+ key: keyBuffer,
+ record: decrypted.record,
+ };
+ }),
+ );
+
+ return new StorageState(version, items);
+ }
+}
diff --git a/packages/mock-server/src/api/server.ts b/packages/mock-server/src/api/server.ts
new file mode 100644
index 0000000000..05dc80464a
--- /dev/null
+++ b/packages/mock-server/src/api/server.ts
@@ -0,0 +1,1006 @@
+// Copyright 2022 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import assert from 'assert';
+import fs from 'fs';
+import fsPromises from 'fs/promises';
+import { type Readable } from 'stream';
+import path from 'path';
+import type { IncomingMessage, ServerResponse } from 'http';
+import http2, {
+ SecureServerOptions,
+ Http2ServerRequest,
+ Http2ServerResponse,
+} from 'http2';
+import { parse as parseURL } from 'url';
+import {
+ PrivateKey,
+ PublicKey,
+ initLogger,
+ LogLevel as SignalClientLogLevel,
+} from '@signalapp/libsignal-client';
+import {
+ GenericServerSecretParams,
+ ServerSecretParams,
+} from '@signalapp/libsignal-client/zkgroup';
+import createDebug from 'debug';
+import WebSocket from 'ws';
+import { run, type RequestHandler } from 'micro';
+
+import { attachmentToPointer } from '../data/attachment';
+import { BackupMediaBatch } from '../data/schemas';
+import { PRIMARY_DEVICE_ID } from '../constants';
+import {
+ AciString,
+ ProvisionIdString,
+ ProvisioningCode,
+ ServiceIdKind,
+ ServiceIdString,
+} from '../types';
+import { serializeContacts } from '../data/contacts';
+import { Group as GroupData } from '../data/group';
+import {
+ encryptAttachment,
+ encryptProvisionMessage,
+ generateServerCertificate,
+} from '../crypto';
+import { signalservice as Proto } from '../../protos/compiled';
+import {
+ BackupMediaBatchResponse,
+ Server as BaseServer,
+ ChallengeResponse,
+ EnvelopeType,
+ IsSendRateLimitedOptions,
+ ModifyGroupOptions,
+ ModifyGroupResult,
+ ProvisionDeviceOptions,
+ ProvisioningResponse,
+ TransferArchiveResponse,
+} from '../server/base';
+import { Device, DeviceKeys } from '../data/device';
+import {
+ PromiseQueue,
+ generateDevicePassword,
+ generateRandomE164,
+ generateRegistrationId,
+} from '../util';
+
+import { createHandler as createHTTPHandler } from '../server/http';
+import { createHandler as createGRPCHandler } from '../server/grpc';
+import { Connection as WSConnection } from '../server/ws';
+
+import { PrimaryDevice } from './primary-device';
+
+type TrustRoot = Readonly<{
+ privateKey: string;
+ publicKey: string;
+}>;
+
+type ZKParams = Readonly<{
+ secretParams: string;
+ publicParams: string;
+ genericSecretParams: string;
+ genericPublicParams: string;
+ backupSecretParams: string;
+ backupPublicParams: string;
+}>;
+
+type StrictConfig = Readonly<{
+ trustRoot: TrustRoot;
+ zkParams: ZKParams;
+ https: SecureServerOptions;
+ timeout: number;
+ maxStorageReadKeys?: number;
+ cdn3Path?: string;
+ updates2Path?: string;
+}>;
+
+export type Config = Readonly<{
+ trustRoot?: TrustRoot;
+ zkParams?: ZKParams;
+ https?: SecureServerOptions;
+ timeout?: number;
+ maxStorageReadKeys?: number;
+ cdn3Path?: string;
+ updates2Path?: string;
+}>;
+
+export type CreatePrimaryDeviceOptions = Readonly<{
+ profileName: string;
+ contacts?: ReadonlyArray;
+ contactsWithoutProfileKey?: ReadonlyArray;
+ password?: string;
+}>;
+
+export type PendingProvision = {
+ complete: (response: PendingProvisionResponse) => Promise;
+};
+
+export type PendingProvisionResponse = Readonly<{
+ provisionURL: string;
+ primaryDevice: PrimaryDevice;
+}>;
+
+export type RateLimitOptions = Readonly<{
+ source: ServiceIdString;
+ target: ServiceIdString;
+}>;
+
+type ProvisionResultQueue = Readonly<{
+ seenServiceIdKinds: Set;
+ promiseQueue: PromiseQueue;
+}>;
+
+const debug = createDebug('mock:server:mock');
+const libsignalDebug = createDebug('mock:server:libsignal');
+
+const CERTS_DIR = path.join(__dirname, '..', '..', 'certs');
+
+const CERT = fs.readFileSync(path.join(CERTS_DIR, 'full-cert.pem'));
+const KEY = fs.readFileSync(path.join(CERTS_DIR, 'key.pem'));
+const TRUST_ROOT: TrustRoot = JSON.parse(
+ fs.readFileSync(path.join(CERTS_DIR, 'trust-root.json')).toString(),
+);
+const ZK_PARAMS: ZKParams = JSON.parse(
+ fs.readFileSync(path.join(CERTS_DIR, 'zk-params.json')).toString(),
+);
+
+const DEFAULT_API_TIMEOUT = 60000;
+
+initLogger(
+ SignalClientLogLevel.Info,
+ (
+ level: SignalClientLogLevel,
+ target: string,
+ file: string | null,
+ line: number | null,
+ message: string,
+ ) => {
+ let fileString = '';
+ if (file && line) {
+ fileString = ` ${file}:${line}`;
+ } else if (file) {
+ fileString = ` ${file}`;
+ }
+ const logString = `${SignalClientLogLevel[level]} ${message} ${target}${fileString}`;
+
+ libsignalDebug(logString);
+ },
+);
+
+export class Server extends BaseServer {
+ private readonly config: StrictConfig;
+
+ private readonly trustRoot: PrivateKey;
+ private readonly primaryDevices = new Map();
+ private readonly knownNumbers = new Set();
+ private emptyAttachment: Proto.AttachmentPointer.Params | undefined;
+
+ private provisionQueue: PromiseQueue;
+ private provisionResultQueueByCode = new Map<
+ ProvisioningCode,
+ ProvisionResultQueue
+ >();
+ private provisionResultQueueByKey = new Map();
+ private manifestQueueByAci = new Map>();
+ private groupQueueById = new Map>();
+ private transferArchiveByDevice = new Map();
+ private transferCallbacksByDevice = new Map<
+ Device,
+ Array<(response: TransferArchiveResponse) => void>
+ >();
+ private rateLimitCountByPair = new Map<
+ `${ServiceIdString}:${ServiceIdString}`,
+ number
+ >();
+ private responseForChallenges: ChallengeResponse | undefined;
+ private unregisteredServiceIds = new Set();
+ private wsUpgradeResponseHeaders: Record = {};
+
+ constructor(config: Config = {}) {
+ super();
+
+ this.config = {
+ timeout: DEFAULT_API_TIMEOUT,
+ trustRoot: TRUST_ROOT,
+ zkParams: ZK_PARAMS,
+ ...config,
+
+ https: {
+ key: KEY,
+ cert: CERT,
+ allowHTTP1: true,
+ ...(config.https ?? {}),
+ settings: {
+ ...(config.https?.settings ?? {}),
+ enableConnectProtocol: true,
+ },
+ },
+ };
+
+ const trustPrivate = Buffer.from(
+ this.config.trustRoot.privateKey,
+ 'base64',
+ );
+ this.trustRoot = PrivateKey.deserialize(trustPrivate);
+
+ const zkSecret = Buffer.from(this.config.zkParams.secretParams, 'base64');
+ this.zkSecret = new ServerSecretParams(zkSecret);
+
+ const genericSecret = Buffer.from(
+ this.config.zkParams.genericSecretParams,
+ 'base64',
+ );
+ this.genericServerSecret = new GenericServerSecretParams(genericSecret);
+
+ const backupSecret = Buffer.from(
+ this.config.zkParams.backupSecretParams,
+ 'base64',
+ );
+ this.backupServerSecret = new GenericServerSecretParams(backupSecret);
+
+ this.certificate = generateServerCertificate(this.trustRoot);
+
+ this.provisionQueue = this.createQueue('api/Server/provisionQueue');
+ }
+
+ public async listen(port: number, host?: string): Promise {
+ if (this.https) {
+ throw new Error('Already listening');
+ }
+
+ const emptyData = encryptAttachment(Buffer.alloc(0));
+ const emptyCDNKey = await this.storeAttachment(emptyData.blob);
+
+ this.emptyAttachment = attachmentToPointer(emptyCDNKey, emptyData);
+
+ const httpHandler = createHTTPHandler(this, {
+ cdn3Path: this.config.cdn3Path,
+ updates2Path: this.config.updates2Path,
+ });
+
+ const grpcHandler = createGRPCHandler(this);
+
+ const server = http2
+ .createSecureServer(this.config.https, (req, res) => {
+ let handler: RequestHandler;
+ if (req.headers['content-type'] === 'application/grpc') {
+ handler = grpcHandler;
+ } else {
+ handler = httpHandler;
+ }
+
+ // micro is actually compatible with http2 requests, but the types are
+ // not.
+ void run(
+ req as unknown as IncomingMessage,
+ res as unknown as ServerResponse,
+ handler,
+ );
+ })
+ .on('connect', (req: Http2ServerRequest, res: Http2ServerResponse) => {
+ // WebSocket
+ if (req.method === 'CONNECT') {
+ res.writeHead(200, this.wsUpgradeResponseHeaders);
+
+ const websocket = new WebSocket(
+ null as unknown as string,
+ undefined,
+ {},
+ );
+ (websocket as any).setSocket(req.stream, Buffer.alloc(0), {});
+ const conn = new WSConnection(req, websocket, this);
+
+ conn.start(websocket).catch((error: unknown) => {
+ websocket.close();
+ debug('Websocket handling error', error);
+ });
+ return;
+ }
+ });
+
+ this.https = server;
+
+ return new Promise((resolve) => {
+ server.listen(port, host, () => resolve());
+ });
+ }
+
+ public async close(): Promise {
+ const https = this.https;
+ if (!https) {
+ throw new Error('Not listening');
+ }
+
+ debug('closing server');
+
+ await new Promise((resolve) => https.close(resolve));
+ }
+
+ //
+ // Various queues
+ //
+
+ public async waitForProvision(): Promise {
+ return this.provisionQueue.shift();
+ }
+
+ private async waitForStorageManifest(
+ device: Device,
+ afterVersion?: bigint,
+ ): Promise {
+ let queue = this.manifestQueueByAci.get(device.aci);
+ if (!queue) {
+ queue = this.createQueue('api/Server/waitForStorageManifest');
+ this.manifestQueueByAci.set(device.aci, queue);
+ }
+
+ let version: bigint;
+ do {
+ version = await queue.shift();
+ } while (afterVersion !== undefined && version <= afterVersion);
+ }
+
+ public async waitForGroupUpdate(group: GroupData): Promise {
+ let queue = this.groupQueueById.get(group.id);
+ if (!queue) {
+ queue = this.createQueue('api/Server/waitForGroupUpdate');
+ this.groupQueueById.set(group.id, queue);
+ }
+
+ let version: number;
+ do {
+ version = await queue.shift();
+ } while (version <= group.revision);
+ }
+
+ //
+ // Helper methods
+ //
+
+ public async createPrimaryDevice({
+ profileName,
+ contacts = [],
+ contactsWithoutProfileKey = [],
+ password,
+ }: CreatePrimaryDeviceOptions): Promise {
+ const number = await this.generateNumber();
+
+ const registrationId = generateRegistrationId();
+ const pniRegistrationId = generateRegistrationId();
+ const devicePassword = password ?? generateDevicePassword();
+ const device = await this.registerDevice({
+ number,
+ registrationId,
+ pniRegistrationId,
+ password: devicePassword,
+ });
+
+ const { aci } = device;
+
+ debug(
+ 'creating primary device with aci=%s registrationId=%d',
+ aci,
+ registrationId,
+ );
+
+ if (!this.emptyAttachment) {
+ throw new Error('Mock#init must be called before starting the server');
+ }
+
+ const contactsAttachment = encryptAttachment(
+ serializeContacts([
+ ...contacts.map((device) => device.toContact()),
+ ...contactsWithoutProfileKey.map((device) => device.toContact()),
+ ]),
+ );
+ const contactsCDNKey = await this.storeAttachment(contactsAttachment.blob);
+ debug('contacts cdn key', contactsCDNKey);
+ if (this.emptyAttachment.attachmentIdentifier?.cdnKey != null) {
+ debug('groups cdn key', this.emptyAttachment.attachmentIdentifier.cdnKey);
+ }
+
+ const primary = new PrimaryDevice(device, {
+ profileName: profileName,
+ contacts: attachmentToPointer(contactsCDNKey, contactsAttachment),
+ trustRoot: this.trustRoot.getPublicKey(),
+ serverPublicParams: this.zkSecret.getPublicParams(),
+
+ generateNumber: this.generateNumber.bind(this),
+ generatePni: this.generatePni.bind(this),
+ changeDeviceNumber: this.changeDeviceNumber.bind(this),
+ send: this.send.bind(this),
+ getSenderCertificate: this.getSenderCertificate.bind(this, device),
+ getDeviceByServiceId: this.getDeviceByServiceId.bind(this),
+ issueExpiringProfileKeyCredential:
+ this.issueExpiringProfileKeyCredential.bind(this),
+ getGroup: this.getGroup.bind(this),
+ createGroup: this.createGroup.bind(this),
+ modifyGroup: this.modifyGroup.bind(this),
+ waitForGroupUpdate: this.waitForGroupUpdate.bind(this),
+ getStorageManifest: this.getStorageManifest.bind(this, device),
+ getStorageItem: this.getStorageItem.bind(this, device),
+ getAllStorageKeys: this.getAllStorageKeys.bind(this, device),
+ waitForStorageManifest: this.waitForStorageManifest.bind(this, device),
+ applyStorageWrite: this.applyStorageWrite.bind(this, device),
+ });
+ await primary.init();
+
+ this.primaryDevices.set(primary.device.number, primary);
+ this.primaryDevices.set(primary.device.aci, primary);
+
+ debug(
+ 'created primary device number=%s aci=%s',
+ primary.device.number,
+ primary.device.aci,
+ );
+
+ return primary;
+ }
+
+ public async createSecondaryDevice(primary: PrimaryDevice): Promise {
+ const registrationId = generateRegistrationId();
+ const pniRegistrationId = generateRegistrationId();
+
+ const device = await this.registerDevice({
+ primary: primary.device,
+ registrationId,
+ pniRegistrationId,
+ });
+
+ for (const serviceIdKind of [ServiceIdKind.ACI, ServiceIdKind.PNI]) {
+ await this.updateDeviceKeys(
+ device,
+ serviceIdKind,
+ await primary.generateKeys(device, serviceIdKind),
+ );
+ }
+
+ primary.addSecondaryDevice(device);
+
+ return device;
+ }
+
+ public unregister(
+ primary: PrimaryDevice,
+ serviceIdKind = ServiceIdKind.ACI,
+ ): void {
+ this.unregisteredServiceIds.add(
+ primary.device.getServiceIdByKind(serviceIdKind),
+ );
+ }
+
+ public register(
+ primary: PrimaryDevice,
+ serviceIdKind = ServiceIdKind.ACI,
+ ): void {
+ this.unregisteredServiceIds.delete(
+ primary.device.getServiceIdByKind(serviceIdKind),
+ );
+ }
+
+ public respondToChallengesWith(code = 413, data?: unknown): void {
+ this.responseForChallenges = {
+ code,
+ data,
+ };
+ }
+
+ public stopRespondingToChallenges(): void {
+ this.responseForChallenges = undefined;
+ }
+
+ public getResponseForChallenges(): ChallengeResponse | undefined {
+ return this.responseForChallenges;
+ }
+
+ public rateLimit({ source, target }: RateLimitOptions): void {
+ this.rateLimitCountByPair.set(`${source}:${target}`, 0);
+ }
+
+ public stopRateLimiting({
+ source,
+ target,
+ }: RateLimitOptions): number | undefined {
+ const key: `${ServiceIdString}:${ServiceIdString}` = `${source}:${target}`;
+ const existing = this.rateLimitCountByPair.get(key);
+ this.rateLimitCountByPair.delete(key);
+ return existing;
+ }
+
+ public async removeAllCDNAttachments(): Promise {
+ const { cdn3Path } = this.config;
+ assert(cdn3Path, 'cdn3Path must be provided to store attachments');
+
+ const dir = path.join(cdn3Path, 'attachments');
+ await fsPromises.rm(dir, {
+ recursive: true,
+ });
+ }
+
+ public async storeAttachmentOnCdn(
+ cdnNumber: number,
+ cdnKey: string,
+ data: Uint8Array | Readable,
+ ): Promise {
+ assert.strictEqual(cdnNumber, 3, 'Only cdn 3 currently supported');
+ const { cdn3Path } = this.config;
+ assert(cdn3Path, 'cdn3Path must be provided to store attachments');
+
+ const dir = path.join(cdn3Path, 'attachments');
+ await fsPromises.mkdir(dir, {
+ recursive: true,
+ });
+ await fsPromises.writeFile(path.join(dir, cdnKey), data);
+ }
+
+ public setWebsocketUpgradeResponseHeaders(
+ headers: Record,
+ ): void {
+ this.wsUpgradeResponseHeaders = headers;
+ }
+
+ public async storeBackupOnCdn(
+ backupId: Uint8Array,
+ data: Uint8Array | Readable,
+ ): Promise {
+ const { cdn3Path } = this.config;
+ assert(cdn3Path, 'cdn3Path must be provided to store attachments');
+
+ const dir = path.join(
+ cdn3Path,
+ 'backups',
+ Buffer.from(backupId).toString('base64url'),
+ );
+
+ await fsPromises.mkdir(dir, {
+ recursive: true,
+ });
+ await fsPromises.writeFile(path.join(dir, 'backup'), data);
+ }
+
+ //
+ // Implement Server's abstract methods
+ //
+
+ public async getProvisioningResponse(
+ id: ProvisionIdString,
+ abortSignal?: AbortSignal,
+ ): Promise {
+ const responseQueue = this.createQueue(
+ 'api/server/responseQueue',
+ );
+ const resultQueue = this.createQueue('api/server/resultQueue');
+
+ const { promise, cancel } = this.provisionQueue.pushAndWait({
+ complete: async (response) => {
+ const { promise } = responseQueue.pushAndWait(response);
+ await promise;
+
+ return resultQueue.shift();
+ },
+ });
+
+ const abortListener = () => {
+ cancel();
+ };
+ abortSignal?.addEventListener('abort', abortListener);
+
+ await promise;
+
+ abortSignal?.removeEventListener('abort', abortListener);
+
+ const {
+ // tsdevice:/?uuid=&pub_key=&capabilities=<...>
+ provisionURL,
+ primaryDevice,
+ } = await responseQueue.shift();
+
+ const { query } = parseURL(provisionURL, true);
+
+ assert.strictEqual(query.uuid, id, 'id mismatch');
+ if (query.pub_key == null || Array.isArray(query.pub_key)) {
+ throw new Error('Expected `pub_key` in provision URL');
+ }
+
+ const publicKey = PublicKey.deserialize(
+ Buffer.from(query.pub_key, 'base64'),
+ );
+
+ const aciIdentityKey = await primaryDevice.getIdentityKey(
+ ServiceIdKind.ACI,
+ );
+ const pniIdentityKey = await primaryDevice.getIdentityKey(
+ ServiceIdKind.PNI,
+ );
+ const provisioningCode = await this.getProvisioningCode(
+ id,
+ primaryDevice.device.number,
+ );
+
+ this.provisionResultQueueByCode.set(provisioningCode, {
+ seenServiceIdKinds: new Set(),
+ promiseQueue: resultQueue,
+ });
+
+ const envelopeData = Proto.ProvisionMessage.encode({
+ aciIdentityKeyPrivate: aciIdentityKey.serialize(),
+ aciIdentityKeyPublic: aciIdentityKey.getPublicKey().serialize(),
+ pniIdentityKeyPrivate: pniIdentityKey.serialize(),
+ pniIdentityKeyPublic: pniIdentityKey.getPublicKey().serialize(),
+ number: primaryDevice.device.number,
+ aciBinary: primaryDevice.device.aciRawUuid,
+ pniBinary: primaryDevice.device.pniRawUuid,
+ provisioningCode,
+ profileKey: primaryDevice.profileKey.serialize(),
+ userAgent: primaryDevice.userAgent,
+ readReceipts: true,
+ provisioningVersion: Proto.ProvisioningVersion.CURRENT,
+ masterKey: primaryDevice.masterKey,
+ ephemeralBackupKey: primaryDevice.ephemeralBackupKey ?? null,
+ mediaRootBackupKey: primaryDevice.mediaRootBackupKey,
+ accountEntropyPool: primaryDevice.accountEntropyPool,
+ });
+
+ const { body, ephemeralKey } = encryptProvisionMessage(
+ Buffer.from(envelopeData),
+ publicKey,
+ );
+
+ const envelope = Proto.ProvisionEnvelope.encode({
+ publicKey: ephemeralKey,
+ body,
+ });
+
+ return { envelope: Buffer.from(envelope) };
+ }
+
+ public async handleMessage(
+ source: Device | undefined,
+ serviceIdKind: ServiceIdKind,
+ envelopeType: EnvelopeType,
+ target: Device,
+ encrypted: Buffer,
+ timestamp: bigint,
+ ): Promise {
+ if (envelopeType !== EnvelopeType.SealedSender) {
+ assert(source, 'No source for non-sealed sender envelope');
+ }
+
+ debug('got message for %s.%d', target.aci, target.deviceId);
+
+ if (target.deviceId !== PRIMARY_DEVICE_ID) {
+ if (target.isProvisioned) {
+ let type: Proto.Envelope.Type;
+
+ switch (envelopeType) {
+ case EnvelopeType.CipherText:
+ type = Proto.Envelope.Type.DOUBLE_RATCHET;
+ break;
+ case EnvelopeType.PreKey:
+ type = Proto.Envelope.Type.PREKEY_MESSAGE;
+ break;
+ case EnvelopeType.SealedSender:
+ type = Proto.Envelope.Type.UNIDENTIFIED_SENDER;
+ break;
+ case EnvelopeType.Plaintext:
+ type = Proto.Envelope.Type.PLAINTEXT_CONTENT;
+ break;
+ default:
+ throw new Error(`Unsupported envelope type: ${envelopeType}`);
+ }
+ void this.send(
+ target,
+ Buffer.from(
+ Proto.Envelope.encode({
+ type,
+ sourceServiceIdBinary: source?.aciBinary ?? null,
+ sourceDeviceId: source?.deviceId ?? null,
+ destinationServiceIdBinary:
+ target.getServiceIdBinaryByKind(serviceIdKind),
+ serverTimestamp: timestamp,
+ clientTimestamp: timestamp,
+ content: encrypted,
+ urgent: null,
+ serverGuid: null,
+ ephemeral: null,
+ story: null,
+ reportSpamToken: null,
+ serverGuidBinary: null,
+ updatedPniBinary: null,
+
+ // Deprecated string fields
+ sourceServiceId: null,
+ destinationServiceId: null,
+ updatedPni: null,
+ }),
+ ),
+ );
+ }
+ return;
+ }
+
+ const primary = this.primaryDevices.get(target.aci);
+ if (!primary) {
+ debug('ignoring message, primary device not found');
+ return;
+ }
+
+ await primary.handleEnvelope(
+ source,
+ serviceIdKind,
+ envelopeType,
+ encrypted,
+ );
+ }
+
+ public isUnregistered(serviceId: ServiceIdString): boolean {
+ return this.unregisteredServiceIds.has(serviceId);
+ }
+
+ public isSendRateLimited({
+ source,
+ target,
+ }: IsSendRateLimitedOptions): boolean {
+ const key: `${ServiceIdString}:${ServiceIdString}` = `${source}:${target}`;
+ const existing = this.rateLimitCountByPair.get(key);
+ if (existing === undefined) {
+ return false;
+ }
+
+ const newValue = existing + 1;
+ debug(
+ 'isSendRateLimited: source=%j target=%j count=%d',
+ source,
+ target,
+ newValue,
+ );
+ this.rateLimitCountByPair.set(key, newValue);
+ return true;
+ }
+
+ //
+ // Override `Server`'s methods to automatically pass keys to primary
+ // devices.
+ //
+ // TODO(indutny): use popSingleUseKey() perhaps?
+ //
+
+ public override async updateDeviceKeys(
+ device: Device,
+ serviceIdKind: ServiceIdKind,
+ keys: DeviceKeys,
+ ): Promise {
+ await super.updateDeviceKeys(device, serviceIdKind, keys);
+
+ // Atomic linking updates only signed pre keys, and we should ignore it.
+ if (!keys.preKeys?.length && !keys.kyberPreKeys?.length) {
+ return;
+ }
+
+ const key = `${device.aci}.${device.getRegistrationId(serviceIdKind)}`;
+
+ // Device is marked as provisioned only once we have its keys
+ const resultQueue = this.provisionResultQueueByKey.get(key);
+ if (!resultQueue) {
+ return;
+ }
+
+ debug('updateDeviceKeys: got keys for', device.debugId, serviceIdKind);
+
+ const { seenServiceIdKinds, promiseQueue } = resultQueue;
+
+ assert(
+ !seenServiceIdKinds.has(serviceIdKind),
+ `Duplicate service id kind ${serviceIdKind} ` +
+ `for device: ${device.debugId}`,
+ );
+ seenServiceIdKinds.add(serviceIdKind);
+ if (
+ !seenServiceIdKinds.has(ServiceIdKind.ACI) ||
+ !seenServiceIdKinds.has(ServiceIdKind.PNI)
+ ) {
+ return;
+ }
+
+ this.provisionResultQueueByKey.delete(key);
+ const { promise } = promiseQueue.pushAndWait(device);
+ await promise;
+ }
+
+ public override async provisionDevice(
+ options: ProvisionDeviceOptions,
+ ): Promise {
+ const { provisioningCode } = options;
+
+ const queue = this.provisionResultQueueByCode.get(provisioningCode);
+ assert(
+ queue !== undefined,
+ `Missing provision result queue for code: ${provisioningCode}`,
+ );
+ this.provisionResultQueueByCode.delete(provisioningCode);
+
+ const device = await super.provisionDevice(options);
+
+ for (const serviceIdKind of [ServiceIdKind.ACI, ServiceIdKind.PNI]) {
+ const key = `${device.aci}.${device.getRegistrationId(serviceIdKind)}`;
+ this.provisionResultQueueByKey.set(key, queue);
+ }
+
+ const primary = this.primaryDevices.get(device.aci);
+ primary?.addSecondaryDevice(device);
+
+ return device;
+ }
+
+ // Override `getStorageItems` to provide configurable limit for maximum
+ // storage read keys.
+ public override async getStorageItems(
+ device: Device,
+ keys: ReadonlyArray>,
+ ): Promise | undefined> {
+ if (
+ this.config.maxStorageReadKeys !== undefined &&
+ keys.length > this.config.maxStorageReadKeys
+ ) {
+ debug('getStorageItems: requested more than max keys', device.debugId);
+ return undefined;
+ }
+
+ return super.getStorageItems(device, keys);
+ }
+
+ // Override updateGroup to notify about group modifications
+ public override async modifyGroup(
+ options: ModifyGroupOptions,
+ ): Promise {
+ const { group } = options;
+ debug('modifyGroup', group.id);
+
+ const result = await super.modifyGroup(options);
+
+ let queue = this.groupQueueById.get(group.id);
+ if (!queue) {
+ queue = this.createQueue('api/Server/modifyGroup');
+ this.groupQueueById.set(group.id, queue);
+ }
+
+ queue.push(group.revision);
+
+ return result;
+ }
+
+ protected override async onStorageManifestUpdate(
+ device: Device,
+ version: bigint,
+ ): Promise {
+ debug('onStorageManifestUpdate', device.debugId);
+
+ let queue = this.manifestQueueByAci.get(device.aci);
+ if (!queue) {
+ queue = this.createQueue('api/Server/onStorageManifestUpdate');
+ this.manifestQueueByAci.set(device.aci, queue);
+ }
+
+ queue.push(version);
+ }
+
+ protected override async backupTransitAttachments(
+ backupId: string,
+ batch: BackupMediaBatch,
+ ): Promise> {
+ const { cdn3Path } = this.config;
+ assert(cdn3Path, 'cdn3Path must be provided to store attachments');
+
+ const dir = path.join(cdn3Path, 'attachments');
+ const mediaDir = path.join(cdn3Path, 'backups', backupId, 'media');
+
+ await fsPromises.mkdir(mediaDir, {
+ recursive: true,
+ });
+
+ return Promise.all(
+ batch.items.map(async (item) => {
+ assert.strictEqual(item.sourceAttachment.cdn, 3, 'Invalid object CDN');
+ const transitPath = path.join(dir, item.sourceAttachment.key);
+ const finalPath = path.join(mediaDir, item.mediaId);
+
+ // TODO(indutny): streams
+ let data: Buffer;
+ try {
+ data = await fsPromises.readFile(transitPath);
+ } catch (error) {
+ assert(error instanceof Error);
+ if ('code' in error && error.code === 'ENOENT') {
+ return {
+ cdn: 3,
+ status: 410,
+ mediaId: item.mediaId,
+ };
+ }
+ throw error;
+ }
+
+ assert.strictEqual(
+ data.byteLength,
+ item.objectLength,
+ 'Invalid objectLength',
+ );
+
+ const reencrypted = encryptAttachment(data, {
+ aesKey: item.encryptionKey,
+ macKey: item.hmacKey,
+
+ // Deterministic value
+ iv: Buffer.alloc(16),
+ });
+
+ await fsPromises.writeFile(finalPath, reencrypted.blob);
+
+ void this.onNewBackupMediaObject(backupId, {
+ cdn: 3,
+ mediaId: item.mediaId,
+ objectLength: reencrypted.blob.length,
+ });
+
+ return {
+ cdn: 3,
+ status: 200,
+ mediaId: item.mediaId,
+ };
+ }),
+ );
+ }
+
+ public async provideTransferArchive(
+ device: Device,
+ archive: TransferArchiveResponse,
+ ): Promise {
+ const callbacks = this.transferCallbacksByDevice.get(device) ?? [];
+ this.transferCallbacksByDevice.delete(device);
+
+ this.transferArchiveByDevice.set(device, archive);
+ for (const callback of callbacks) {
+ callback(archive);
+ }
+ }
+
+ public override async getTransferArchive(
+ device: Device,
+ ): Promise {
+ const existing = this.transferArchiveByDevice.get(device);
+ if (existing !== undefined) {
+ return existing;
+ }
+
+ return new Promise((resolve) => {
+ let list = this.transferCallbacksByDevice.get(device);
+ if (list === undefined) {
+ list = [];
+ this.transferCallbacksByDevice.set(device, list);
+ }
+ list.push(resolve);
+ });
+ }
+
+ //
+ // Private
+ //
+
+ private createQueue(name: string): PromiseQueue {
+ return new PromiseQueue({
+ timeout: this.config.timeout,
+ name,
+ });
+ }
+
+ private async generateNumber(): Promise {
+ let number: string;
+ do {
+ number = generateRandomE164();
+ } while (this.knownNumbers.has(number));
+ this.knownNumbers.add(number);
+
+ return number;
+ }
+}
diff --git a/packages/mock-server/src/api/storage-state.ts b/packages/mock-server/src/api/storage-state.ts
new file mode 100644
index 0000000000..b819855ddb
--- /dev/null
+++ b/packages/mock-server/src/api/storage-state.ts
@@ -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;
+
+export type StorageStateRecord =
+ Readonly<{
+ type: Proto.ManifestRecord.Identifier.Type;
+ key: Buffer;
+ record: Value;
+ }>;
+
+export type StorageStateNewRecord = Readonly<{
+ type: Proto.ManifestRecord.Identifier.Type;
+ key?: Buffer;
+ record: RecordValue;
+}>;
+
+export type DiffResult = Readonly<{
+ added: ReadonlyArray;
+ removed: ReadonlyArray;
+}>;
+
+const KEY_SIZE = 16;
+
+const IdentifierType = Proto.ManifestRecord.Identifier.Type;
+type IdentifierType = Proto.ManifestRecord.Identifier.Type;
+
+export type ToStorageItemOptions = Readonly<{
+ storageKey: Buffer;
+ recordIkm: Buffer | undefined;
+}>;
+
+export type CreateWriteOperationOptions = Readonly<{
+ storageKey: Buffer;
+ recordIkm: Buffer | undefined;
+ previous?: StorageState;
+}>;
+
+type StorageRecordPredicate = (
+ record: StorageStateRecord,
+) => record is StorageStateRecord;
+type StorageRecordMapper = (record: Value) => Value;
+type StorageItemPredicate = (
+ item: StorageStateItem,
+ index: number,
+) => item is StorageStateItem;
+
+class StorageStateItem {
+ public readonly type: IdentifierType;
+ public readonly key: Buffer;
+ public readonly record: Value;
+
+ constructor({ type, key, record }: StorageStateRecord) {
+ 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
+ > {
+ return this.type === IdentifierType.ACCOUNT && this.record.account != null;
+ }
+
+ public isGroup(
+ group: Group,
+ ): this is StorageStateItem> {
+ 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> {
+ 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 {
+ 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;
+
+ constructor(
+ public readonly version: bigint,
+ items: ReadonlyArray,
+ ) {
+ 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,
+ ): StorageState {
+ return this.updateItem(
+ (item) => item.isAccount(),
+ (record) => {
+ return {
+ record: 'account',
+ account: {
+ ...record.account,
+ ...diff,
+ },
+ };
+ },
+ );
+ }
+
+ public updateManyAccounts(
+ diff: Partial,
+ ): 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 = {},
+ ): StorageState {
+ return this.addItem({
+ type: IdentifierType.GROUPV2,
+ record: {
+ groupV2: {
+ ...EMPTY_GROUP,
+ ...diff,
+ masterKey: group.masterKey,
+ },
+ },
+ });
+ }
+
+ public updateGroup(
+ group: Group,
+ diff: Partial,
+ ): 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 = {},
+ 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,
+ 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,
+ ): 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(
+ find: StorageRecordPredicate,
+ ): StorageStateRecord | undefined {
+ const item = this.items.find((item): item is StorageStateItem => {
+ return find(item.toRecord());
+ });
+
+ return item?.toRecord();
+ }
+
+ public filterRecords(
+ filter: StorageRecordPredicate,
+ ): ReadonlyArray> {
+ return this.items.filter((item): item is StorageStateItem =>
+ filter(item.toRecord()),
+ );
+ }
+
+ public hasRecord(find: (record: StorageStateRecord) => boolean): boolean {
+ return (
+ this.findRecord(find as StorageRecordPredicate) !== undefined
+ );
+ }
+
+ public updateRecord(
+ find: StorageRecordPredicate,
+ map: StorageRecordMapper,
+ ): StorageState {
+ return this.updateItem(
+ (item): item is StorageStateItem => find(item.toRecord()),
+ map,
+ );
+ }
+
+ public updateManyRecords(
+ filter: StorageRecordPredicate,
+ map: StorageRecordMapper,
+ ): StorageState {
+ return this.updateManyItems(
+ (item): item is StorageStateItem => 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>
+ > {
+ return this.items
+ .filter(
+ (
+ item,
+ ): item is StorageStateItem<
+ Extract
+ > => item.type === IdentifierType.GROUPV2,
+ )
+ .map((item) => item.toRecord());
+ }
+
+ public hasKey(storageKey: Buffer): 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();
+
+ 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();
+ const removedIds = new Map();
+
+ for (const item of this.items) {
+ addedIds.set(item.key.toString('base64'), item.record);
+ }
+
+ for (const item of oldState.items) {
+ const keyString = item.key.toString('base64');
+ if (!addedIds.delete(keyString)) {
+ removedIds.set(keyString, item.record);
+ }
+ }
+
+ return {
+ added: Array.from(addedIds.values()),
+ removed: Array.from(removedIds.values()),
+ };
+ }
+
+ //
+ // Private
+ //
+
+ private 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(
+ find: StorageItemPredicate,
+ map: StorageRecordMapper,
+ ): StorageState {
+ const itemIndex = this.findItemIndex(find);
+ const item = this.items[itemIndex] as StorageStateItem