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----- +MIIFwDCCA6igAwIBAgIUH4s+Chj0H/rJGlds6A9JcN9f5BowDQYJKoZIhvcNAQEL +BQAwgY8xCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJDQTELMAkGA1UEBwwCTEExFDAS +BgNVBAoMC1NpZ25hbCBNb2NrMRQwEgYDVQQLDAtTaWduYWwgTW9jazEXMBUGA1UE +AwwOU2lnbmFsIE1vY2sgQ0ExITAfBgkqhkiG9w0BCQEWEmluZHV0bnlAc2lnbmFs +Lm9yZzAgFw0yMjAyMjgxOTU2NDBaGA8yMTIyMDIwNDE5NTY0MFowgY8xCzAJBgNV +BAYTAlVTMQswCQYDVQQIDAJDQTELMAkGA1UEBwwCTEExFDASBgNVBAoMC1NpZ25h +bCBNb2NrMRQwEgYDVQQLDAtTaWduYWwgTW9jazEXMBUGA1UEAwwOU2lnbmFsIE1v +Y2sgQ0ExITAfBgkqhkiG9w0BCQEWEmluZHV0bnlAc2lnbmFsLm9yZzCCAiIwDQYJ +KoZIhvcNAQEBBQADggIPADCCAgoCggIBAM3E82nXdDLWjATjl9cWHwSmstUERDCE +iZwHC2CVXxZalKo0knXDvBCbBDnenG5gaYzJVZ8s1+Vk4K193E1LDNkBuSCfnyMg +gvlNs2PSrIMy2Is8Vi+81dD2J4MBoHSCxPFO6pJQLahjbwyuFnAyffgapfHKnBbL +4zmMcd/hyYjswFZrqbdwm2DBrsJLl//vfveR+E72o9qLggIlVpP9Hii8aU9KOD1p +kE2dMkogfhhyfFFOCOOVZU/i9azYBVgicZxT1rj+O3LAwoHNMo2H0koa77WqaFbf +gqqTpm8yfSLXwWeVv4190I5R8yM/kWnt+/HsBc233wm3WyMJ26WQbQ5FYFwMOMPY +RoNY6KKBzpYKC6UUxlPUqeIKQzwGDOq4CPrjY1YyWXkszDNdT/GSJ2YPMY7P4ehF +Arw+mGLjmoqwRNSPdasU6B/y3VfAK14MHqPuM+rlBv27QiYKN12dICq4q7tITdpd +F0Qy0WLuI1XXEsH+mToPzi1yvfwEwru0jCder0CCzD18ifeYQEorWf1oaNfuVHL/ +e5Y5N0ST04oV2/GsNPlGNWgpEdKQxu3kQBDEwZHXnRgxGVCZXTQNzBQkTFJyLpnz +5ns8W4T+OJhLSV6bktqUpWbwRB0S1RYg09sP5z5qeCFdII4zlk4pcicmIQc5NwL2 +xyPie+VNHvEFAgMBAAGjEDAOMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQELBQAD +ggIBAI6Oskw0by2g1pmjU53eTqrFt4Rai2BJeUzPM3iUj6OBgB7IiXiwFdxWSLTL +SvkW7SSKOtX0Uc246qrRUStw9CPWdHBdm4LiMTgVFIFXJ0wq5oEZwrNfmV9Yqjo4 +PhgQfaAvObB6M8b/yntMQWEHT0u+oBdXEmnCYZZzLN84KeW74p2VRmqjWejtSC3g +4HR+tMhALmUUibyVCTMHEEWUe4ohi0DMjCs2/7lRoTCy8YVC7tHgYAPb1S6pS5BL +VZfOeDjWcqx6UI1JBJXnpUCMNUO6VW14slK92vSPOKoS9tpx4JatI02QSWA4T/Nf +uXMXvTskDKegvNn8hIaXqOct0FpdtoG30MKU606N9Rxnk5p0RNfsMbdu4we47G4J +D9KWxBRGh2RkCl6HLcJlm+TFsQQE1xxYcaXa/icIOrDhlWvkrwj/VZxhgMQoRNBb ++UyCD/meUxUaEDvBBlbp9gVpAqr5mIiIPdKGsgMZY63mGGWujlPXA0OC9cC+FtMU +U9xqkvgBKNs6LaLdK9AUjQq3VYaCoi3Y9lGdkjIP2s5TQ7CuWbGSmoGA439l7qo3 +gbKNhQ48VehfGKPPFOdD23U1cK+/KjqT6X3S0/AvWfaNEwVo8fNaP4j4FrWz3fRy +ef8JpIuJ1HKPBaadOw0RZy41liJGwQ0coF9HG/2Sxb7S+OgP +-----END CERTIFICATE----- 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----- +MIIJpDBOBgkqhkiG9w0BBQ0wQTApBgkqhkiG9w0BBQwwHAQICwRrFmr6qYUCAggA +MAwGCCqGSIb3DQIJBQAwFAYIKoZIhvcNAwcECJqoeaygySugBIIJUMa6daoiHqkR +uiinE2LtcfztrfvPKvZfebun1D7FiLg+QUiEQkq3C/7h3KuCvHl+c1JjUgJOmYQz +F6RHWAjZ2eAykwlinZxLTEFFze3rQVsgRunISXa/8cU8yUpBJ/sstXm7pkwRoGpc +c8rGxzaM16LluV4X59n2IuLvyRR60Chqc29C1hnjQMw2kYPfF4Tp06QgkRcMrSz4 +4ffRAhvzKJFMrnVTlW3Dgxpngtpt2mW93v4S5H78u/jsVt4XBqMLoq+F6wIqfX9b +RmGiM1RH5k0RxHC+VlapFjlE6Hpuc/jbMxfP6E6k+sy3iPP3ohdfEPBXgDO0g9Ny +XJxrLoX2HmEpJU7S/WWj+pjmHfPXQ5uyat8zWow0BBxi/pxx8Tev68bTTGXP2H3U +029XiC8abI2u8GjzM9hr5enjqAOlkeoFsTiAp9QVBfkam9aYiXnTqr/jdpTByyAX +/H5kdveqqlerjBYP/kzZ16tsqdEqeXh7lhsDJxF8fSVt+ujfmdgn0ti6bvlHAVA3 +TSnDqK19KkxVKzAxCMlbpbWzODByn3m+i/2qYsNBq6sKdA1sDZoF5u4AWzHgLDII +r0hq76AR6TYa4lWPR6jLBJXFd4o2ZFzndELdjhtV8J2Jt0dbs9C6bSV/So0UdCCr +4ldZIIU64T9sZ/z5YKaEsEsryovkib2fH7owagBTzwtL1BJgCn/L+Je85ZabtIyq +2lH7Z5AheTjeCajBBhq4G9dznVtqCrVC3jWr9rDFQyUCMnoQn7FF97Z7aJQIkDX1 +gmNot74msYOJ73yVOR524N8qDcXfRIll86rcJCBuibpd+EXIkEeDTCxEXbfXDjxb +ZR66e19PjFO1P8QBEKEdJJBcgmsvsSsfcbGaPXjamgLCwhUgc6cg+4Hwgiayta/D +iZPdVqz72QNeb4JWDawuDJX3TA74gljbtXQc2z/4tKKkEKQKI6aSG1phca/RP1Fc +uYXhYtrXeEh4eekxNkIuVYaYwdxxN8M7p5ZgF2qfCNsVeDuwFod8X5fo2NK0aF5E +gYrXv5IfHfbfVFCwBk0+Z44j4OqcRinRJv1BiKzftU/woAmNvvcXUy2tLv0rQmUk +W7woxPsfP0X6ji5eqjjKeuWwVBIjPP5FvsX3S+S4sEwC+h6OAUhNaV8ilI8hU2op +ChkQ2gqjJqgv1h4qAghlVjFcioiMOnG+1+6JHepux0jLP8unsjspYaPw2qMQlEHk +IZU0BvxxqV7J8hloMCvthI2ZzNuzW2e8aY7WXlQJYjRZddN044oroTXZkgjr4xiu +C5FSf2jwWNHfcEdlR5hT4enZ6mtj8tQzG35qIXbFG5YeNrZ8sNTTWdcnPdZZXGQ0 +DRdtijs6hfE2bfvYwHSxghLWa7VvPX7x42SEk6z1mjA5XRUSdZ6SzErDc2l52DQ1 +8FbaimWB4QVwhCud2We/XQzge+c8aAd8BfyRHchJQjsTYO4fYJqFPFjiHpjGIOFQ +YCmgZovS+6TLVoDYfYL1K82NdpudVoNJd3t0/cuhrG1afwvgdYUL0j77Ir6fP56M +E6E64XIs65i+IExp59LxMgFOQj/rmTNdAv6BaXRwzm+1EbnvmgRBjBdap5kcMr3v +BwU5UiqjZ1OlKzLONel/9tGlH5LCD/2iuNsU8I8cjcOEfGjgMwFuulHcSZtj3gJQ +YWeyKu1nHkyBVZUSywDPMC0oiNGaVYz3QEX1WvLKsjmcnOnZyHisjpS6GGUBrq9A +YAX2+1YNAGsjuEd/t8A8q/LFaT74eiY679u/vB8G2vh8jduaceD+lOM7+ge05nv7 +L4RSkTZFhrTJJ2xlYlFx/TrFSm4Smnit2niZb7n+KYUuVjMEiqu4uTky+voRi67l +BcC2X+XFtMMSdDoOBFX7exvu4JmryE5JFA8jMhfdAwXe+Q+yNsCPpxv6e0xoF6H5 +5Z4me0XHrpUpAQE+JQ0lFZFvQo9aASH7TYzgZke6VrQN2+FbqnXXPVVd3/DhZQlL +TRbBHhwTWncE7ufq6+uazHFR0vaedes5rSDwF80tLTxJgqB3kAQNEFUDTf2Yr9rn +L0L+ejXN6MWYQYrq6uJP4Z2+N0ahUJ8dmmZtjkMRkfvFo3dMHjWEH/2pKYwPfstt +a2tzcu0XgABJcqAP/NgHz6yXSL79Udq/YVRlQtp2UeuSYKOBZ09P4EU1DkCoEOBi +ai6rQfkimZ9t9FIuE9h8vNIM9//gF4Serk/mX/Jskx+0jAxekmGiCFN27PKIj/f6 +qOaSxRFoosWx2HkfI/rR70JN1zHOyjdJHvfc6iz+iX8tBu12ILPgbvudbHiAqNRw +DYVKwnZDBPXzAjChqDP/Yfk/1PPMUhn8O/k/f0Zc+gPiuTFjAw8/hhRwYWj6nF1c +AdQyh58pT9nYJ1QRfxdvgqhVf7t5sBXi53AYG0+jGb9ecJ5pNJb4NSit4EgHG2a9 +iBb9J71RHY9XfX0gCeJPrs9+eF/wCcs2EmidVbLsiJ4ul/pnWvju6knPK8aXNS2h +ppYaEgvmR/cI04BmErH5sWPxV1sVDB5G7DQWzfh4yIhUkxARWiV8hyvxQHETTaWL +M5xFV8FsQQFRj3h0UDUiyx+yr9PfflkcTGVEceqgyZPLEW0Ycx5GU9QbkJRykF44 +LLqqp+cmaHzBRbyhSH8B0TlsLhr9UW4BJtRXJL3fq/WpCxVLuKSpkyhrQ0vmY6EJ +YpbfTC3WIXPHgh2ViN0jckRt+JgWdPZ6mzZ/MT/imxfUE8axvqtd3PQMHz3xZBIL +FaIE5UTSswqRzxsu96H/RYG3nwQ9Yn2JmB0p3WDbHhINQqedxDRwk21EBzQaUNKC +WI/VsvooLFNsT064g8B8iswR2NR0ky5ty3+IGsQt0P0Bk6xuZs2CrFBjuOkg8kwK +cpF7uOR+7+tgBDNqKoATnTAYFGg7MoHI5qwvsLK90PZsnTXSAGcD7wjrY4zoOcTR +0b0VUxK0DMWWrGvr8CCdXG210GcHm4GgCYv1yOhYmeEB3iGNuMDtG1gZ53QsaJym +i5Xos12oC9Hrn9tuzged5LLFdCP8qsLun0+A0FN4p9nihpULmDRJ5TTfXinGWzPd +N3afKhX0jUX2hTkJlR6o98bHE0HsxgQLHLOUYWMdCUqhz5V7eHAkhSgwPqPinCGR +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----- +MIIHITCCBQmgAwIBAgIJAKsL4DcI3IrcMA0GCSqGSIb3DQEBCwUAMIGPMQswCQYD +VQQGEwJVUzELMAkGA1UECAwCQ0ExCzAJBgNVBAcMAkxBMRQwEgYDVQQKDAtTaWdu +YWwgTW9jazEUMBIGA1UECwwLU2lnbmFsIE1vY2sxFzAVBgNVBAMMDlNpZ25hbCBN +b2NrIENBMSEwHwYJKoZIhvcNAQkBFhJpbmR1dG55QHNpZ25hbC5vcmcwIBcNMjQx +MjA5MTUxMjAxWhgPMjEyNDExMTUxNTEyMDFaMIGQMQswCQYDVQQGEwJVUzELMAkG +A1UECAwCQ0ExCzAJBgNVBAcMAkxBMRQwEgYDVQQKDAtTaWduYWwgTW9jazEUMBIG +A1UECwwLU2lnbmFsIE1vY2sxGDAWBgNVBAMMD21vY2suc2lnbmFsLm9yZzEhMB8G +CSqGSIb3DQEJARYSaW5kdXRueUBzaWduYWwub3JnMIICIjANBgkqhkiG9w0BAQEF +AAOCAg8AMIICCgKCAgEAu3MPeLQDzykt/SYpM5r9ttV0LzTtaU9iYz8V7ujz5hhW +FF2wKjpJ8vNEDq0pJA/uBbXSXWTWBD5i5Hqv4lIljv3JhCF1s/d58it/OOuPKMC9 +5AWStcT9iHzrRSe5JV2JYrHNTT0qc468EJ63xLln5KAcu4E9mjaz0Gh3UWUof1qn ++XK9+cSN7Y3y+ta5Uq5+RUCDwfiaAilb2KbnfrYJ15zy0qB5zDndqZTcZsCn53Oq +q1mKyue/fv2tdCI/mmhEOViU7QLs8R+1/11+K9a5hzm8WdE6XY1/BORQQJPqrMJA +dcrNRuXYxVoTT83PYUaJ946pzJLf/BlbIv7TR0zLhLLDUzDRXbuVI8iyw9fs4/c3 +j35YkuY0u45cYDUc+w6WL8755mP8eDSLzPygiXBAsdBRRxHQCFtfGcmAA/UGMXrO +ggY2wGz6SJxSM9eXCD5ifQxst58/De702IdrYauQp7csBUwSIuznV7KHRYHU6AZ+ +CT7Li9R/rsZHlCwNdvK49bt3wlERnC8NxuJuDwqkS+Upwa63A7RXooDlVCFPhsYS +hLJsiXDeOg8jJT6++EM7vUZIj4cAj2H5VfZCiep+qmcTsrzj4uuUNC4Y2h3P1EEg +h6sV6/tJslyL6Xalc1goMB/6lgpDAcfEWzcXMZnZRQ6aF/xInTo8Q4bcpNNOxVUC +AwEAAaOCAXkwggF1MF8GCCsGAQUFBwEBBFMwUTAjBggrBgEFBQcwAYYXaHR0cDov +L21vY2suc2lnbmFsLm9yZy8wKgYIKwYBBQUHMAKGHmh0dHA6Ly9tb2NrLnNpZ25h +bC5vcmcvY2EuY2VydDAJBgNVHRMEAjAAMCwGA1UdEQQlMCOHBH8AAAGHEAAAAAAA +AAAAAAAAAAAAAAGCCWxvY2FsaG9zdDAdBgNVHQ4EFgQUVDRE+A8tMFLbIywM1zO7 +k8CpBjcwgbkGA1UdIwSBsTCBrqGBlaSBkjCBjzELMAkGA1UEBhMCVVMxCzAJBgNV +BAgMAkNBMQswCQYDVQQHDAJMQTEUMBIGA1UECgwLU2lnbmFsIE1vY2sxFDASBgNV +BAsMC1NpZ25hbCBNb2NrMRcwFQYDVQQDDA5TaWduYWwgTW9jayBDQTEhMB8GCSqG +SIb3DQEJARYSaW5kdXRueUBzaWduYWwub3JnghQfiz4KGPQf+skaV2zoD0lw31/k +GjANBgkqhkiG9w0BAQsFAAOCAgEAgCmc24bMgLgHd8RjMrjF3hkKug/BuY3qzns3 +NaBxRF14oLFA3I6Al3sxsH4OXSKXzSPaRfcI8QMZ0qkreWUSIVpnX8asHil45H8V +VXCiW9Df3OYfQRfLOwVa/WJ0qFVGBvQpy84iAj+xRqMQpGdj+S2NVwVpf28nPLID +1AU1y1AW8hhZOWEDwd7kLxg2H8zQS4XLyzZn7AcA/YbuEa4dw9c6bWqVDTo7Xrty +Db4S7R/QLQXJ1BwkkwCOpYj5a/hjFgpiI3hmrd5nFoxnG+DiqQpIK4JdqFaYFcSG +ho6reRbeydwBI8p7oy68EQbMxZEH39gw3Ir9By/9WwVir3wCZOef75y4Qt9QVp7d +stbMO1dH01FhnUKlJo6BHrxMlIvsfFUjyi+0L3JVXJACCTc2eMqmb8m1yJuVgRxG +9gcbaTBFjsjZ9WX7U6rMlKm57ruNzF9gTJbbNykT8gNoNPBIhPJEZITtvy+k0jc0 +2Z5J3o8rtYVu8sX4EVq5UrEMn+yoxvAU7ZqQSyQSr+4ore91TcBvL3mYrrthXAzx +35ytcuoJQCX0WDwvSoGZmaKQhQqt3bZBHWl2Fxz7S6zMGBkS3uZL9Pja/8xyyyFM +7RmQv09a7vWfBsC2w4pwe1osqn98o8zIpFujI6uLXQ82rig4iB9u+H4ReL58kntk +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----- +MIIHITCCBQmgAwIBAgIJAKsL4DcI3IrcMA0GCSqGSIb3DQEBCwUAMIGPMQswCQYD +VQQGEwJVUzELMAkGA1UECAwCQ0ExCzAJBgNVBAcMAkxBMRQwEgYDVQQKDAtTaWdu +YWwgTW9jazEUMBIGA1UECwwLU2lnbmFsIE1vY2sxFzAVBgNVBAMMDlNpZ25hbCBN +b2NrIENBMSEwHwYJKoZIhvcNAQkBFhJpbmR1dG55QHNpZ25hbC5vcmcwIBcNMjQx +MjA5MTUxMjAxWhgPMjEyNDExMTUxNTEyMDFaMIGQMQswCQYDVQQGEwJVUzELMAkG +A1UECAwCQ0ExCzAJBgNVBAcMAkxBMRQwEgYDVQQKDAtTaWduYWwgTW9jazEUMBIG +A1UECwwLU2lnbmFsIE1vY2sxGDAWBgNVBAMMD21vY2suc2lnbmFsLm9yZzEhMB8G +CSqGSIb3DQEJARYSaW5kdXRueUBzaWduYWwub3JnMIICIjANBgkqhkiG9w0BAQEF +AAOCAg8AMIICCgKCAgEAu3MPeLQDzykt/SYpM5r9ttV0LzTtaU9iYz8V7ujz5hhW +FF2wKjpJ8vNEDq0pJA/uBbXSXWTWBD5i5Hqv4lIljv3JhCF1s/d58it/OOuPKMC9 +5AWStcT9iHzrRSe5JV2JYrHNTT0qc468EJ63xLln5KAcu4E9mjaz0Gh3UWUof1qn ++XK9+cSN7Y3y+ta5Uq5+RUCDwfiaAilb2KbnfrYJ15zy0qB5zDndqZTcZsCn53Oq +q1mKyue/fv2tdCI/mmhEOViU7QLs8R+1/11+K9a5hzm8WdE6XY1/BORQQJPqrMJA +dcrNRuXYxVoTT83PYUaJ946pzJLf/BlbIv7TR0zLhLLDUzDRXbuVI8iyw9fs4/c3 +j35YkuY0u45cYDUc+w6WL8755mP8eDSLzPygiXBAsdBRRxHQCFtfGcmAA/UGMXrO +ggY2wGz6SJxSM9eXCD5ifQxst58/De702IdrYauQp7csBUwSIuznV7KHRYHU6AZ+ +CT7Li9R/rsZHlCwNdvK49bt3wlERnC8NxuJuDwqkS+Upwa63A7RXooDlVCFPhsYS +hLJsiXDeOg8jJT6++EM7vUZIj4cAj2H5VfZCiep+qmcTsrzj4uuUNC4Y2h3P1EEg +h6sV6/tJslyL6Xalc1goMB/6lgpDAcfEWzcXMZnZRQ6aF/xInTo8Q4bcpNNOxVUC +AwEAAaOCAXkwggF1MF8GCCsGAQUFBwEBBFMwUTAjBggrBgEFBQcwAYYXaHR0cDov +L21vY2suc2lnbmFsLm9yZy8wKgYIKwYBBQUHMAKGHmh0dHA6Ly9tb2NrLnNpZ25h +bC5vcmcvY2EuY2VydDAJBgNVHRMEAjAAMCwGA1UdEQQlMCOHBH8AAAGHEAAAAAAA +AAAAAAAAAAAAAAGCCWxvY2FsaG9zdDAdBgNVHQ4EFgQUVDRE+A8tMFLbIywM1zO7 +k8CpBjcwgbkGA1UdIwSBsTCBrqGBlaSBkjCBjzELMAkGA1UEBhMCVVMxCzAJBgNV +BAgMAkNBMQswCQYDVQQHDAJMQTEUMBIGA1UECgwLU2lnbmFsIE1vY2sxFDASBgNV +BAsMC1NpZ25hbCBNb2NrMRcwFQYDVQQDDA5TaWduYWwgTW9jayBDQTEhMB8GCSqG +SIb3DQEJARYSaW5kdXRueUBzaWduYWwub3JnghQfiz4KGPQf+skaV2zoD0lw31/k +GjANBgkqhkiG9w0BAQsFAAOCAgEAgCmc24bMgLgHd8RjMrjF3hkKug/BuY3qzns3 +NaBxRF14oLFA3I6Al3sxsH4OXSKXzSPaRfcI8QMZ0qkreWUSIVpnX8asHil45H8V +VXCiW9Df3OYfQRfLOwVa/WJ0qFVGBvQpy84iAj+xRqMQpGdj+S2NVwVpf28nPLID +1AU1y1AW8hhZOWEDwd7kLxg2H8zQS4XLyzZn7AcA/YbuEa4dw9c6bWqVDTo7Xrty +Db4S7R/QLQXJ1BwkkwCOpYj5a/hjFgpiI3hmrd5nFoxnG+DiqQpIK4JdqFaYFcSG +ho6reRbeydwBI8p7oy68EQbMxZEH39gw3Ir9By/9WwVir3wCZOef75y4Qt9QVp7d +stbMO1dH01FhnUKlJo6BHrxMlIvsfFUjyi+0L3JVXJACCTc2eMqmb8m1yJuVgRxG +9gcbaTBFjsjZ9WX7U6rMlKm57ruNzF9gTJbbNykT8gNoNPBIhPJEZITtvy+k0jc0 +2Z5J3o8rtYVu8sX4EVq5UrEMn+yoxvAU7ZqQSyQSr+4ore91TcBvL3mYrrthXAzx +35ytcuoJQCX0WDwvSoGZmaKQhQqt3bZBHWl2Fxz7S6zMGBkS3uZL9Pja/8xyyyFM +7RmQv09a7vWfBsC2w4pwe1osqn98o8zIpFujI6uLXQ82rig4iB9u+H4ReL58kntk +hDBJ4zQ= +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIFwDCCA6igAwIBAgIUH4s+Chj0H/rJGlds6A9JcN9f5BowDQYJKoZIhvcNAQEL +BQAwgY8xCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJDQTELMAkGA1UEBwwCTEExFDAS +BgNVBAoMC1NpZ25hbCBNb2NrMRQwEgYDVQQLDAtTaWduYWwgTW9jazEXMBUGA1UE +AwwOU2lnbmFsIE1vY2sgQ0ExITAfBgkqhkiG9w0BCQEWEmluZHV0bnlAc2lnbmFs +Lm9yZzAgFw0yMjAyMjgxOTU2NDBaGA8yMTIyMDIwNDE5NTY0MFowgY8xCzAJBgNV +BAYTAlVTMQswCQYDVQQIDAJDQTELMAkGA1UEBwwCTEExFDASBgNVBAoMC1NpZ25h +bCBNb2NrMRQwEgYDVQQLDAtTaWduYWwgTW9jazEXMBUGA1UEAwwOU2lnbmFsIE1v +Y2sgQ0ExITAfBgkqhkiG9w0BCQEWEmluZHV0bnlAc2lnbmFsLm9yZzCCAiIwDQYJ +KoZIhvcNAQEBBQADggIPADCCAgoCggIBAM3E82nXdDLWjATjl9cWHwSmstUERDCE +iZwHC2CVXxZalKo0knXDvBCbBDnenG5gaYzJVZ8s1+Vk4K193E1LDNkBuSCfnyMg +gvlNs2PSrIMy2Is8Vi+81dD2J4MBoHSCxPFO6pJQLahjbwyuFnAyffgapfHKnBbL +4zmMcd/hyYjswFZrqbdwm2DBrsJLl//vfveR+E72o9qLggIlVpP9Hii8aU9KOD1p +kE2dMkogfhhyfFFOCOOVZU/i9azYBVgicZxT1rj+O3LAwoHNMo2H0koa77WqaFbf +gqqTpm8yfSLXwWeVv4190I5R8yM/kWnt+/HsBc233wm3WyMJ26WQbQ5FYFwMOMPY +RoNY6KKBzpYKC6UUxlPUqeIKQzwGDOq4CPrjY1YyWXkszDNdT/GSJ2YPMY7P4ehF +Arw+mGLjmoqwRNSPdasU6B/y3VfAK14MHqPuM+rlBv27QiYKN12dICq4q7tITdpd +F0Qy0WLuI1XXEsH+mToPzi1yvfwEwru0jCder0CCzD18ifeYQEorWf1oaNfuVHL/ +e5Y5N0ST04oV2/GsNPlGNWgpEdKQxu3kQBDEwZHXnRgxGVCZXTQNzBQkTFJyLpnz +5ns8W4T+OJhLSV6bktqUpWbwRB0S1RYg09sP5z5qeCFdII4zlk4pcicmIQc5NwL2 +xyPie+VNHvEFAgMBAAGjEDAOMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQELBQAD +ggIBAI6Oskw0by2g1pmjU53eTqrFt4Rai2BJeUzPM3iUj6OBgB7IiXiwFdxWSLTL +SvkW7SSKOtX0Uc246qrRUStw9CPWdHBdm4LiMTgVFIFXJ0wq5oEZwrNfmV9Yqjo4 +PhgQfaAvObB6M8b/yntMQWEHT0u+oBdXEmnCYZZzLN84KeW74p2VRmqjWejtSC3g +4HR+tMhALmUUibyVCTMHEEWUe4ohi0DMjCs2/7lRoTCy8YVC7tHgYAPb1S6pS5BL +VZfOeDjWcqx6UI1JBJXnpUCMNUO6VW14slK92vSPOKoS9tpx4JatI02QSWA4T/Nf +uXMXvTskDKegvNn8hIaXqOct0FpdtoG30MKU606N9Rxnk5p0RNfsMbdu4we47G4J +D9KWxBRGh2RkCl6HLcJlm+TFsQQE1xxYcaXa/icIOrDhlWvkrwj/VZxhgMQoRNBb ++UyCD/meUxUaEDvBBlbp9gVpAqr5mIiIPdKGsgMZY63mGGWujlPXA0OC9cC+FtMU +U9xqkvgBKNs6LaLdK9AUjQq3VYaCoi3Y9lGdkjIP2s5TQ7CuWbGSmoGA439l7qo3 +gbKNhQ48VehfGKPPFOdD23U1cK+/KjqT6X3S0/AvWfaNEwVo8fNaP4j4FrWz3fRy +ef8JpIuJ1HKPBaadOw0RZy41liJGwQ0coF9HG/2Sxb7S+OgP +-----END CERTIFICATE----- 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----- +MIIJKAIBAAKCAgEAu3MPeLQDzykt/SYpM5r9ttV0LzTtaU9iYz8V7ujz5hhWFF2w +KjpJ8vNEDq0pJA/uBbXSXWTWBD5i5Hqv4lIljv3JhCF1s/d58it/OOuPKMC95AWS +tcT9iHzrRSe5JV2JYrHNTT0qc468EJ63xLln5KAcu4E9mjaz0Gh3UWUof1qn+XK9 ++cSN7Y3y+ta5Uq5+RUCDwfiaAilb2KbnfrYJ15zy0qB5zDndqZTcZsCn53Oqq1mK +yue/fv2tdCI/mmhEOViU7QLs8R+1/11+K9a5hzm8WdE6XY1/BORQQJPqrMJAdcrN +RuXYxVoTT83PYUaJ946pzJLf/BlbIv7TR0zLhLLDUzDRXbuVI8iyw9fs4/c3j35Y +kuY0u45cYDUc+w6WL8755mP8eDSLzPygiXBAsdBRRxHQCFtfGcmAA/UGMXrOggY2 +wGz6SJxSM9eXCD5ifQxst58/De702IdrYauQp7csBUwSIuznV7KHRYHU6AZ+CT7L +i9R/rsZHlCwNdvK49bt3wlERnC8NxuJuDwqkS+Upwa63A7RXooDlVCFPhsYShLJs +iXDeOg8jJT6++EM7vUZIj4cAj2H5VfZCiep+qmcTsrzj4uuUNC4Y2h3P1EEgh6sV +6/tJslyL6Xalc1goMB/6lgpDAcfEWzcXMZnZRQ6aF/xInTo8Q4bcpNNOxVUCAwEA +AQKCAgBIOGKDnMfC7xi66hMBwPtzj/X9oiS+aEl993ZZ4ALoagPwQNB41JBdPIDC +LtElBcYtCQqN1SXn6KltYh+V0RSLbRhRAhs5RWYEeeHAanFzwB7pVYRa6BTOm2KB ++HYLAWjHT73Lvn82mo220Y/4CX8PxOiNDZRQcDcDWtxtzc22k5UYNK8hJiuQlRpC +rqwkQPqBcAPTnhaoMosig9u5JCXSnrOnlxCWMM1IYwQvypZmRFhDQHKLDpLnOJG1 +puRed0Gh3pEyZ+gmVpNmWf0cotREV2hMKqKUHMoNdnG9D8Bg4062k3ZXlILaNoQu +QRtfXTAW4y5klUoa9SH577gzRlE6m7z21mBqvi/4/UaJFM/x7XEGNfVkqosH/QAT +PnKfmPDQOxJv649uzxuTv1GGUGR/VTirtDabC4vAkAX1NAOmRIY+42tg8uznXy7m +zRBGjWsbYS2MwIUD0KehDhpd7hzNbx5yxYO1r6tBYe48fw1OTZLan8JzjH2E/Okz +qEv4r3h6nm+u//Whxx4JVqjUI4yLg0KuAUdmiAt0mMcNXEapglIGUteEI/yYMzd7 +uqO+h6gUmCVO8fCv9GHsLGOSxC/oqmCAwaLJ8LuRe+cgvsuHsgxojyIuJ56Kt4+B +lRlF1yQbsEx3Jp2kfMrZg+gizEiSc/FD2xfIHb1cEViLl1UWGQKCAQEA7vT4iDvv +B6QqUA22Y8+tTELc2RkPAi6Cumox7mrhNnVgmHuI/6RmK6Q8K0uGFM5utjoPMGuD ++ydA/yyDTGhhuyH1cTCxSwLFsnfwgDWvWgmOEOejVnaZUeNcsT+OsaRxjNBhfEHW +rglQln1GHkHwAGf5bvFfAcDXLY49dfuOman16hHxuQnHBii2sa6ASBoBTW4NDeqj +Dm5Ga8ZWDkqMvvfJ048CQyXn+d681GDWCt9JaRSuH9gzxQQFH0D1EC7vTiyZzN9M +nOAnWUZ4opwy1hJClXPwwmL8d7jUfG3Tw/UDCKkD6cliippJEliMnA/DM5ZobU22 +zeLxk+TlcGwG9wKCAQEAyNGiGhU6e5/K9oKjc2oOhm1KP5UEl0cvUUyYLca+IoA4 +4O/tcQDqSTMhWD5tkfas2bsRtbEsKxqgUg6By2IczOjrn/h9at1ujeSA0n4f4CIY +hxQdkhE6IyrQHIn9waAtNCcvDk2ehD8Vjh9iwS0ATukGJ1uclO399OwwMIcj/SZ/ +65KSh2WY42LmK0Be7A+fqFvNPr5jXvD/acg0XP2T4aMG5sE7OaK0BiTEDAsbWi1o +KWz9iPvZtNNoGD2XIFJmF50Ikotk+lCBy7YMA3Y8e3/gg5vilmR3ay3fLQDIwfyF +Rcrf8JzfeQeJH2UiOCGQVAgg55Mwqbu/ay9rW9KHEwKCAQEAr0m+gtlMR9OyQlU8 +xU0T+AxYS10peFU8CplaFWYL3VIPUOvWHImxdTQd+ziEnACukDhY6hEEmRk7gbRa +gJNVlducW7L3a2oWMgvvvW0kO60krNvvIr8PS1W4qkFQYJmbvksiJ+94FuS4XBx7 +cji2YOXkwPCI3BVlA4MDLOgivDBEN7eAFVfJyofVNNQoQDvrVqxzIRFNGYnlWKv+ +dq9TPccxI2MVqsJEwDQXWsfKW+FdzZqg/LUxjMWdKEcTPhLf8v2euP3ZCn8X/lJG +ripc1FJCy7VoGIBaaUyJetlp7aZu2kx5lWboRXpWPgH864JYlCAybHQEtFVVF0Ni +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": "AO+mldTEMQNCmAuOfmpEtB7SgGUTS+hjHS5fxuQTOakH8AgBJFAyly5hxxRq99J2gfclDWVUSILAcpVIEl61qQu0f36tHEmpQauCIlDQHZltasHCQCTP/UdoOUDq17w+bwVPDQPLdUD9zL7kV1HmTA3PHZDYQ1dMM2qtnlBtrqwAkIivfOkdpSjggzG6DUMksU3dMa80XyXKGu7Wkc23DQbegnpO4KX1bY/dRnrW9pdvq5s/fWUdSq4vuqgYdNv+C6W9Shirn8eDpw8eTtl3SHUFrNwHumPvnlPnioljmAILmg+8pz5JJH52yY+i7EvU9pe5G6nkB00Qo69lpoWfDAh7Cfjr9wL6AsX2GkaYisyWQQGo+0ZjFr4QkvvSC83TBSK6MI2KosIo/Ne0RZJByuVUKryZrCnvS1NBCKBqaEVMBK8WjHp29d0uqYTrW1YsU1zNEzA9nVVQoi2+dumkG2WxCIpoKZ8JSoKPoWxGo23VRgKAMmRW5VqCLYkassVHDSwQMfeN1RJXwg79LagzJOyeFwpPk2S1kpgHIj5+Z/oJSrWEik3Z2mvC69RlR43vrE0Qcg+wAOqQuGb/E6d9bRtbFQ/Fj/Qb5KqIbDW/rvAGikjkW05n/yloZkVcnrMgBGCmvejptBZkGW/AGnIqTPi6+Mkdmde+kU89Hx9ivhgGsJzYz6XPahVe8iU/w+g8kIP0lJKECR3xf+5tq/erngvNmeSJ/B5+LmC1Fal0LvfIodcg/CBfXDyeLEnOhgxuAFVAnpUKZqPWB5TenMTLWEtAvX+uc2HrZcu/ZXtZ2l8CBmW8dq8J6fAhB6HCx7szqEhjMsEAFyiQjfy2rMSQcgLMsW9pgMcG7c0Ms63XPauVr/QTKZZUqqehXtdckDGsYvIWVHoslT8CotrCdF5fkVFJMZLBEfPbWhxVjtBlEkcRH9RCCt9eenAEYZcS+ZMW+PZCKzjI9sbVionBLuowqgIODXul+3AQrRfP57ZLjZHwd+el+c/1ZXmJ+/InsF6/IHAlYxuvpn19fzaIJIZTHNdgnQ7dLalOhSaJV32ifoALIpk4JY6jjD3JZLYNlpvIzJx+T0RNZ5CmZrMMeMSnTgGMY5Nk2UtdbJV3gYyw2Ibmm9oxnXS1Ufo/IZjXrD7ecScsrtdQ4ByFZabRBrikm2Os7NGIvC8qE73QbjdvOOsEVWMPvF0qa978CTuBRwdjv64tUppCexyZTwgCfk/viA0ERhkA4GbVCP5GcRi/cIOBbIuUfUZgx4kCvCBRlDiGCVJ5oMg3+djc+Om23gQ7eZQdPeEk2/e/P4FsxLXCHjcL5LXYv/xYCjI+J2v8ZmLmXdszNHWlzT4QcpL6WYkaiAgmzxVyzphYvyyvBBJ0rchQyBgu3WLEOMM7qbC49RXZBHgdfCjrc/yBWWWaMOFYO4jnsXZ2vVP+1mcTv5PF654PmMPGNo3b5uATe4Z/bEK35Eb51P/WeTIWlmTTaNeP+2lsjCe4i0Qcvst4SitCF1YrBifIwQbNfUUwswvv+TCUCqWkcqO5hifj7JEmVGdVVU+OhjluRV3Ia0zTPHquJMUGQq222UZWqtnSoTXHPEBFGWHeBc/Wv8o9o4gOoYeTaAoYTQG7Jp5dV5Hu3of2eldYPR1dntYIfjurWwNx72w3BvDiOl+O069G4/oEyHyHU4FOPYDszmtpzhBTe5fD3/AOEbGZYBbZ9ckrtKaWHOSu1XkBMb9b54hIWfY5cIoFAQnyo4XMjuHXS8U22k9dPQn9Ddd6OUfyve4v7OSbKa/fCcvJvnshxp4sK1HSlOWJhAI2MphhX9bGyVgxTQpBe0AIjGeq7KOthm4kokBsNk9UuUlOcJFeJUuUxZv9IkqAlgEeFmFDechpob+HJDidjF5y8WB+JFO0oE/CFrv7FL2OAm5Lr19jtqotWrOhRHQ8AqyZYiW6luYtM11trKlqRcgOTJWFHuLw9GBo3RHxH8jQnPxEJHxqUUGh1e1uSHwCJm2OMUXZDKNJfG4N7p6lPvTCzA0ZxePAw6D6EllhQzNUWWxuJnoPBvFlH1Vul5lB8DTAFDFGDUMKyG4QFOPCHD8DL8YNihDnUtIvwI1Vpub617CtK8IJ2CsYQZygFGr+wg0c5vJBch+vFDJhgEsrSfakznA+1LUSlR5Du4sK/PsnZqwvcSxCbQRrkf01Sh9VpKCkscl98thS403J5xHvkUEN/KUcU6TVxMiTOcFnrYdShvN36Vv9XRlwAj6dsTOUsgmybsaZDxwRPPKkBi6IEY0NYgyfx6XYXn+Q56ZOFhaUCSew9Tofs72hNf+LhvQj3PgE1vIsxaPLhzWdYYTJ3LALjdNGyXYFTA4SeH38Z3vX7++rQGExfQfnvaC7Bp8Q1AnceagrYazptq4W0JjNFTt1mtrrUKrloJGH1sKB8r1RCzdx5ITMf0JbA7+TRx4n+dnSRCY0dBnrd1fO2D4MjWQBIEL+Pz/t6ryYimIe3EtK1QA6cIBLOFGaQ0HdaIX6vwyYPpsDWBuyYZKMWHsIJMdAa3wn6GgaePl/Gn5AQb1oRUfpr1AUndnqejnMDFDxhkhMwkI2cJ5gb7cZBCBXkWIErT6dR8ACypg/Lf7chsw/Uw8A31vkZxy0IIL+Q+YBEQr8l0M9S9vPz6VvWG+lUVzL5rlC07cObZoGVnVy403QYiChHWiwSudNMPdVh06+fub4Q5/y/utIwuQ1yqB+U4sKyouEyEUaC0+lVYbM5iMTGvbozw0HuWAQCMqpbMCMQw9CfuGTVGjuxnJPH6g8gIOFyQB76iyQmmFARdNpczadDuHoMHMnxpe2XR8PMyN9puTXmqsAFSccDEkA1qv8RhoLWau2HyHYFkkjfc3OFrKscBnSaTGgsuEjtM6JKwt+cQxleMlQUVz/0MP0ylrqdOXPFc40WJ+d5Bd9MFBS9Ju8A8C1Tr5D6/XONY41WWTz2HhYOcxyggsprUCcPGTJrKEDLkyavAMzlhmVb0d261EaweFOdFr9oHWqOkgWXQrZYRVg8gGpRhDZW8KybgN9z7eq6grgQpPf0jFSdOeXVXHAdOII3mS/X9Tgjr792i8dU9rbIYxlolV+YPCuqWc5Q50GaJCwE4f/dAeNFv+7Iqd5ThKBgNTgUWC6R86qCxA0oAMeo8+1k803g1ErYvb4Z5CGJ2MycrGJl2mqh5uXweu0NT4ekHiGZrXU8MvC8gPx3eZscXuzfoQPWMuVBu6716UFyfFZnugOJmtSg9l6yXQNqVZDRBeZe1az2VS6lZanQQWeI3dRUtFGO1efh0kf9YIyZAa+N+MLFBezxY9JIHJtAY16lWQqYibgOorEJGUqS2eCOYcU5aQBZkGzDIYCeX8Iva4LOv/3w2F0z4BdkJEw5zIpiYtvZIZWbaMbr5bgvAUAg+INObvNqkzLwvIg5BjXqT+ywnpLDsiaqlFHrNnRBf5os8NUfR1cxH774pyEPgzfphs8sVaZ9fmZ9ho9lSEIwqQVnKKSTPg27hOwsz8XEn6XoUoxfWwZM7z0oaw+sQnripjI7kllOzgKeuRTPqyaeyiN4EifpRD0I53lXEqmCLAPzucIgYVpNd7xkAGKDucPaluUGpkbqqfWZHvkbgMI", + "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 | undefined; + assert(item, 'consistency check'); + + return this.replaceItem(itemIndex, { + type: item.type, + record: map(item.record), + }); + } + + public updateManyItems( + filter: StorageItemPredicate, + map: StorageRecordMapper, + ): StorageState { + let updated = 0; + const newItems = this.items.map((item, index) => { + if (filter(item, index)) { + updated += 1; + return new StorageStateItem({ + type: item.type, + key: StorageState.createStorageID(), + record: map(item.record), + }); + } else { + return item; + } + }); + if (updated === 0) { + throw new Error('No items updated'); + } + return new StorageState(this.version, newItems); + } + + private replaceItem( + index: number, + { + type, + record, + key = StorageState.createStorageID(), + }: StorageStateNewRecord, + ): StorageState { + const newItems = [ + ...this.items.slice(0, index), + new StorageStateItem({ type, key, record }), + ...this.items.slice(index + 1), + ]; + + return new StorageState(this.version, newItems); + } + + private removeItem( + find: (item: StorageStateItem, index: number) => boolean, + ): StorageState { + const itemIndex = this.findItemIndex(find); + + const newItems = [ + ...this.items.slice(0, itemIndex), + ...this.items.slice(itemIndex + 1), + ]; + + return new StorageState(this.version, newItems); + } + + private removeManyItems( + filter: (item: StorageStateItem, index: number) => boolean, + ): StorageState { + const newItems = this.items.filter((item, index) => { + return !filter(item, index); + }); + if (newItems.length === this.items.length) { + throw new Error('No items removed'); + } + return new StorageState(this.version, newItems); + } + + private changePin( + { device }: PrimaryDevice, + serviceIdKind: ServiceIdKind, + isPinned: boolean, + ): StorageState { + const deviceServiceIdBinary = + device.getServiceIdBinaryByKind(serviceIdKind); + + return this.updateItem( + (item) => item.isAccount(), + (record) => { + const { account } = record; + + const { pinnedConversations } = account; + + const newPinnedConversations = pinnedConversations?.slice() ?? []; + + const existingIndex = newPinnedConversations.findIndex((convo) => { + if (convo.identifier?.contact == null) { + return false; + } + const existing = convo.identifier.contact.serviceIdBinary; + return ( + existing && Buffer.compare(existing, deviceServiceIdBinary) === 0 + ); + }); + + if (isPinned && existingIndex === -1) { + newPinnedConversations.push({ + identifier: { + contact: { + e164: null, + serviceIdBinary: deviceServiceIdBinary, + }, + }, + }); + } else if (!isPinned && existingIndex !== -1) { + newPinnedConversations.splice(existingIndex, 1); + } + + return { + account: { + ...account, + pinnedConversations: newPinnedConversations, + }, + }; + }, + ); + } + + private changeGroupPin(group: Group, isPinned: boolean): StorageState { + return this.updateItem( + (item) => item.isAccount(), + (record) => { + const { account } = record; + const { pinnedConversations } = + account satisfies Proto.AccountRecord.Params; + + const newPinnedConversations = pinnedConversations?.slice() ?? []; + + const existingIndex = newPinnedConversations.findIndex((convo) => { + if (convo.identifier?.groupMasterKey == null) { + return false; + } + return group.masterKey.equals(convo.identifier.groupMasterKey); + }); + + if (isPinned && existingIndex === -1) { + newPinnedConversations.push({ + identifier: { + groupMasterKey: group.masterKey, + }, + }); + } else if (!isPinned && existingIndex !== -1) { + newPinnedConversations.splice(existingIndex, 1); + } + + return { + account: { + ...account, + pinnedConversations: newPinnedConversations, + }, + }; + }, + ); + } + + private static createStorageID(): Buffer { + return crypto.randomBytes(KEY_SIZE); + } +} diff --git a/packages/mock-server/src/calling.ts b/packages/mock-server/src/calling.ts new file mode 100644 index 0000000000..65c96de2e0 --- /dev/null +++ b/packages/mock-server/src/calling.ts @@ -0,0 +1,299 @@ +// Copyright 2026 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { + createHash, + createHmac, + randomBytes, + randomInt, + timingSafeEqual, +} from 'node:crypto'; +import z from 'zod'; +import { parseAuthHeader } from './util'; +import { CallingPublicKey } from './sfu/crypto'; + +export type Uint32 = number & { Uint32: never }; + +const UINT32_MAX = 4_294_967_296; // 2 ** 32 − 1 + +function getRandomUint32(): Uint32 { + const minInclusive = 0; + const maxExclusive = UINT32_MAX + 1; + return randomInt(minInclusive, maxExclusive) as Uint32; +} + +export type HexString = string & { HexString: never }; + +export function getRandomHexString(size: number): HexString { + return randomBytes(size).toString('hex') as HexString; +} + +/** + * Calling IDs + * ---------------------------------------------------------------------------- + */ + +/** + * In multi-participant calls, the room id is the permanent ID + * equivalent to an ACI. It comes from a shared secret owned by the clients. + */ +export type CallingRoomId = string & { RoomId: never }; + +/** + * Ephemeral, generated by calling service. + * Only used for group/adhoc calls. + */ +export type CallingEraId = HexString & { EraId: never }; + +export function getRandomCallingEraId(): CallingEraId { + return getRandomHexString(16) as CallingEraId; +} + +export type CallingDemuxId = Uint32 & { DemuxId: never }; + +export function getRandomCallingDemuxId(): CallingDemuxId { + return ((getRandomUint32() & ~0b1111) >>> 0) as CallingDemuxId; +} + +export type CallingUserId = HexString & { UserId: never }; + +export enum CallType { + Group, + Adhoc, +} + +export enum CallLinkRestrictions { + AdminApproval, + None, +} + +/** + * Http + * ---------------------------------------------------------------------------- + */ + +export const CallingPublicKeySchema = z + .string() + .nonempty() + .transform((input) => { + return input as HexString; + }); + +export function decodeCallingPublicKey(input: HexString): CallingPublicKey { + return CallingPublicKey.fromBytes(Buffer.from(input, 'hex')); +} + +export function encodeCallingPublicKey(key: CallingPublicKey): HexString { + return Buffer.from(key.toBytes()).toString('hex') as HexString; +} + +/** + * Peek + * ---------------------------------------------------------------------------- + */ + +export type CallInfoClient = Readonly<{ + demuxId: CallingDemuxId; + opaqueUserId: CallingUserId; +}>; + +export type CallInfo = Readonly<{ + eraId: CallingEraId; + maxClients: number; + creatorUserId: CallingUserId; + activeClients: ReadonlyArray; + pendingClients: ReadonlyArray | null; +}>; + +/** + * Errors + * ---------------------------------------------------------------------------- + */ + +export enum CallingErrorCode { + AuthError, + CallNotFound, + TooManyClients, + NoPermissionToCreateCall, + DuplicateDemuxIdDetected, + InternalError, +} + +export class CallingError extends Error { + #code: CallingErrorCode; + constructor(code: CallingErrorCode, message?: string) { + super(message); + this.#code = code; + } + get code(): CallingErrorCode { + return this.#code; + } +} + +export const CallingErrorCodesToHttpStatus: Record = { + [CallingErrorCode.AuthError]: 401, + [CallingErrorCode.CallNotFound]: 404, + [CallingErrorCode.TooManyClients]: 413, + [CallingErrorCode.NoPermissionToCreateCall]: 500, + [CallingErrorCode.DuplicateDemuxIdDetected]: 500, + [CallingErrorCode.InternalError]: 500, +}; + +/** + * Auth + * ---------------------------------------------------------------------------- + */ + +export const CALLING_SERVICE_SECRET = randomBytes(32); + +function parseNumberSafe(value: string): number | null { + const trimmed = value.trim(); + if (trimmed === '') { + return null; + } + const parsed = Number(value); + if (!Number.isFinite(parsed)) { + return null; + } + return parsed; +} + +export type CallingAuthToken = Readonly<{ + userId: CallingUserId; + groupId: CallingRoomId; + time: number; + isAllowedToInitiateGroupCall: boolean; + macCiphertext: string; + macDigest: Uint8Array; +}>; + +export function parseCallingAuthHeader(authHeader?: string): CallingAuthToken { + const { error, password } = parseAuthHeader(authHeader); + if (error != null) { + throw new CallingError(CallingErrorCode.AuthError, error); + } + + const [ + version = '', + userIdStr = '', + groupIdHex = '', + timeUnixSecsStr = '', + permissionStr = '', + macDigestHex = '', + ] = password.split(':'); + + if (version !== '2') { + throw new CallingError( + CallingErrorCode.AuthError, + 'Unsupported call auth signature', + ); + } + + const userId = userIdStr as CallingUserId; + if (userId.length === 0) { + throw new CallingError(CallingErrorCode.AuthError, 'Missing userId'); + } + + const groupId = Buffer.from(groupIdHex, 'hex').toString() as CallingRoomId; + if (groupId.length === 0) { + throw new CallingError(CallingErrorCode.AuthError, 'Missing groupId'); + } + + const timeUnixSecs = parseNumberSafe(timeUnixSecsStr); + if (timeUnixSecs == null) { + throw new CallingError( + CallingErrorCode.AuthError, + 'Time not encoded correctly', + ); + } + const time = timeUnixSecs * 1000; + + const isAllowedToInitiateGroupCall = permissionStr === '1'; + + const macDigest = Buffer.from(macDigestHex, 'hex'); + if (macDigest.length === 0) { + throw new CallingError(CallingErrorCode.AuthError, 'Missing macDigest'); + } + + const macCiphertext = password.slice( + 0, + password.length - macDigestHex.length - 1, + ); + + const token: CallingAuthToken = { + userId, + groupId, + time, + isAllowedToInitiateGroupCall, + macCiphertext, + macDigest, + }; + + return token; +} + +const GV2_AUTH_MATCH_LIMIT = 10; +const GV2_AUTH_MAX_HEADER_AGE = 30 * 60 * 60 * 1000; + +export type CallingAuth = Readonly<{ + userId: CallingUserId; + roomId: CallingRoomId; + isAllowedToInitiateGroupCall: boolean; +}>; + +export function verifyCallingAuthToken( + token: CallingAuthToken, + key: Uint8Array, +): CallingAuth { + const expectedDigest = createHmac('sha256', key) + .update(token.macCiphertext) + .digest() + .subarray(0, GV2_AUTH_MATCH_LIMIT); + + if (!timingSafeEqual(expectedDigest, token.macDigest)) { + throw new CallingError(CallingErrorCode.AuthError, 'Incorrect hmac digest'); + } + + if (Date.now() > token.time + GV2_AUTH_MAX_HEADER_AGE) { + throw new CallingError(CallingErrorCode.AuthError, 'Expired credentials'); + } + + const auth: CallingAuth = { + userId: token.userId, + roomId: token.groupId, + isAllowedToInitiateGroupCall: token.isAllowedToInitiateGroupCall, + }; + + return auth; +} + +export type GenerateCallingAuthTokenOptions = Readonly<{ + userId: Uint8Array; + groupId: string; + isAllowedToInitiateGroupCall: boolean; + key: Uint8Array; +}>; + +export function generateCallingAuthToken( + options: GenerateCallingAuthTokenOptions, +): string { + let data = ''; + + data += '2'; + data += ':'; + data += createHash('sha256').update(options.userId).digest('hex'); + data += ':'; + data += Buffer.from(options.groupId).toString('hex'); + data += ':'; + data += `${Math.trunc(Date.now() / 1000)}`; + data += ':'; + data += options.isAllowedToInitiateGroupCall ? '1' : '0'; + + const hmac = createHmac('sha256', options.key) + .update(data) + .digest() + .subarray(0, GV2_AUTH_MATCH_LIMIT) + .toString('hex'); + + return `${data}:${hmac}`; +} diff --git a/packages/mock-server/src/constants.ts b/packages/mock-server/src/constants.ts new file mode 100644 index 0000000000..5df8f845d5 --- /dev/null +++ b/packages/mock-server/src/constants.ts @@ -0,0 +1,15 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +export const PRIMARY_DEVICE_ID = 1; +export const PRIMARY_SIGNED_PREKEY_ID = 1; + +export const SERVER_CERTIFICATE_ID = 1; + +export const NEVER_EXPIRES = Number.MAX_SAFE_INTEGER; + +export const MAX_GROUP_CREDENTIALS_DAYS = 7; + +export const DAY_IN_SECONDS = 24 * 3600; + +export const PROFILE_KEY_CREDENTIAL_EXPIRATION = 7 * DAY_IN_SECONDS; diff --git a/packages/mock-server/src/crypto.ts b/packages/mock-server/src/crypto.ts new file mode 100644 index 0000000000..b1e65740da --- /dev/null +++ b/packages/mock-server/src/crypto.ts @@ -0,0 +1,454 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import crypto from 'crypto'; +import { Buffer } from 'buffer'; +import { + KEMPublicKey, + PrivateKey, + PublicKey, + SenderCertificate, + hkdf, +} from '@signalapp/libsignal-client'; + +import { signalservice as Proto } from '../protos/compiled'; + +import { Attachment } from './data/attachment'; +import type { ServerPreKey, ServerSignedPreKey } from './data/schemas'; +import { NEVER_EXPIRES, SERVER_CERTIFICATE_ID } from './constants'; +import { + AciString, + DeviceId, + KyberPreKey, + PreKey, + SignedPreKey, +} from './types'; +import { ReadonlyDeep } from 'type-fest'; + +const AES_KEY_SIZE = 32; +const MAC_KEY_SIZE = 32; +const AESGCM_IV_SIZE = 12; +const AUTH_TAG_SIZE = 16; +const MASTER_KEY_SIZE = 32; + +export type EncryptedProvisionMessage = { + body: Buffer; + ephemeralKey: Buffer; +}; + +export type ServerCertificate = { + privateKey: PrivateKey; + certificate: Proto.ServerCertificate.Params; +}; + +export type Sender = { + readonly aci: AciString; + readonly number?: string; + readonly deviceId: DeviceId; + readonly identityKey: PublicKey; + readonly expires?: number; +}; + +export function encryptProvisionMessage( + data: Buffer, + remotePubKey: PublicKey, +): EncryptedProvisionMessage { + const privateKey = PrivateKey.generate(); + const publicKey = privateKey.getPublicKey(); + + const agreement = privateKey.agree(remotePubKey); + + const secrets = hkdf( + AES_KEY_SIZE + MAC_KEY_SIZE, + agreement, + Buffer.from('TextSecure Provisioning Message'), + null, + ); + + const aesKey = secrets.slice(0, AES_KEY_SIZE); + const macKey = secrets.slice(AES_KEY_SIZE); + + const iv = crypto.randomBytes(16); + + const cipher = crypto.createCipheriv('aes-256-cbc', aesKey, iv); + const encrypted = Buffer.concat([cipher.update(data), cipher.final()]); + + const version = Buffer.from([1]); + + const ciphertext = Buffer.concat([version, iv, encrypted]); + + const mac = crypto.createHmac('sha256', macKey).update(ciphertext).digest(); + + const body = Buffer.concat([ciphertext, mac]); + + return { + body, + ephemeralKey: Buffer.from(publicKey.serialize()), + }; +} + +export type EncryptAttachmentOptions = Readonly<{ + aesKey: Buffer; + macKey: Buffer; + iv: Buffer; +}>; + +export function encryptAttachment( + cleartext: Buffer, + { aesKey, macKey, iv }: EncryptAttachmentOptions = { + aesKey: crypto.randomBytes(32), + macKey: crypto.randomBytes(32), + iv: crypto.randomBytes(16), + }, +): Attachment { + const cipher = crypto.createCipheriv('aes-256-cbc', aesKey, iv); + const ciphertext = Buffer.concat([cipher.update(cleartext), cipher.final()]); + + const mac = crypto + .createHmac('sha256', macKey) + .update(iv) + .update(ciphertext) + .digest(); + + const key = Buffer.concat([aesKey, macKey]); + + const blob = Buffer.concat([iv, ciphertext, mac]); + + const digest = crypto.createHash('sha256').update(blob).digest(); + + return { + key, + blob, + digest, + size: cleartext.length, + }; +} + +export function generateServerCertificate( + rootKey: PrivateKey, +): ServerCertificate { + const privateKey = PrivateKey.generate(); + + const data = Buffer.from( + Proto.ServerCertificate.Certificate.encode({ + id: SERVER_CERTIFICATE_ID, + key: privateKey.getPublicKey().serialize(), + }), + ); + + const signature = rootKey.sign(data); + + const certificate = { + certificate: data, + signature, + }; + + return { + privateKey, + certificate, + }; +} + +export function generateSenderCertificate( + serverCert: ServerCertificate, + sender: Sender, +): SenderCertificate { + const data = Buffer.from( + Proto.SenderCertificate.Certificate.encode({ + senderE164: sender.number ?? null, + senderUuid: sender.aci, + senderDevice: sender.deviceId, + expires: BigInt(sender.expires ?? NEVER_EXPIRES), + identityKey: sender.identityKey.serialize(), + signer: serverCert.certificate, + }), + ); + + const signature = serverCert.privateKey.sign(data); + + const certificate = Buffer.from( + Proto.SenderCertificate.encode({ + certificate: data, + signature, + }), + ); + + return SenderCertificate.deserialize(certificate); +} + +export function deriveAccessKey( + profileKey: Uint8Array, +): Buffer { + const cipher = crypto.createCipheriv( + 'aes-256-gcm', + profileKey, + Buffer.alloc(12), + ); + + return Buffer.concat([cipher.update(Buffer.alloc(16)), cipher.final()]); +} + +export function deriveMasterKey( + accountEntropyPool: string, +): Buffer { + return Buffer.from( + hkdf( + MASTER_KEY_SIZE, + Buffer.from(accountEntropyPool), + Buffer.from('20240801_SIGNAL_SVR_MASTER_KEY'), + null, + ), + ); +} + +export function deriveStorageKey( + masterKey: Buffer, +): Buffer { + const hash = crypto.createHmac('sha256', masterKey); + hash.update('Storage Service Encryption'); + return hash.digest(); +} + +function deriveStorageManifestKey( + storageKey: Buffer, + version: bigint, +): Buffer { + const hash = crypto.createHmac('sha256', storageKey); + hash.update(`Manifest_${version.toString()}`); + return hash.digest(); +} + +const STORAGE_SERVICE_ITEM_KEY_INFO_PREFIX = + '20240801_SIGNAL_STORAGE_SERVICE_ITEM_'; +const STORAGE_SERVICE_ITEM_KEY_LEN = 32; + +export type DeriveStorageItemKeyOptions = Readonly<{ + storageKey: Buffer; + recordIkm: Buffer | undefined; + key: Buffer; +}>; + +export function deriveStorageItemKey({ + storageKey, + recordIkm, + key, +}: DeriveStorageItemKeyOptions): Buffer { + if (recordIkm === undefined) { + const hash = crypto.createHmac('sha256', storageKey); + hash.update(`Item_${key.toString('base64')}`); + return hash.digest(); + } + + return Buffer.from( + hkdf( + STORAGE_SERVICE_ITEM_KEY_LEN, + recordIkm, + Buffer.concat([Buffer.from(STORAGE_SERVICE_ITEM_KEY_INFO_PREFIX), key]), + Buffer.alloc(0), + ), + ); +} + +function decryptAESGCM( + ciphertext: Buffer, + key: Buffer, +): Buffer { + const iv = ciphertext.subarray(0, AESGCM_IV_SIZE); + const tag = ciphertext.subarray(ciphertext.length - AUTH_TAG_SIZE); + const rest = ciphertext.subarray(iv.length, ciphertext.length - tag.length); + + const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv); + + decipher.setAuthTag(tag); + + return Buffer.concat([decipher.update(rest), decipher.final()]); +} + +function encryptAESGCM( + plaintext: Uint8Array, + key: Uint8Array, +): Buffer { + const iv = crypto.randomBytes(AESGCM_IV_SIZE); + const cipher = crypto.createCipheriv('aes-256-gcm', key, iv); + + const ciphertext = [cipher.update(plaintext), cipher.final()]; + + const tag = cipher.getAuthTag(); + + return Buffer.concat([iv, ...ciphertext, tag]); +} + +export function decryptStorageManifest( + storageKey: Buffer, + manifest: Proto.StorageManifest.Params, +): Proto.ManifestRecord { + if (!manifest.value?.length) { + throw new Error('Missing manifest.value'); + } + + const manifestKey = deriveStorageManifestKey( + storageKey, + manifest.version ?? 0n, + ); + + const decoded = Proto.ManifestRecord.decode( + decryptAESGCM(Buffer.from(manifest.value), manifestKey), + ); + + if (decoded.version !== manifest.version) { + throw new Error('manifestRecord.version != manifest.version'); + } + + return decoded; +} + +export function encryptStorageManifest( + storageKey: Buffer, + manifestRecord: Proto.ManifestRecord.Params, +): Proto.StorageManifest.Params { + if (!manifestRecord.version) { + throw new Error('Missing manifest.version'); + } + + const manifestKey = deriveStorageManifestKey( + storageKey, + manifestRecord.version, + ); + + const encrypted = encryptAESGCM( + Buffer.from(Proto.ManifestRecord.encode(manifestRecord)), + manifestKey, + ); + + return { + version: manifestRecord.version, + value: encrypted, + }; +} + +export type DecryptStorageItemOptions = Readonly<{ + storageKey: Buffer; + recordIkm: Buffer | undefined; + item: Proto.StorageItem.Params; +}>; + +export function decryptStorageItem({ + storageKey, + recordIkm, + item, +}: DecryptStorageItemOptions): Proto.StorageRecord { + if (!item.key) { + throw new Error('Missing item.key'); + } + if (!item.value) { + throw new Error('Missing item.value'); + } + + const itemKey = deriveStorageItemKey({ + storageKey, + recordIkm, + key: Buffer.from(item.key), + }); + + return Proto.StorageRecord.decode( + decryptAESGCM(Buffer.from(item.value), itemKey), + ); +} + +export type EncryptStorageItemOptions = Readonly<{ + storageKey: Buffer; + key: Buffer; + recordIkm: Buffer | undefined; + record: Proto.StorageRecord.Params; +}>; + +export function encryptStorageItem({ + storageKey, + key, + recordIkm, + record, +}: EncryptStorageItemOptions): Proto.StorageItem.Params { + const itemKey = deriveStorageItemKey({ + storageKey, + recordIkm, + key, + }); + + const encrypted = encryptAESGCM( + Buffer.from(Proto.StorageRecord.encode(record)), + itemKey, + ); + + return { + key, + value: encrypted, + }; +} + +export function encryptProfileName( + profileKey: Uint8Array, + name: string, +): Buffer { + const encrypted = encryptAESGCM(Buffer.from(name), profileKey); + + return encrypted; +} + +export function generateAccessKeyVerifier( + accessKey: Buffer, +): Buffer { + const zeroes = Buffer.alloc(32); + + return crypto.createHmac('sha256', accessKey).update(zeroes).digest(); +} + +export function decodePreKey({ keyId, publicKey }: ServerPreKey): PreKey { + return { + keyId, + publicKey: PublicKey.deserialize(Buffer.from(publicKey, 'base64')), + }; +} + +export function decodeSignedPreKey({ + keyId, + publicKey, + signature, +}: ServerSignedPreKey): SignedPreKey { + return { + keyId, + publicKey: PublicKey.deserialize(Buffer.from(publicKey, 'base64')), + signature: Buffer.from(signature, 'base64'), + }; +} + +export function decodeKyberPreKey({ + keyId, + publicKey, + signature, +}: ServerSignedPreKey): KyberPreKey { + return { + keyId, + publicKey: KEMPublicKey.deserialize(Buffer.from(publicKey, 'base64')), + signature: Buffer.from(signature, 'base64'), + }; +} + +export function hashRemoteConfig( + config: ReadonlyDeep>, +): Buffer { + // Not necessarily secure, but this will let us detect changes. The exact + // format isn't important so long as it's deterministic. + const mac = crypto.createHmac('sha256', 'remoteConfig'); + return config + .reduce( + (mac, [name, value], index) => + mac + .update(index.toString()) + .update(name.length.toString()) + .update(name) + .update(value.length.toString()) + .update(value), + mac, + ) + .digest(); +} diff --git a/packages/mock-server/src/data/attachment.ts b/packages/mock-server/src/data/attachment.ts new file mode 100644 index 0000000000..fcc1ba9684 --- /dev/null +++ b/packages/mock-server/src/data/attachment.ts @@ -0,0 +1,39 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { signalservice as Proto } from '../../protos/compiled'; + +export type Attachment = { + key: Buffer; + blob: Buffer; + digest: Buffer; + size: number; +}; + +export function attachmentToPointer( + cdnKey: string, + attachment: Attachment, +): Proto.AttachmentPointer.Params { + return { + contentType: 'application/octet-stream', + attachmentIdentifier: { + cdnKey, + }, + key: attachment.key, + size: attachment.size, + digest: attachment.digest, + + clientUuid: null, + thumbnail: null, + incrementalMac: null, + chunkSize: null, + fileName: null, + flags: null, + width: null, + height: null, + caption: null, + blurHash: null, + uploadTimestamp: null, + cdnNumber: null, + }; +} diff --git a/packages/mock-server/src/data/call.ts b/packages/mock-server/src/data/call.ts new file mode 100644 index 0000000000..6b0df8917c --- /dev/null +++ b/packages/mock-server/src/data/call.ts @@ -0,0 +1,20 @@ +// Copyright 2026 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { CallingRoomId } from '../calling'; + +export type CallDataOptions = Readonly<{ + roomId: CallingRoomId | null; +}>; + +export abstract class CallData { + #roomId: CallingRoomId | null; + + constructor(options: CallDataOptions) { + this.#roomId = options.roomId; + } + + public get roomId(): CallingRoomId | null { + return this.#roomId; + } +} diff --git a/packages/mock-server/src/data/certificates.ts b/packages/mock-server/src/data/certificates.ts new file mode 100644 index 0000000000..cd998fd100 --- /dev/null +++ b/packages/mock-server/src/data/certificates.ts @@ -0,0 +1,57 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import assert from 'assert'; +import fs from 'fs/promises'; +import path from 'path'; + +export type Certificates = Readonly<{ + certificateAuthority: string; + genericServerPublicParams: string; + backupServerPublicParams: string; + serverPublicParams: string; + serverTrustRoots: ReadonlyArray; +}>; + +const CERTS_DIR = path.join(__dirname, '..', '..', 'certs'); + +async function loadString(file: string): Promise { + const raw = await fs.readFile(path.join(CERTS_DIR, file)); + return raw.toString(); +} + +async function loadJSONProperty( + file: string, + property: string, +): Promise { + const raw = await fs.readFile(path.join(CERTS_DIR, file)); + const obj = JSON.parse(raw.toString()); + const value = obj[property]; + + assert(typeof value === 'string', `Expected string at: ${file}/${property}`); + return value; +} + +export async function load(): Promise { + const [ + certificateAuthority, + genericServerPublicParams, + backupServerPublicParams, + serverPublicParams, + serverTrustRoot, + ] = await Promise.all([ + loadString('ca-cert.pem'), + loadJSONProperty('zk-params.json', 'genericPublicParams'), + loadJSONProperty('zk-params.json', 'backupPublicParams'), + loadJSONProperty('zk-params.json', 'publicParams'), + loadJSONProperty('trust-root.json', 'publicKey'), + ]); + + return { + certificateAuthority, + genericServerPublicParams, + backupServerPublicParams, + serverPublicParams, + serverTrustRoots: [serverTrustRoot], + }; +} diff --git a/packages/mock-server/src/data/contacts.ts b/packages/mock-server/src/data/contacts.ts new file mode 100644 index 0000000000..770b0f3029 --- /dev/null +++ b/packages/mock-server/src/data/contacts.ts @@ -0,0 +1,49 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { signalservice as Proto } from '../../protos/compiled'; + +export type Contact = Readonly<{ + aciBinary: Uint8Array; + number: string; + profileName: string; +}>; + +export function serializeContacts( + contacts: ReadonlyArray, +): Buffer { + const chunks = contacts + .map((contact) => { + const { aciBinary, number, profileName: name } = contact; + return Buffer.from( + Proto.ContactDetails.encode({ + aciBinary, + number, + name, + avatar: null, + expireTimer: null, + expireTimerVersion: null, + inboxPosition: null, + aci: null, + }), + ); + }) + .map((chunk) => { + const size: Array = []; + + let remaining = chunk.length; + do { + let element = remaining & 0x7f; + remaining >>>= 7; + + if (remaining !== 0) { + element |= 0x80; + } + size.push(element); + } while (remaining !== 0); + + return [Buffer.from(size), chunk]; + }); + + return Buffer.concat(chunks.flat()); +} diff --git a/packages/mock-server/src/data/device.ts b/packages/mock-server/src/data/device.ts new file mode 100644 index 0000000000..40ea5cad46 --- /dev/null +++ b/packages/mock-server/src/data/device.ts @@ -0,0 +1,337 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { timingSafeEqual } from 'node:crypto'; +import createDebug from 'debug'; +import { + Aci, + Pni, + ProtocolAddress, + PublicKey, +} from '@signalapp/libsignal-client'; +import { + BackupLevel, + ProfileKeyCommitment, +} from '@signalapp/libsignal-client/zkgroup'; + +import { + AciString, + DeviceId, + KyberPreKey, + PniString, + PreKey, + RegistrationId, + ServiceIdKind, + ServiceIdString, + SignedPreKey, +} from '../types'; + +const debug = createDebug('mock:device'); + +export type DeviceOptions = Readonly<{ + aci: AciString; + pni: PniString; + number: string; + deviceId: DeviceId; + registrationId: RegistrationId; + pniRegistrationId: RegistrationId; + isProvisioned: boolean; +}>; + +export type ChangeNumberOptions = Readonly<{ + number: string; + pni: PniString; + pniRegistrationId: RegistrationId; +}>; + +export type DeviceKeys = Readonly<{ + identityKey: PublicKey; + preKeys?: ReadonlyArray; + kyberPreKeys?: ReadonlyArray; + lastResortKey?: KyberPreKey; + signedPreKey?: SignedPreKey; + + preKeyIterator?: AsyncIterator; + kyberPreKeyIterator?: AsyncIterator; +}>; + +export type SingleUseKey = Readonly<{ + identityKey: PublicKey; + + signedPreKey: SignedPreKey; + preKey: PreKey | undefined; + pqPreKey: KyberPreKey; +}>; + +type InternalDeviceKeys = Readonly<{ + identityKey: PublicKey; + signedPreKey: SignedPreKey; + lastResortKey: KyberPreKey; + preKeys: Array; + kyberPreKeys: Array; + preKeyIterator?: AsyncIterator; + kyberPreKeyIterator?: AsyncIterator; +}>; + +// Technically, it is infinite. +const PRE_KEY_ITERATOR_COUNT = 100; + +export class Device { + public readonly aci: AciString; + public readonly deviceId: DeviceId; + public readonly address: ProtocolAddress; + + // If `true` - the device was provisioned and should receive messages over + // the websocket. + public readonly isProvisioned: boolean; + + public capabilities: { + deleteSync: boolean; + versionedExpirationTimer: boolean; + ssre2: boolean; + usernameChangeSyncMessage: boolean; + }; + + public backupLevel = BackupLevel.Paid; + public accessKey?: Buffer; + public profileKeyCommitment?: ProfileKeyCommitment; + public profileName?: Buffer; + + private keys = new Map(); + + private privPni: PniString; + private privNumber: string; + private privPniAddress: ProtocolAddress; + private readonly registrationId: RegistrationId; + private pniRegistrationId: RegistrationId; + + constructor(options: DeviceOptions) { + this.aci = options.aci; + this.deviceId = options.deviceId; + this.registrationId = options.registrationId; + + this.privPni = options.pni; + this.privNumber = options.number; + this.pniRegistrationId = options.pniRegistrationId; + + this.isProvisioned = options.isProvisioned; + + this.address = ProtocolAddress.new(this.aci, this.deviceId); + this.privPniAddress = ProtocolAddress.new(this.pni, this.deviceId); + this.capabilities = { + deleteSync: true, + versionedExpirationTimer: true, + ssre2: true, + usernameChangeSyncMessage: true, + }; + } + + public get debugId(): string { + return `${this.aci}.${this.deviceId}`; + } + + public getRegistrationId(serviceIdKind: ServiceIdKind): number { + switch (serviceIdKind) { + case ServiceIdKind.ACI: + return this.registrationId; + case ServiceIdKind.PNI: + return this.pniRegistrationId; + } + } + + public get aciBinary(): Uint8Array { + return Aci.parseFromServiceIdString(this.aci).getServiceIdBinary(); + } + + public get pni(): PniString { + return this.privPni; + } + + public get pniBinary(): Uint8Array { + return Pni.parseFromServiceIdString(this.pni).getServiceIdBinary(); + } + + public get aciRawUuid(): Uint8Array { + return Aci.parseFromServiceIdString(this.aci).getRawUuidBytes(); + } + + public get pniRawUuid(): Uint8Array { + return Pni.parseFromServiceIdString(this.pni).getRawUuidBytes(); + } + + public get number(): string { + return this.privNumber; + } + + public get pniAddress(): ProtocolAddress { + return this.privPniAddress; + } + + public async changeNumber({ + number, + pni, + pniRegistrationId, + }: ChangeNumberOptions): Promise { + this.privNumber = number; + this.privPni = pni; + this.pniRegistrationId = pniRegistrationId; + this.privPniAddress = ProtocolAddress.new(this.pni, this.deviceId); + } + + public async setKeys( + serviceIdKind: ServiceIdKind, + keys: DeviceKeys, + ): Promise { + debug('setting %s keys for %s', serviceIdKind, this.debugId); + const existingKeys = this.keys.get(serviceIdKind); + const { + signedPreKey = existingKeys?.signedPreKey, + lastResortKey = existingKeys?.lastResortKey, + } = keys; + + if (!signedPreKey) { + throw new Error('setKeys: Missing signedPreKey'); + } + if (!lastResortKey) { + throw new Error('setKeys: Missing lastResortKey'); + } + + this.keys.set(serviceIdKind, { + identityKey: keys.identityKey, + + signedPreKey, + preKeys: keys.preKeys?.slice() ?? [], + kyberPreKeys: keys.kyberPreKeys?.slice() ?? [], + lastResortKey, + + preKeyIterator: keys.preKeyIterator, + kyberPreKeyIterator: keys.kyberPreKeyIterator, + }); + } + + public async getIdentityKey( + serviceIdKind = ServiceIdKind.ACI, + ): Promise { + const keys = this.keys.get(serviceIdKind); + if (!keys) { + throw new Error('No keys available for device'); + } + return keys.identityKey; + } + + public async popSingleUseKey( + serviceIdKind = ServiceIdKind.ACI, + ): Promise { + const keys = this.keys.get(serviceIdKind); + if (!keys) { + throw new Error('No keys available for device'); + } + + debug('popping single use key for %s', this.debugId); + + let preKey: PreKey | undefined; + if (keys.preKeyIterator) { + const { value } = await keys.preKeyIterator.next(); + preKey = value; + } + preKey ??= keys.preKeys.shift(); + + let pqPreKey: KyberPreKey | undefined; + if (keys.kyberPreKeyIterator) { + const { value } = await keys.kyberPreKeyIterator.next(); + pqPreKey = value; + } + pqPreKey ??= keys.kyberPreKeys.shift(); + pqPreKey ??= keys.lastResortKey; + + if (!pqPreKey) { + throw new Error( + 'popSingleUseKey: Missing pqPreKey; checked iterator/array/lastResort', + ); + } + + return { + identityKey: keys.identityKey, + signedPreKey: keys.signedPreKey, + preKey, + pqPreKey, + }; + } + + public async getPreKeyCount( + serviceIdKind = ServiceIdKind.ACI, + ): Promise { + const keys = this.keys.get(serviceIdKind); + if (!keys) { + throw new Error('No keys available for device'); + } + if (keys.preKeyIterator) { + return PRE_KEY_ITERATOR_COUNT; + } + return keys.preKeys.length; + } + + public async getKyberPreKeyCount( + serviceIdKind = ServiceIdKind.ACI, + ): Promise { + const keys = this.keys.get(serviceIdKind); + if (!keys) { + throw new Error('No keys available for device'); + } + if (keys.kyberPreKeyIterator) { + return PRE_KEY_ITERATOR_COUNT; + } + return keys.kyberPreKeys.length; + } + + public getServiceIdByKind(serviceIdKind: ServiceIdKind): ServiceIdString { + switch (serviceIdKind) { + case ServiceIdKind.ACI: + return this.aci; + case ServiceIdKind.PNI: + return this.pni; + } + } + + public getServiceIdBinaryByKind( + serviceIdKind: ServiceIdKind, + ): Uint8Array { + switch (serviceIdKind) { + case ServiceIdKind.ACI: + return this.aciBinary; + case ServiceIdKind.PNI: + return this.pniBinary; + } + } + + public getServiceIdKind(serviceId: ServiceIdString): ServiceIdKind { + if (serviceId === this.aci) { + return ServiceIdKind.ACI; + } + if (serviceId === this.pni) { + return ServiceIdKind.PNI; + } + throw new Error(`Unknown serviceId: ${serviceId}`); + } + + public getServiceIdBinaryKind( + serviceIdBinary: Uint8Array, + ): ServiceIdKind { + if (timingSafeEqual(serviceIdBinary, this.aciBinary)) { + return ServiceIdKind.ACI; + } + if (timingSafeEqual(serviceIdBinary, this.pniBinary)) { + return ServiceIdKind.PNI; + } + throw new Error('Unknown serviceId'); + } + + public getAddressByKind(serviceIdKind: ServiceIdKind): ProtocolAddress { + switch (serviceIdKind) { + case ServiceIdKind.ACI: + return this.address; + case ServiceIdKind.PNI: + return this.pniAddress; + } + } +} diff --git a/packages/mock-server/src/data/group.ts b/packages/mock-server/src/data/group.ts new file mode 100644 index 0000000000..601b732921 --- /dev/null +++ b/packages/mock-server/src/data/group.ts @@ -0,0 +1,82 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import assert from 'assert'; +import { + GroupPublicParams, + UuidCiphertext, +} from '@signalapp/libsignal-client/zkgroup'; + +import { signalservice as Proto } from '../../protos/compiled'; + +export abstract class Group { + protected privChanges?: Proto.GroupChanges.Params; + protected privPublicParams?: GroupPublicParams; + + public get changes(): Readonly { + assert(this.privChanges !== undefined, 'Group not initialized'); + return this.privChanges; + } + + public get publicParams(): GroupPublicParams { + assert(this.privPublicParams !== undefined, 'Group not initialized'); + return this.privPublicParams; + } + + public get state(): Readonly { + const { groupChanges } = this.changes; + assert(groupChanges, 'Missing group changes in the group state'); + const state = groupChanges.at(-1)?.groupState; + assert(state, 'Group must have the last state'); + return state; + } + + public get id(): string { + return Buffer.from( + this.publicParams.getGroupIdentifier().serialize(), + ).toString('base64'); + } + + public get revision(): number { + return this.state.version ?? 0; + } + + public getChangesSince(since: number): Readonly { + return { + groupChanges: this.changes.groupChanges?.slice(since) ?? null, + groupSendEndorsementsResponse: null, + }; + } + + public getMember( + uuidCiphertext: UuidCiphertext, + ): Proto.Member.Params | undefined { + const state = this.state; + const userId = Buffer.from(uuidCiphertext.serialize()); + return ( + state.members?.find((member) => { + if (!member.userId) { + return false; + } + + return userId.equals(member.userId); + }) ?? undefined + ); + } + + public getPendingMember( + uuidCiphertext: UuidCiphertext, + ): Proto.MemberPendingProfileKey.Params | undefined { + const state = this.state; + const userId = Buffer.from(uuidCiphertext.serialize()); + return ( + state.membersPendingProfileKey?.find(({ member }) => { + if (!member?.userId) { + return false; + } + + return userId.equals(member.userId); + }) ?? undefined + ); + } +} diff --git a/packages/mock-server/src/data/schemas.ts b/packages/mock-server/src/data/schemas.ts new file mode 100644 index 0000000000..c6a52da14d --- /dev/null +++ b/packages/mock-server/src/data/schemas.ts @@ -0,0 +1,291 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import z from 'zod'; + +import { + AciString, + DeviceId, + PniString, + RegistrationId, + ServiceIdString, +} from '../types'; +import { fromBase64, fromURLSafeBase64 } from '../util'; + +export const PositiveInt = z.coerce.number().int().nonnegative(); + +export const AciSchema = z.string().transform((x) => x as AciString); +export const PniSchema = z + .string() + .refine((x) => x.startsWith('PNI:')) + .transform((x) => x as PniString); +export const ServiceIdSchema = z + .string() + .transform((x) => x as ServiceIdString); +export const RegistrationIdSchema = z + .number() + .transform((x) => x as RegistrationId); +export const DeviceIdSchema = z.number().transform((x) => x as DeviceId); + +const PreKeySchema = z.object({ + keyId: z.number(), + publicKey: z.string(), +}); +export type ServerPreKey = z.infer; + +const SignedPreKeySchema = z.object({ + keyId: z.number(), + publicKey: z.string(), + signature: z.string(), +}); +export type ServerSignedPreKey = z.infer; + +export const DeviceKeysSchema = z.object({ + preKeys: PreKeySchema.array(), + pqPreKeys: SignedPreKeySchema.array().optional(), + pqLastResortPreKey: SignedPreKeySchema.optional(), + signedPreKey: SignedPreKeySchema.optional(), +}); + +export type DeviceKeys = z.infer; + +export const MessageSchema = z.object({ + // NOTE: Envelope.Type + type: z.number(), + destinationDeviceId: DeviceIdSchema, + destinationRegistrationId: RegistrationIdSchema, + content: z.string(), +}); + +export type Message = z.infer; + +export const MessageListSchema = z.object({ + messages: MessageSchema.array(), + timestamp: z.number(), +}); + +export type MessageList = z.infer; + +export const AtomicLinkingDataSchema = z.object({ + verificationCode: z.string(), + accountAttributes: z.object({ + fetchesMessages: z.boolean(), + registrationId: RegistrationIdSchema, + pniRegistrationId: RegistrationIdSchema, + name: z.string(), + }), + aciSignedPreKey: SignedPreKeySchema, + pniSignedPreKey: SignedPreKeySchema, + aciPqLastResortPreKey: SignedPreKeySchema, + pniPqLastResortPreKey: SignedPreKeySchema, +}); + +export const UpdateProfileSchema = z.object({ + commitment: z.string(), + version: z.string(), + name: z.string(), + aboutEmoji: z.string().nullish(), + about: z.string().nullish(), + paymentAddress: z.string().nullish(), + avatar: z.boolean(), + sameAvatar: z.boolean(), + badgeIds: z.array(z.string()).nullish(), + phoneNumberSharing: z.string().nullish(), +}); +export type UploadProfileResponse = + | string + | undefined + | { + acl: string; + algorithm: string; + credential: string; + date: string; + key: string; + policy: string; + signature: string; + }; + +export const RegisterAccountSchema = z.object({ + sessionId: z.string(), + recoveryPassword: z.string().optional(), + accountAttributes: z.object({ + fetchesMessages: z.boolean(), + registrationId: RegistrationIdSchema, + pniRegistrationId: RegistrationIdSchema, + name: z.string().optional(), + capabilities: z.object({ + attachmentBackfill: z.boolean(), + spqr: z.boolean(), + usernameChangeSyncMessage: z.boolean(), + }), + registrationLock: z.string().optional(), + unidentifiedAccessKey: z.array(z.number()), + unrestrictedUnidentifiedAccess: z.boolean(), + discoverableByPhoneNumber: z.boolean(), + recoveryPassword: z.string(), + phoneNumberIdentityRegistrationId: z.string().optional(), + }), + skipDeviceTransfer: z.boolean(), + aciIdentityKey: z.string(), + pniIdentityKey: z.string(), + aciSignedPreKey: SignedPreKeySchema, + pniSignedPreKey: SignedPreKeySchema, + aciPqLastResortPreKey: SignedPreKeySchema, + pniPqLastResortPreKey: SignedPreKeySchema, + apnToken: z + .object({ + apnRegistrationId: z.string(), + }) + .optional(), + gcmToken: z + .object({ + gcmRegistrationId: z.string(), + }) + .optional(), +}); +export type RegisterAccountResponse = { + uuid: string; + number: string; + pni: string; + usernameHash?: string; + usernameLinkHandle?: string; + storageCapable: boolean; + entitlements: { + badges: Array<{ + id: string; + expirationSeconds: number; + visible: boolean; + }>; + backup?: { + backupLevel: number; + expirationSeconds: number; + }; + }; + reregistration: boolean; +}; + +export const TransportSchema = z.literal('sms').or(z.literal('voice')); +export type Transport = z.infer; +export const ClientTypeSchema = z + .literal('desktop') + .or(z.literal('ios')) + .or(z.literal('android')); + +export const ModifyVerificationSessionSchema = z.object({ + pushToken: z.string().optional(), + pushTokenType: z.string().optional(), + pushChallenge: z.string().optional(), + captcha: z.string().optional(), + mcc: z.string().optional(), + mnc: z.string().optional(), +}); +export const CreateVerificationSessionSchema = z.object({ + number: z.string(), + ...ModifyVerificationSessionSchema.shape, +}); + +export type VerificationSession = { + id: string; + nextSms: number | null; + nextCall: number | null; + nextVerificationAttempt: number | null; + allowedToRequestCode: boolean; + requestedInformation: Array<'pushChallenges' | 'captcha'>; + verified: boolean; +}; +export type VerificationSessionStorage = { + number: string; + session: VerificationSession; + lastRequestedCode?: string; + lastRequestedTransport?: Transport; +}; + +export const RequestVerificationCodeSchema = z.object({ + transport: TransportSchema, + client: ClientTypeSchema, +}); +export const SubmitVerificationCodeSchema = z.object({ + code: z.string(), +}); + +export const GroupStateSchema = z.object({ + publicKey: z.instanceof(Uint8Array), + version: z.literal(0), + accessControl: z.object({ + attributes: z.number(), + members: z.number(), + addFromInviteLink: z.number(), + }), + members: z.unknown().array().min(1), +}); + +export const CreateCallLinkAuthSchema = z.object({ + createCallLinkCredentialRequest: z.string().transform(fromBase64), +}); + +export type CreateCallLinkAuth = z.infer; + +export const CreateCallLinkSchema = z.object({ + adminPasskey: z.string().transform(fromBase64), + zkparams: z.string().transform(fromBase64), +}); + +export type CreateCallLink = z.infer; + +export const UpdateCallLinkSchema = z.object({ + adminPasskey: z.string().transform(fromBase64), + name: z.string().optional(), + restrictions: z.enum(['none', 'adminApproval']).optional(), + revoked: z.boolean().optional(), +}); + +export type UpdateCallLink = z.infer; + +export const DeleteCallLinkSchema = z.object({ + adminPasskey: z.string().transform(fromBase64), +}); + +export type DeleteCallLink = z.infer; + +export const SetBackupIdSchema = z.object({ + messagesBackupAuthCredentialRequest: z.string().transform(fromBase64), + mediaBackupAuthCredentialRequest: z.string().transform(fromBase64), +}); + +export type SetBackupId = z.infer; + +export const BackupHeadersSchema = z.object({ + 'x-signal-zk-auth': z.string().transform(fromBase64), + 'x-signal-zk-auth-signature': z.string().transform(fromBase64), +}); + +export type BackupHeaders = z.infer; + +export const SetBackupKeySchema = z.object({ + backupIdPublicKey: z.string().transform(fromBase64), +}); + +export type SetBackupKey = z.infer; + +export const BackupMediaBatchSchema = z.object({ + items: z + .object({ + sourceAttachment: z.object({ + cdn: z.number(), + key: z.string(), + }), + objectLength: z.number(), + mediaId: z.string(), + hmacKey: z.string().transform(fromBase64), + encryptionKey: z.string().transform(fromBase64), + }) + .array(), +}); + +export type BackupMediaBatch = z.infer; + +export const UsernameConfirmationSchema = z.object({ + usernameHash: z.string().transform(fromURLSafeBase64), + zkProof: z.string().transform(fromURLSafeBase64), + encryptedUsername: z.string().transform(fromURLSafeBase64), +}); diff --git a/packages/mock-server/src/index.ts b/packages/mock-server/src/index.ts new file mode 100644 index 0000000000..9bafe8af7a --- /dev/null +++ b/packages/mock-server/src/index.ts @@ -0,0 +1,25 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +export { Group } from './api/group'; +export { StorageState, StorageStateRecord } from './api/storage-state'; +export { Server, Config } from './api/server'; +export { + EncryptOptions, + PrimaryDevice, + ReceiptOptions, + ReceiptType, + SyncReadMessage, + SyncReadOptions, + SyncSentOptions, + EMPTY_DATA_MESSAGE, + EMPTY_GROUP_ACTIONS, +} from './api/primary-device'; +export { Device, SingleUseKey } from './data/device'; +export { EnvelopeType } from './server/base'; +export { + signalservice as Proto, + signaling as SignalingProto, +} from '../protos/compiled'; +export { load as loadCertificates, Certificates } from './data/certificates'; +export { ServiceIdKind } from './types'; diff --git a/packages/mock-server/src/server/base.ts b/packages/mock-server/src/server/base.ts new file mode 100644 index 0000000000..d70deb2fda --- /dev/null +++ b/packages/mock-server/src/server/base.ts @@ -0,0 +1,2044 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { + Aci, + Pni, + PublicKey, + SenderCertificate, + usernames, +} from '@signalapp/libsignal-client'; +import { + AuthCredentialPresentation, + BackupAuthCredentialPresentation, + BackupAuthCredentialRequest, + BackupCredentialType, + BackupLevel, + CallLinkAuthCredentialResponse, + CreateCallLinkCredentialRequest, + CreateCallLinkCredentialResponse, + GenericServerSecretParams, + GroupPublicParams, + ProfileKeyCredentialRequest, + ServerSecretParams, + ServerZkAuthOperations, + ServerZkProfileOperations, + UuidCiphertext, +} from '@signalapp/libsignal-client/zkgroup'; +import assert from 'assert'; +import http2 from 'http2'; +import crypto from 'crypto'; +import createDebug from 'debug'; +import { v4 as uuidv4, parse as parseUuid } from 'uuid'; +import { AddressInfo } from 'net'; + +import { signalservice as Proto } from '../../protos/compiled'; +import { + DAY_IN_SECONDS, + MAX_GROUP_CREDENTIALS_DAYS, + PRIMARY_DEVICE_ID, + PROFILE_KEY_CREDENTIAL_EXPIRATION, +} from '../constants'; +import { ServerCertificate, generateSenderCertificate } from '../crypto'; +import { ChangeNumberOptions, Device, DeviceKeys } from '../data/device'; +import { + BackupHeaders, + BackupMediaBatch, + CreateCallLink, + DeleteCallLink, + Message, + RegisterAccountResponse, + SetBackupId, + SetBackupKey, + UpdateCallLink, + VerificationSessionStorage, +} from '../data/schemas'; +import { + AciString, + AttachmentId, + DeviceId, + PniString, + ProvisionIdString, + ProvisioningCode, + RegistrationId, + ServiceIdKind, + ServiceIdString, +} from '../types'; +import { getTodayInSeconds } from '../util'; +import { ModifyGroupResult, ServerGroup } from './group'; +import { ServerCall } from './call'; +import { + CallingError, + CallingErrorCode, + CallingEraId, + getRandomCallingDemuxId, + getRandomCallingEraId, + CallingRoomId, + CallInfo, + CallingUserId, + CallType, + CallingDemuxId, +} from '../calling'; +import { SfuService } from '../sfu/service'; +import { SfuClientStatus } from '../sfu/call'; +import { + getRandomIcePassword, + getRandomIceUsernameFragment, + IcePassword, + IceUsernameFragment, +} from '../sfu/ice'; +import { Port, ServerMediaAddress } from '../sfu/config'; +import { CallingPublicKey } from '../sfu/crypto'; +import type { JsonValue, PartialDeep } from 'type-fest'; + +export enum EnvelopeType { + CipherText = 'CipherText', + Plaintext = 'Plaintext', + PreKey = 'PreKey', + SealedSender = 'SealedSender', + SenderKey = 'SenderKey', +} + +export type ProvisioningResponse = Readonly<{ + envelope: Buffer; +}>; + +export type CredentialsRange = Readonly<{ + from: number; + to: number; +}>; + +export type StorageCredentials = Readonly<{ + username: string; + password: string; +}>; + +export type Credentials = Array<{ + credential: string; + redemptionTime: number; +}>; + +export type BackupCredentials = Readonly<{ + messages: Credentials; + media: Credentials; +}>; + +export type ChallengeResponse = Readonly<{ + code: number; + data: unknown; +}>; + +export type PreparedMultiDeviceMessage = Readonly<{ + timestamp: bigint; + targets: ReadonlyArray<[Device, Message]>; +}>; + +export type SenderCertificateOptions = Readonly<{ + includeE164?: boolean; +}>; + +export type ProvisionDeviceOptions = Readonly<{ + number: string; + password: string; + provisioningCode: ProvisioningCode; + registrationId: RegistrationId; + pniRegistrationId: RegistrationId; +}>; + +export type RegisterDeviceOptions = Readonly< + ( + | { + primary?: undefined; + provisionId?: ProvisionIdString; + number: string; + password: string; + } + | { + primary: Device; + provisionId?: undefined; + number?: undefined; + password?: string; + } + ) & { + registrationId: RegistrationId; + pniRegistrationId: RegistrationId; + } +>; + +export type PrepareMultiDeviceMessageResult = Readonly< + | { + status: 'stale'; + staleDevices: ReadonlyArray; + } + | { + status: 'incomplete'; + missingDevices: ReadonlyArray; + extraDevices: ReadonlyArray; + } + | { + status: 'unknown'; + } + | { + status: 'ok'; + targetServiceId: ServiceIdString; + result: PreparedMultiDeviceMessage; + } +>; + +export type ConfirmUsernameResult = Readonly<{ + usernameHash: Uint8Array; + usernameLinkHandle: Uint8Array; +}>; + +export type SetUsernameLinkResult = Readonly<{ + entropy: Uint8Array; + serverId: Uint8Array; +}>; + +export type StorageWriteResult = Readonly< + | { + updated: false; + manifest: Proto.StorageManifest.Params; + error?: void; + } + | { + updated: true; + manifest?: void; + error?: void; + } + | { + updated?: void; + error: string; + } +>; + +export type ModifyGroupOptions = Readonly<{ + group: ServerGroup; + actions: Proto.GroupChange.Actions.Params; + aciCiphertext: Uint8Array; + pniCiphertext: Uint8Array; +}>; + +export type EncryptedStickerPack = Readonly<{ + id: Buffer; + manifest: Buffer; + stickers: ReadonlyArray>; +}>; + +export type IsSendRateLimitedOptions = Readonly<{ + source: ServiceIdString; + target: ServiceIdString; +}>; + +export { type ModifyGroupResult }; + +interface WebSocket { + sendMessage: (message: Buffer | 'empty') => Promise; + close: (code: number) => void; +} + +interface SerializableCredential { + serialize: () => Uint8Array; +} + +type AuthEntry = Readonly<{ + readonly password: string; + readonly device: Device; +}>; + +type StorageAuthEntry = Readonly<{ + username: string; + password: string; + device: Device; +}>; + +type MessageQueueEntry = (socket: WebSocket) => Promise; + +export type ServerJoinCallRequest = Readonly<{ + roomId: CallingRoomId; + userId: CallingUserId; + isAllowedToInitiateGroupCall: boolean; + clientIceUsernameFragment: IceUsernameFragment; + clientIcePassword: IcePassword; + clientPublicKey: CallingPublicKey; + clientHkdfExtraInfo: Uint8Array | null; + callType: CallType; + isAdmin: boolean; + // roomId: CallingRoomId | null; + newClientsRequireApproval: boolean; + approvedUsers: ReadonlyArray | null; +}>; + +export type ServerJoinCallResponse = Readonly<{ + demuxId: CallingDemuxId; + serverMediaAddress: ServerMediaAddress; + serverIceUsernameFragment: IceUsernameFragment; + serverIcePassword: IcePassword; + serverPublicKey: CallingPublicKey; + callEraId: CallingEraId; + callCreatorUserId: CallingUserId; + clientStatus: SfuClientStatus; +}>; + +export type CallLinkEntry = Readonly<{ + adminPasskey: Buffer; + encryptedName: string; + restrictions: 'none' | 'adminApproval'; + revoked: boolean; + expiration: number; +}>; + +export type BackupInfo = Readonly<{ + cdn: 3; + backupDir: string; + mediaDir: string; + backupName: string; + usedSpace?: number; +}>; + +export class BackupAuthError extends Error {} + +export type BackupMediaObject = Readonly<{ + cdn: 3; + mediaId: string; + objectLength: number; +}>; + +export type BackupMediaList = Readonly<{ + storedMediaObjects: ReadonlyArray; + backupDir: string; + mediaDir: string; + cursor: string | undefined; +}>; + +export type BackupMediaCursor = { + readonly backupId: string; + remainingMedia: ReadonlyArray; +}; + +export type ListBackupMediaOptions = Readonly<{ + cursor: string | undefined; + limit: number; +}>; + +export type BackupMediaBatchResponse = Readonly<{ + status: number; + failureReason?: string; + cdn: 3; + mediaId: string; +}>; + +export type BackupMediaBatchResult = Readonly<{ + responses: ReadonlyArray; +}>; + +export type TransferArchiveResponse = Readonly< + | { + error: 'RELINK_REQUESTED' | 'CONTINUE_WITHOUT_UPLOAD'; + } + | { + cdn: 3; + key: string; + } +>; + +export type AttachmentUploadForm = Readonly<{ + cdn: 3; + key: string; + headers: Record; + signedUploadLocation: string; +}>; + +export type RemoteConfigValueType = { + enabled: boolean; + value?: string; +}; + +export type HardcodedResponseError = { + code: number; + data: PartialDeep; +}; + +const debug = createDebug('mock:server:base'); + +// NOTE: This class is currently extended only by src/api/server.ts +export abstract class Server { + private readonly devices = new Map>(); + private readonly devicesByServiceId = new Map(); + private readonly devicesByAuth = new Map(); + private readonly usedServiceIds = new Set(); + private readonly usedProvisionIds = new Set(); + private readonly storageAuthByUsername = new Map(); + private readonly storageAuthByDevice = new Map(); + private readonly storageManifestByAci = new Map< + AciString, + Proto.StorageManifest.Params + >(); + private readonly storageItemsByAci = new Map< + AciString, + Map> + >(); + private readonly provisioningCodes = new Map< + string, + Map + >(); + private readonly attachments = new Map>(); + private readonly stickerPacks = new Map(); + private readonly webSockets = new Map(); + private readonly messageQueue = new WeakMap< + Device, + Array + >(); + private readonly groups = new Map(); + private readonly aciByUsername = new Map(); + private readonly aciByReservedUsername = new Map(); + private readonly usernameByAci = new Map(); + private readonly reservedUsernameByAci = new Map(); + private readonly usernameLinkIdByServiceId = new Map< + ServiceIdString, + string + >(); + private readonly usernameLinkById = new Map>(); + private readonly callsByRoomId = new Map(); + private readonly callLinksByRoomId = new Map(); + private readonly backupAuthReqByAci = new Map< + AciString, + { + messages: BackupAuthCredentialRequest; + media: BackupAuthCredentialRequest; + } + >(); + private readonly backupKeyById = new Map(); + private readonly backupCDNPasswordById = new Map(); + private readonly backupMediaById = new Map< + string, + Array + >(); + private readonly backupMediaCursorById = new Map(); + private readonly remoteConfig = new Map(); + + protected privCertificate: ServerCertificate | undefined; + protected privZKSecret: ServerSecretParams | undefined; + protected privGenericServerSecret: GenericServerSecretParams | undefined; + protected privBackupServerSecret: GenericServerSecretParams | undefined; + protected https: http2.Http2SecureServer | undefined; + protected verificationStore = new Map(); + protected backupAuth = { username: 'fake', password: 'fake1234' }; + + protected registerResponseData: Partial | undefined; + protected registerResponseError: HardcodedResponseError | undefined; + + protected sfuService = new SfuService(); + + public address(): AddressInfo { + if (!this.https) { + throw new Error('Not listening'); + } + + const result = this.https.address(); + if (result == null || typeof result !== 'object') { + throw new Error('Invalid .address() result'); + } + return result; + } + + // + // Service Ids + // + + public async generateAci(): Promise { + let result: AciString; + do { + result = uuidv4() as AciString; + } while (this.usedServiceIds.has(result)); + this.usedServiceIds.add(result); + return result; + } + + public async generatePni(): Promise { + let result: PniString; + do { + result = `PNI:${uuidv4()}` as PniString; + } while (this.usedServiceIds.has(result)); + this.usedServiceIds.add(result); + return result; + } + + // + // Provisioning + // + + public async generateProvisionId(): Promise { + let result: ProvisionIdString; + do { + result = uuidv4() as ProvisionIdString; + } while (this.usedProvisionIds.has(result)); + this.usedProvisionIds.add(result); + return result; + } + + public async releaseProvisionId(id: ProvisionIdString): Promise { + this.usedProvisionIds.delete(id); + } + + public abstract getProvisioningResponse( + id: ProvisionIdString, + abortSignal?: AbortSignal, + ): Promise; + + public setRegisterResponseData(data: Partial): void { + this.registerResponseData = data; + } + public getRegisterResponseData(): + | Partial + | undefined { + return this.registerResponseData; + } + + public setRegisterResponseError( + error: HardcodedResponseError | undefined, + ): void { + this.registerResponseError = error; + } + public getRegisterResponseError(): HardcodedResponseError | undefined { + return this.registerResponseError; + } + + public async registerDevice({ + primary, + provisionId, + number: maybeNumber, + registrationId, + pniRegistrationId, + password, + }: RegisterDeviceOptions): Promise { + if (provisionId && !this.usedProvisionIds.has(provisionId)) { + throw new Error('Use generateProvisionId() to create new provision id'); + } + + let aci: AciString; + let pni: PniString; + let number: string; + if (primary) { + ({ aci, pni, number } = primary); + } else { + [aci, pni] = await Promise.all([this.generateAci(), this.generatePni()]); + number = maybeNumber; + } + + let list = this.devices.get(number); + if (!list) { + list = []; + this.devices.set(number, list); + } + const deviceId = (list.length + 1) as DeviceId; + const isPrimary = deviceId === PRIMARY_DEVICE_ID; + + const device = new Device({ + aci, + pni, + number, + deviceId, + registrationId, + pniRegistrationId, + isProvisioned: !!password, + }); + + if (isPrimary) { + assert(!this.devicesByServiceId.has(aci), 'Duplicate primary device'); + this.devicesByServiceId.set(aci, device); + this.devicesByServiceId.set(pni, device); + } + + if (password) { + this.setDeviceAuthPassword(number, device, password); + } + + list.push(device); + + debug('registered device number=%j aci=%s pni=%s', number, aci, pni); + return device; + } + + // Called from primary device + public async getProvisioningCode( + id: ProvisionIdString, + number: string, + ): Promise { + let entry = this.provisioningCodes.get(number); + if (!entry) { + entry = new Map(); + this.provisioningCodes.set(number, entry); + } + let code: ProvisioningCode; + do { + code = crypto.randomBytes(8).toString('hex') as ProvisioningCode; + } while (entry.has(code)); + entry.set(code, id); + return code; + } + + // Called from secondary device + public async provisionDevice({ + number, + password, + provisioningCode, + registrationId, + pniRegistrationId, + }: ProvisionDeviceOptions): Promise { + const entry = this.provisioningCodes.get(number); + if (!entry) { + throw new Error('Invalid number for provisioning'); + } + + const provisionIdString = entry.get(provisioningCode); + if (!provisionIdString) { + throw new Error('Invalid provisioning code'); + } + entry.delete(provisioningCode); + + const [primary] = this.devices.get(number) ?? []; + assert(primary !== undefined, 'Missing primary device when provisioning'); + + const device = await this.registerDevice({ + primary, + registrationId, + pniRegistrationId, + password, + }); + + debug( + 'provisioned device id=%j number=%j aci=%j', + device.deviceId, + number, + device.aci, + ); + return device; + } + + private setDeviceAuthPassword( + number: string, + device: Device, + password: string, + ) { + const username = `${number}.${device.deviceId}`; + + // This is awkward, but WebSockets use it. + const secondUsername = `${device.aci}.${device.deviceId}`; + + // Add auth only after successfully registering the device + assert( + !this.devicesByAuth.has(username) && + !this.devicesByAuth.has(secondUsername), + 'Duplicate username in `provisionDevice`', + ); + const authEntry = { + password, + device, + }; + this.devicesByAuth.set(username, authEntry); + this.devicesByAuth.set(secondUsername, authEntry); + } + + public async updateDeviceKeys( + device: Device, + serviceIdKind: ServiceIdKind, + keys: Omit, + ): Promise { + debug('setting device=%s keys', device.debugId); + const primary = this.devicesByServiceId.get(device.aci); + assert(primary, 'must have primary device'); + await device.setKeys(serviceIdKind, { + ...keys, + identityKey: await primary.getIdentityKey(serviceIdKind), + }); + } + + public async changeDeviceNumber( + device: Device, + options: ChangeNumberOptions, + ): Promise { + const oldNumber = device.number; + const oldPni = device.pni; + await device.changeNumber(options); + + const oldDevices = this.devices.get(oldNumber) ?? []; + const oldDeviceIndex = oldDevices.indexOf(device); + if (oldDeviceIndex !== -1) { + oldDevices.splice(oldDeviceIndex, 1); + if (oldDevices.length === 0) { + this.devices.delete(oldNumber); + } + } + + let newDevices = this.devices.get(options.number); + if (!newDevices) { + newDevices = []; + this.devices.set(options.number, newDevices); + } + newDevices.push(device); + + const oldPrimary = this.devicesByServiceId.get(oldPni); + if (oldPrimary === device) { + this.devicesByServiceId.delete(oldPni); + this.devicesByServiceId.set(options.pni, device); + } + } + + // Verification Sessions + + public getVerificationSession( + id: string, + ): VerificationSessionStorage | undefined { + return this.verificationStore.get(id); + } + + public saveVerificationSession(store: VerificationSessionStorage): void { + this.verificationStore.set(store.session.id, store); + } + + // + // Auth + // + + public async auth( + username: string, + password: string, + ): Promise { + const entry = this.devicesByAuth.get(username); + if (!entry) { + debug('auth failed, username=%j is unknown', username); + return; + } + if (entry.password !== password) { + debug('auth failed, invalid login/password %j:%j', username, password); + return; + } + return entry.device; + } + + // + // Remote config + // + public setRemoteConfig(key: string, value: RemoteConfigValueType): void { + this.remoteConfig.set(key, value); + } + + public getRemoteConfig(): Map { + return this.remoteConfig; + } + + // + // CDN + // + + protected async storeAttachment( + attachment: Buffer, + ): Promise { + const id = crypto + .createHash('sha256') + .update(attachment) + .digest('hex') as AttachmentId; + this.attachments.set(id, attachment); + return id; + } + + public async fetchAttachment( + id: AttachmentId, + ): Promise | undefined> { + return this.attachments.get(id); + } + + public async fetchStickerPack( + packId: string, + ): Promise | undefined> { + return this.stickerPacks.get(packId)?.manifest; + } + + public async fetchSticker( + packId: string, + stickerId: number, + ): Promise | undefined> { + return this.stickerPacks.get(packId)?.stickers[stickerId]; + } + + public async storeStickerPack(pack: EncryptedStickerPack): Promise { + this.stickerPacks.set(pack.id.toString('hex'), pack); + } + + public async getAttachmentUploadForm( + folder: string, + key: string, + ): Promise { + const { port } = this.address(); + + // These are the only two in the TLS certificate + const signedUploadLocation = `https://localhost:${port}/cdn3/${folder}/${key}`; + return { + cdn: 3, + key, + headers: { + // TODO(indutny): verify on request + expectedHeaders: crypto.randomBytes(16).toString('hex'), + }, + signedUploadLocation, + }; + } + + // + // Messages + // + + public async prepareMultiDeviceMessage( + source: Device | undefined, + targetServiceId: ServiceIdString, + messages: ReadonlyArray, + timestamp: bigint, + ): Promise { + if (this.isUnregistered(targetServiceId)) { + return { status: 'unknown' }; + } + + const devices = await this.getAllDevicesByServiceId(targetServiceId); + if (devices.length === 0) { + return { status: 'unknown' }; + } + + const deviceById = new Map(); + for (const device of devices) { + deviceById.set(device.deviceId, device); + } + + const targets = new Array<[Device, Message]>(); + + const extraDevices = new Set(); + const staleDevices = new Set(); + for (const message of messages) { + const { destinationDeviceId, destinationRegistrationId } = message; + + const target = deviceById.get(destinationDeviceId); + if (!target) { + extraDevices.add(destinationDeviceId); + continue; + } + + const serviceIdKind = target.getServiceIdKind(targetServiceId); + + deviceById.delete(destinationDeviceId); + + if ( + target.getRegistrationId(serviceIdKind) !== destinationRegistrationId + ) { + staleDevices.add(destinationDeviceId); + continue; + } + + targets.push([target, message]); + } + + if (source?.aci === targetServiceId) { + deviceById.delete(source.deviceId); + } + + if (staleDevices.size !== 0) { + return { status: 'stale', staleDevices: Array.from(staleDevices) }; + } + + if (extraDevices.size !== 0 || deviceById.size !== 0) { + return { + status: 'incomplete', + missingDevices: Array.from(deviceById.keys()), + extraDevices: Array.from(extraDevices), + }; + } + + return { status: 'ok', targetServiceId, result: { timestamp, targets } }; + } + + public async handlePreparedMultiDeviceMessage( + source: Device | undefined, + targetServiceId: ServiceIdString, + prepared: PreparedMultiDeviceMessage, + ): Promise { + for (const [target, message] of prepared.targets) { + let envelopeType: EnvelopeType; + if (message.type === Proto.Envelope.Type.DOUBLE_RATCHET) { + envelopeType = EnvelopeType.CipherText; + } else if (message.type === Proto.Envelope.Type.PREKEY_MESSAGE) { + envelopeType = EnvelopeType.PreKey; + } else if (message.type === Proto.Envelope.Type.UNIDENTIFIED_SENDER) { + envelopeType = EnvelopeType.SealedSender; + } else if (message.type === Proto.Envelope.Type.PLAINTEXT_CONTENT) { + envelopeType = EnvelopeType.Plaintext; + } else { + throw new Error(`Unsupported envelope type: ${message.type}`); + } + + const serviceIdKind = target.getServiceIdKind(targetServiceId); + + await this.handleMessage( + source, + serviceIdKind, + envelopeType, + target, + Buffer.from(message.content, 'base64'), + prepared.timestamp, + ); + } + } + + public abstract handleMessage( + source: Device | undefined, + serviceIdKind: ServiceIdKind, + envelopeType: EnvelopeType, + target: Device, + encrypted: Buffer, + timestamp: bigint, + ): Promise; + + public async addWebSocket(device: Device, socket: WebSocket): Promise { + debug('adding websocket for device=%s', device.debugId); + const existing = this.webSockets.get(device); + if (existing !== undefined) { + debug('closing stale socket for devices=%s', device.debugId); + existing.close(4409); + } + this.webSockets.set(device, socket); + + // Don't wait for send to be over + void this.sendQueue(device, socket); + } + + public removeWebSocket(device: Device, socket: WebSocket): void { + const existing = this.webSockets.get(device); + if (existing !== socket) { + return; + } + + debug('removing websocket for device=%s', device.debugId); + this.webSockets.delete(device); + } + + // TODO(indutny): timeout + public async send( + target: Device, + message: Buffer, + ): Promise { + const socket = this.webSockets.get(target); + if (socket) { + debug('sending message to %s socket', target.debugId); + try { + await socket.sendMessage(message); + + return; + } catch (error) { + assert(error instanceof Error); + debug( + 'failed to send message to socket of %s, error %s', + target.debugId, + error.message, + ); + } + } + + debug('queueing message for device=%s', target.debugId); + + let queue = this.messageQueue.get(target); + if (!queue) { + queue = []; + this.messageQueue.set(target, queue); + } + + const { promise, resolve, reject } = Promise.withResolvers(); + + queue.push(async (socket) => { + try { + await socket.sendMessage(message); + resolve(); + } catch (error) { + reject(error); + } + }); + + await promise; + debug('queued message sent to device=%s', target.debugId); + } + + // + // Groups + // + + public async createGroup(group: Proto.Group.Params): Promise { + const result = new ServerGroup({ + zkSecret: this.zkSecret, + profileOps: new ServerZkProfileOperations(this.zkSecret), + state: group, + }); + + const key = Buffer.from(result.publicParams.serialize()).toString('base64'); + + if (this.groups.get(key)) { + throw new Error('Duplicate group'); + } + + this.groups.set(key, result); + + return result; + } + + public async modifyGroup({ + group, + actions, + aciCiphertext, + pniCiphertext, + }: ModifyGroupOptions): Promise { + return group.modify( + new UuidCiphertext(Buffer.from(aciCiphertext)), + new UuidCiphertext(Buffer.from(pniCiphertext)), + actions, + ); + } + + public async getGroup( + publicParams: Uint8Array, + ): Promise { + return this.groups.get(Buffer.from(publicParams).toString('base64')); + } + + // + // Storage + // + + public async getStorageAuth(device: Device): Promise { + let auth = this.storageAuthByDevice.get(device); + if (!auth) { + do { + auth = { + username: crypto.randomBytes(8).toString('hex'), + password: crypto.randomBytes(8).toString('hex'), + device, + }; + } while (this.storageAuthByUsername.has(auth.username)); + + this.storageAuthByDevice.set(device, auth); + this.storageAuthByUsername.set(auth.username, auth); + + debug('register new storage username=%j', auth.username); + } + + return { + username: auth.username, + password: auth.password, + }; + } + + public async storageAuth( + username: string, + password: string, + ): Promise { + const auth = this.storageAuthByUsername.get(username); + if (!auth) { + debug('auth failed, username=%j is unknown', username); + return; + } + if (auth.password !== password) { + debug('auth failed, invalid login/password %j:%j', username, password); + } + + return auth.device; + } + + public async getStorageManifest( + device: Device, + ): Promise { + return this.storageManifestByAci.get(device.aci); + } + + public async applyStorageWrite( + device: Device, + { manifest, clearAll, insertItem, deleteKey }: Proto.WriteOperation.Params, + shouldNotify = true, + ): Promise { + if (!manifest) { + return { error: 'missing `writeOperation.manifest`' }; + } + if (!manifest.version) { + return { error: 'missing `writeOperation.manifest.version`' }; + } + + const existing = await this.getStorageManifest(device); + if (existing) { + // Atomicity + assert(existing.version, 'consistency check'); + if (manifest.version !== existing.version + 1n) { + debug( + 'not updating storage manifest, current version=%j new version=%j', + existing.version.toString(), + manifest.version.toString(), + ); + return { updated: false, manifest: existing }; + } + } + + if (clearAll) { + debug('clearing storage items for=%j', device.debugId); + await this.clearStorageItems(device); + } + + const inserts = (insertItem ?? []).map(async (item) => { + assert(item.key instanceof Uint8Array, 'insertItem.key must be a Buffer'); + assert( + item.value instanceof Uint8Array, + 'insertItem.value must be a Buffer', + ); + return this.setStorageItem( + device, + Buffer.from(item.key), + Buffer.from(item.value), + ); + }); + await Promise.all(inserts); + + const deletes = (deleteKey ?? []).map(async (key) => { + return this.deleteStorageItem(device, Buffer.from(key)); + }); + await Promise.all(deletes); + + debug( + 'updating storage manifest to version=%d for=%j', + manifest.version, + device.debugId, + ); + this.storageManifestByAci.set(device.aci, manifest); + + if (shouldNotify) { + await this.onStorageManifestUpdate(device, manifest.version); + } + + return { updated: true }; + } + + private async clearStorageItems(device: Device): Promise { + this.storageItemsByAci.get(device.aci)?.clear(); + } + + private async setStorageItem( + device: Device, + key: Buffer, + value: Buffer, + ): Promise { + let map = this.storageItemsByAci.get(device.aci); + if (!map) { + map = new Map(); + this.storageItemsByAci.set(device.aci, map); + } + + map.set(key.toString('hex'), value); + } + + public async getStorageItem( + device: Device, + key: Buffer, + ): Promise | undefined> { + const map = this.storageItemsByAci.get(device.aci); + if (!map) { + return undefined; + } + + return map.get(key.toString('hex')); + } + + public async getAllStorageKeys( + device: Device, + ): Promise>> { + const map = this.storageItemsByAci.get(device.aci); + if (!map) { + return []; + } + + return Array.from(map.keys()).map((hex) => Buffer.from(hex, 'hex')); + } + + public async getStorageItems( + device: Device, + keys: ReadonlyArray>, + ): Promise | undefined> { + const result = new Array(); + + await Promise.all( + keys.map(async (key) => { + const value = await this.getStorageItem(device, key); + if (value !== undefined) { + result.push({ key, value }); + } + }), + ); + + return result; + } + + public async deleteStorageItem( + device: Device, + key: Buffer, + ): Promise { + const map = this.storageItemsByAci.get(device.aci); + if (!map) { + return; + } + + map.delete(key.toString('hex')); + } + + protected abstract onStorageManifestUpdate( + device: Device, + version: bigint, + ): Promise; + + // + // Calls + // + + public async joinCall( + request: ServerJoinCallRequest, + ): Promise { + let call = this.callsByRoomId.get(request.roomId); + if (call == null) { + if (!request.isAllowedToInitiateGroupCall) { + throw new CallingError(CallingErrorCode.NoPermissionToCreateCall); + } + + call = new ServerCall({ + eraId: getRandomCallingEraId(), + roomId: request.roomId, + creatorUserId: request.userId, + }); + + this.callsByRoomId.set(request.roomId, call); + } + + const demuxId = getRandomCallingDemuxId(); + + const serverIceUsernameFragment = getRandomIceUsernameFragment(); + const serverIcePassword = getRandomIcePassword(); + + const response = await this.sfuService.joinCall({ + eraId: call.eraId, + demuxId, + roomId: call.roomId, + userId: request.userId, + clientIceUsernameFragment: request.clientIceUsernameFragment, + clientIcePassword: request.clientIcePassword, + clientPublicKey: request.clientPublicKey, + clientHkdfExtraInfo: request.clientHkdfExtraInfo, + serverIceUsernameFragment, + serverIcePassword, + callType: request.callType, + isAdmin: request.isAdmin, + newClientsRequireApproval: request.newClientsRequireApproval, + approvedUsers: request.approvedUsers, + }); + + // TODO + const mediaServer: ServerMediaAddress = { + addresses: [] as ServerMediaAddress['addresses'], + hostname: null, + ports: { + udp: 0 as Port, + tcp: 0 as Port, + tls: null, + }, + }; + + return { + demuxId, + serverMediaAddress: mediaServer, + serverIceUsernameFragment: serverIceUsernameFragment, + serverIcePassword: serverIcePassword, + serverPublicKey: response.serverPublicKey, + callEraId: call.eraId, + callCreatorUserId: call.creatorUserId, + clientStatus: response.clientStatus, + }; + } + + public async removeCall( + roomId: CallingRoomId, + eraId: CallingEraId, + ): Promise { + const existing = this.callsByRoomId.get(roomId); + if (existing == null) { + return; + } + + if (existing.eraId !== eraId) { + throw new CallingError( + CallingErrorCode.InternalError, + 'did not match era id', + ); + } + + this.callsByRoomId.delete(roomId); + + // TODO: Should this cleanup sfuService and drop all the clients? + throw new Error('incomplete'); + } + + public async peekCall( + roomId: CallingRoomId, + userId: CallingUserId, + ): Promise { + const call = this.callsByRoomId.get(roomId); + if (call == null) { + throw new CallingError(CallingErrorCode.CallNotFound); + } + + const response = await this.sfuService.peekCall({ + eraId: call.eraId, + userId: userId, + }); + + return response.info; + } + + // + // Usernames + // + + public async reserveUsername( + aci: AciString, + { usernameHashes }: { usernameHashes: Array> }, + ): Promise | undefined> { + // Clear previously reserved usernames + const reserved = this.reservedUsernameByAci.get(aci); + if (reserved !== undefined) { + this.reservedUsernameByAci.delete(aci); + this.aciByReservedUsername.delete(reserved); + } + + for (const hash of usernameHashes) { + const hashHex = Buffer.from(hash).toString('hex'); + if (this.aciByReservedUsername.has(hashHex)) { + continue; + } + if (this.aciByUsername.has(hashHex)) { + continue; + } + + this.reservedUsernameByAci.set(aci, hashHex); + this.aciByReservedUsername.set(hashHex, aci); + return hash; + } + + return undefined; + } + + public async confirmUsername( + aci: AciString, + { + usernameHash, + zkProof, + usernameCiphertext, + }: { + usernameHash: Uint8Array; + zkProof: Uint8Array; + usernameCiphertext: Uint8Array; + }, + ): Promise { + // Clear previously reserved usernames + const reserved = this.reservedUsernameByAci.get(aci); + if (reserved !== Buffer.from(usernameHash).toString('hex')) { + return undefined; + } + + try { + usernames.verifyProof(zkProof, usernameHash); + } catch (error) { + debug('failed to verify username proof of %s: %O', aci, error); + return undefined; + } + + this.reservedUsernameByAci.delete(aci); + this.aciByReservedUsername.delete(reserved); + + this.aciByUsername.set(reserved, aci); + this.usernameByAci.set(aci, reserved); + + const usernameLinkHandle = await this.replaceUsernameLink( + aci, + usernameCiphertext, + ); + + return { usernameHash, usernameLinkHandle }; + } + + public async deleteUsername(aci: AciString): Promise { + const hash = this.usernameByAci.get(aci); + if (!hash) { + return; + } + + this.aciByUsername.delete(hash); + this.usernameByAci.delete(aci); + + await this.deleteUsernameLink(aci); + } + + public async deleteUsernameLink(aci: AciString): Promise { + const previousId = this.usernameLinkIdByServiceId.get(aci); + if (previousId !== undefined) { + this.usernameLinkById.delete(previousId); + } + this.usernameLinkIdByServiceId.delete(aci); + } + + public async lookupByUsernameHash( + usernameHash: Buffer, + ): Promise { + return this.aciByUsername.get(usernameHash.toString('hex')); + } + + public async replaceUsernameLink( + aci: AciString, + encryptedValue: Uint8Array, + { keepLinkHandle = false }: { keepLinkHandle?: boolean } = {}, + ): Promise> { + const previousId = this.usernameLinkIdByServiceId.get(aci); + + const nextId = keepLinkHandle && previousId ? previousId : uuidv4(); + + if (previousId !== undefined) { + this.usernameLinkById.delete(previousId); + } + + this.usernameLinkIdByServiceId.set(aci, nextId); + this.usernameLinkById.set(nextId, Buffer.from(encryptedValue)); + + return parseUuid(nextId) as Uint8Array; + } + + public async lookupByUsernameLink( + lookupId: string, + ): Promise | undefined> { + return this.usernameLinkById.get(lookupId); + } + + // For easier testing + public async lookupByUsername( + username: string, + ): Promise { + return this.aciByUsername.get( + Buffer.from(usernames.hash(username)).toString('hex'), + ); + } + + // For easier testing + public async setUsername(aci: AciString, username: string): Promise { + const hash = Buffer.from(usernames.hash(username)).toString('hex'); + this.usernameByAci.set(aci, hash); + this.aciByUsername.set(hash, aci); + } + + // For easier testing + public async setUsernameLink( + aci: AciString, + username: string, + ): Promise { + const { entropy, encryptedUsername } = + usernames.createUsernameLink(username); + + const serverId = await this.replaceUsernameLink(aci, encryptedUsername); + + return { + entropy, + serverId, + }; + } + + // + // Call Links + // + + public async createCallLinkAuth( + device: Device, + request: CreateCallLinkCredentialRequest, + ): Promise { + return request.issueCredential( + Aci.parseFromServiceIdString(device.aci), + getTodayInSeconds(), + this.genericServerSecret, + ); + } + + public hasCallLink(roomId: string): boolean { + return this.callLinksByRoomId.has(roomId); + } + + public async createCallLink( + roomId: string, + { adminPasskey }: CreateCallLink, + ): Promise { + const callLink: CallLinkEntry = { + adminPasskey, + encryptedName: '', + restrictions: 'none', + revoked: false, + expiration: new Date('2101-01-01').getTime(), + }; + this.callLinksByRoomId.set(roomId, callLink); + return callLink; + } + + public async getCallLink(roomId: string): Promise { + return this.callLinksByRoomId.get(roomId); + } + + public async updateCallLink( + roomId: string, + { adminPasskey, name, restrictions, revoked }: UpdateCallLink, + ): Promise { + const callLink = this.callLinksByRoomId.get(roomId); + if (!callLink) { + throw new Error('Call link not found'); + } + if (!callLink.adminPasskey.equals(adminPasskey)) { + throw new Error('Invalid admin passkey'); + } + const newCallLink: CallLinkEntry = { + adminPasskey, + encryptedName: name ?? callLink.encryptedName, + restrictions: restrictions ?? callLink.restrictions, + revoked: revoked ?? callLink.revoked, + expiration: callLink.expiration, + }; + this.callLinksByRoomId.set(roomId, newCallLink); + return newCallLink; + } + + public async deleteCallLink( + roomId: string, + { adminPasskey }: DeleteCallLink, + ): Promise { + const callLink = this.callLinksByRoomId.get(roomId); + if (!callLink) { + throw new Error('Call link not found'); + } + if (!callLink.adminPasskey.equals(adminPasskey)) { + throw new Error('Invalid admin passkey'); + } + this.callLinksByRoomId.delete(roomId); + } + + // + // Utils + // + + public async getDevice( + number: string, + deviceId: DeviceId, + ): Promise { + const list = this.devices.get(number); + if (!list) { + return; + } + if (deviceId < 1 || deviceId > list.length) { + return; + } + + return list[deviceId - 1]; + } + async removeDevice(number: string, deviceId: DeviceId): Promise { + if (deviceId === PRIMARY_DEVICE_ID) { + throw new Error( + 'You cannot remove a primary device; unregister account instead', + ); + } + const list = this.devices.get(number); + if (!list) { + throw new Error(`No devices found for number ${number}`); + } + if (deviceId < 1 || deviceId > list.length) { + throw new Error( + `Device ${deviceId} is out of range for number ${number}`, + ); + } + + const device = list[deviceId - 1]; + + debug('removeDevice %j.%j (%j)', device?.aci, deviceId, number); + assert(device != null, `Missing device for deviceId ${deviceId}`); + + const copy = [...list]; + copy.splice(deviceId - 1, 1); + this.devices.set(number, copy); + + const idByNumber = `${number}.${deviceId}`; + this.devicesByAuth.delete(idByNumber); + + const idByAci = `${device.aci}.${deviceId}`; + this.devicesByAuth.delete(idByAci); + } + + public async getDeviceByServiceId( + serviceId: ServiceIdString, + deviceId?: DeviceId, + ): Promise { + const primary = this.devicesByServiceId.get(serviceId); + if (deviceId === undefined || !primary || primary.deviceId === deviceId) { + return primary; + } + if (primary.deviceId !== PRIMARY_DEVICE_ID) { + return undefined; + } + return this.getDevice(primary.number, deviceId); + } + + public async getAllDevicesByServiceId( + serviceId: ServiceIdString, + ): Promise> { + const primary = this.devicesByServiceId.get(serviceId); + if (!primary) { + return []; + } + + return this.devices.get(primary.number) ?? []; + } + + public async getSenderCertificate( + device: Device, + { includeE164 = true }: SenderCertificateOptions = {}, + ): Promise { + return generateSenderCertificate(this.certificate, { + number: includeE164 ? device.number : undefined, + aci: device.aci, + deviceId: device.deviceId, + identityKey: await device.getIdentityKey(ServiceIdKind.ACI), + }); + } + + public async getGroupCredentials( + { aci, pni }: Device, + range: CredentialsRange, + ): Promise { + const auth = new ServerZkAuthOperations(this.zkSecret); + + return this.issueCredentials(range, (redemptionTime) => { + return auth.issueAuthCredentialWithPniZkc( + Aci.parseFromServiceIdString(aci), + Pni.parseFromServiceIdString(pni), + redemptionTime, + ); + }); + } + + public async verifyGroupCredentials( + publicParams: Buffer, + credential: Buffer, + ): Promise { + const auth = new ServerZkAuthOperations(this.zkSecret); + + const groupParams = new GroupPublicParams(publicParams); + const presentation = new AuthCredentialPresentation(credential); + + auth.verifyAuthCredentialPresentation(groupParams, presentation); + + // TODO(indutny): verify credential timestamp + + return presentation; + } + + public async getCallLinkAuthCredentials( + { aci }: Device, + range: CredentialsRange, + ): Promise { + return this.issueCredentials(range, (redemptionTime) => { + return CallLinkAuthCredentialResponse.issueCredential( + Aci.parseFromServiceIdString(aci), + redemptionTime, + this.genericServerSecret, + ); + }); + } + + public async issueExpiringProfileKeyCredential( + { aci, profileKeyCommitment }: Device, + request: ProfileKeyCredentialRequest, + ): Promise | undefined> { + if (!profileKeyCommitment) { + return undefined; + } + + const today = getTodayInSeconds(); + + const profile = new ServerZkProfileOperations(this.zkSecret); + return Buffer.from( + profile + .issueExpiringProfileKeyCredential( + request, + Aci.parseFromServiceIdString(aci), + profileKeyCommitment, + today + PROFILE_KEY_CREDENTIAL_EXPIRATION, + ) + .serialize(), + ); + } + + public async setBackupId( + { aci }: Device, + { + messagesBackupAuthCredentialRequest, + mediaBackupAuthCredentialRequest, + }: SetBackupId, + ): Promise { + this.backupAuthReqByAci.set(aci, { + messages: new BackupAuthCredentialRequest( + messagesBackupAuthCredentialRequest, + ), + media: new BackupAuthCredentialRequest(mediaBackupAuthCredentialRequest), + }); + } + + public async setBackupKey( + headers: BackupHeaders, + { backupIdPublicKey }: SetBackupKey, + ): Promise { + const publicKey = PublicKey.deserialize(backupIdPublicKey); + const backupId = this.authenticateBackup(headers, publicKey); + this.backupKeyById.set(backupId, publicKey); + if (!this.backupCDNPasswordById.get(backupId)) { + const password = crypto.randomBytes(16).toString('hex'); + this.backupCDNPasswordById.set(backupId, password); + } + } + + public async refreshBackup(headers: BackupHeaders): Promise { + this.authenticateBackup(headers); + + // No-op for tests + } + + public async getBackupInfo(headers: BackupHeaders): Promise { + const backupId = this.authenticateBackup(headers); + + return { + cdn: 3, + backupDir: backupId, + mediaDir: 'media', + backupName: 'backup', + }; + } + + public async listBackupMedia( + headers: BackupHeaders, + { cursor, limit }: ListBackupMediaOptions, + ): Promise { + const backupId = this.authenticateBackup(headers); + + let cursorData: BackupMediaCursor | undefined; + let newCursor: string | undefined; + if (cursor !== undefined) { + cursorData = this.backupMediaCursorById.get(cursor); + } + if (cursorData === undefined) { + newCursor = crypto.randomBytes(8).toString('hex'); + cursorData = { + backupId, + remainingMedia: this.backupMediaById.get(backupId)?.slice() ?? [], + }; + this.backupMediaCursorById.set(newCursor, cursorData); + } else { + assert.strictEqual(cursorData.backupId, backupId); + } + + const storedMediaObjects = cursorData.remainingMedia.slice(0, limit); + + // End of list + if (storedMediaObjects.length < limit) { + assert(newCursor !== undefined); + + this.backupMediaCursorById.delete(newCursor); + newCursor = undefined; + } else { + cursorData.remainingMedia = cursorData.remainingMedia.slice(limit); + } + + return { + storedMediaObjects, + backupDir: backupId, + mediaDir: 'media', + cursor: newCursor, + }; + } + + public async getBackupMediaUploadForm( + headers: BackupHeaders, + ): Promise { + this.authenticateBackup(headers); + const form = await this.getAttachmentUploadForm('attachments', uuidv4()); + return form; + } + + public async getBackupUploadForm( + headers: BackupHeaders, + ): Promise { + const backupId = this.authenticateBackup(headers); + const form = await this.getAttachmentUploadForm( + 'backups', + `${backupId}/backup`, + ); + return form; + } + + public async backupMediaBatch( + headers: BackupHeaders, + batch: BackupMediaBatch, + ): Promise { + const backupId = this.authenticateBackup(headers); + const responses = await this.backupTransitAttachments(backupId, batch); + return { responses }; + } + + public async getBackupCDNAuth( + headers: BackupHeaders, + ): Promise> { + const backupId = this.authenticateBackup(headers); + const password = this.backupCDNPasswordById.get(backupId); + assert(password !== undefined); + + const basic = Buffer.from(`${backupId}:${password}`); + const authorization = `Basic ${basic.toString('base64')}`; + + return { + authorization, + }; + } + + public getBackupAuth(): { username: string; password: string } { + return this.backupAuth; + } + public setBackupAuth(auth: { username: string; password: string }): void { + this.backupAuth = auth; + } + + public async authorizeBackupCDN( + backupId: string, + password: string, + ): Promise { + const expected = this.backupCDNPasswordById.get(backupId); + if (expected === undefined) { + return false; + } + + if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(password))) { + return false; + } + + return true; + } + + public async getBackupCredentials( + { aci, backupLevel }: Device, + range: CredentialsRange, + ): Promise { + const req = this.backupAuthReqByAci.get(aci); + if (req === undefined) { + return undefined; + } + + const messages = this.issueCredentials(range, (redemptionTime) => { + return req.messages.issueCredential( + redemptionTime, + BackupLevel.Free, + BackupCredentialType.Messages, + this.backupServerSecret, + ); + }); + + const media = this.issueCredentials(range, (redemptionTime) => { + return req.media.issueCredential( + redemptionTime, + backupLevel, + BackupCredentialType.Media, + this.backupServerSecret, + ); + }); + + return { + messages, + media, + }; + } + + protected async onNewBackupMediaObject( + backupId: string, + media: BackupMediaObject, + ): Promise { + let list = this.backupMediaById.get(backupId); + if (list === undefined) { + list = []; + this.backupMediaById.set(backupId, list); + } + list.push(media); + } + + protected abstract backupTransitAttachments( + backupId: string, + batch: BackupMediaBatch, + ): Promise>; + + public abstract getTransferArchive( + device: Device, + ): Promise; + + public abstract isUnregistered(serviceId: ServiceIdString): boolean; + + public abstract isSendRateLimited(options: IsSendRateLimitedOptions): boolean; + + public abstract getResponseForChallenges(): ChallengeResponse | undefined; + + // + // Private + // + + protected set certificate(value: ServerCertificate) { + if (this.privCertificate) { + throw new Error('Certificate already set'); + } + this.privCertificate = value; + } + + protected get certificate(): ServerCertificate { + if (!this.privCertificate) { + throw new Error('Certificate not set'); + } + return this.privCertificate; + } + + protected set genericServerSecret(value: GenericServerSecretParams) { + if (this.privGenericServerSecret) { + throw new Error('zkgroup generic secret already set'); + } + this.privGenericServerSecret = value; + } + + protected get genericServerSecret(): GenericServerSecretParams { + if (!this.privGenericServerSecret) { + throw new Error('zkgroup generic secret not set'); + } + return this.privGenericServerSecret; + } + + protected set backupServerSecret(value: GenericServerSecretParams) { + if (this.privBackupServerSecret) { + throw new Error('zkgroup backup secret already set'); + } + this.privBackupServerSecret = value; + } + + protected get backupServerSecret(): GenericServerSecretParams { + if (!this.privBackupServerSecret) { + throw new Error('zkgroup backup secret not set'); + } + return this.privBackupServerSecret; + } + + protected set zkSecret(value: ServerSecretParams) { + if (this.privZKSecret) { + throw new Error('zkgroup secret already set'); + } + this.privZKSecret = value; + } + + protected get zkSecret(): ServerSecretParams { + if (!this.privZKSecret) { + throw new Error('zkgroup secret not set'); + } + return this.privZKSecret; + } + + private async sendQueue(device: Device, socket: WebSocket): Promise { + let queue = this.messageQueue.get(device); + if (queue) { + this.messageQueue.delete(device); + } else { + queue = []; + } + + debug('sending queued %d messages to %s', queue.length, device.debugId); + try { + await Promise.all( + queue.map((fn) => fn(socket)).concat(socket.sendMessage('empty')), + ); + } catch { + // Ignore errors, socket likely closed + } + debug('sent queued %d messages to %s', queue.length, device.debugId); + } + + private issueCredentials( + { from, to }: CredentialsRange, + issueOne: (redemptionTime: number) => SerializableCredential, + ): Credentials { + const today = getTodayInSeconds(); + if ( + from > to || + from < today || + to > today + DAY_IN_SECONDS * MAX_GROUP_CREDENTIALS_DAYS + ) { + throw new Error('Invalid redemption range'); + } + + const result: Credentials = []; + + for ( + let redemptionTime = from; + redemptionTime <= to; + redemptionTime += DAY_IN_SECONDS + ) { + result.push({ + credential: Buffer.from(issueOne(redemptionTime).serialize()).toString( + 'base64', + ), + redemptionTime, + }); + } + return result; + } + + private authenticateBackup( + headers: BackupHeaders, + newPublicKey?: PublicKey, + ): string { + let presentation: BackupAuthCredentialPresentation; + try { + presentation = new BackupAuthCredentialPresentation( + headers['x-signal-zk-auth'], + ); + presentation.verify(this.backupServerSecret); + } catch (e) { + throw new BackupAuthError( + 'Could not verify backup credential presentation', + { cause: e }, + ); + } + + // Backup id is used in urls, so encode it properly + const backupId = Buffer.from(presentation.getBackupId()).toString( + 'base64url', + ); + + const validatingKey = this.backupKeyById.get(backupId) ?? newPublicKey; + if (!validatingKey) { + throw new BackupAuthError('No backup public key to validate against'); + } + + const isValid = validatingKey.verify( + headers['x-signal-zk-auth'], + headers['x-signal-zk-auth-signature'], + ); + if (!isValid) { + throw new BackupAuthError('Invalid signature'); + } + + return backupId; + } +} diff --git a/packages/mock-server/src/server/call.ts b/packages/mock-server/src/server/call.ts new file mode 100644 index 0000000000..7604221f3e --- /dev/null +++ b/packages/mock-server/src/server/call.ts @@ -0,0 +1,38 @@ +// Copyright 2026 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { CallData, CallDataOptions } from '../data/call'; +import { CallingEraId, CallingRoomId, CallingUserId } from '../calling'; + +export type ServerCallOptions = Readonly< + CallDataOptions & { + roomId: CallingRoomId; + eraId: CallingEraId; + creatorUserId: CallingUserId; + } +>; + +export class ServerCall extends CallData { + #roomId: CallingRoomId; + #eraId: CallingEraId; + #creatorUserId: CallingUserId; + + constructor(options: ServerCallOptions) { + super(options); + this.#roomId = options.roomId; + this.#eraId = options.eraId; + this.#creatorUserId = options.creatorUserId; + } + + public override get roomId(): CallingRoomId { + return this.#roomId; + } + + public get eraId(): CallingEraId { + return this.#eraId; + } + + public get creatorUserId(): CallingUserId { + return this.#creatorUserId; + } +} diff --git a/packages/mock-server/src/server/common.ts b/packages/mock-server/src/server/common.ts new file mode 100644 index 0000000000..f2293c7e8e --- /dev/null +++ b/packages/mock-server/src/server/common.ts @@ -0,0 +1,37 @@ +// Copyright 2026 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import createDebug from 'debug'; +import type { ServerRequest, ServerResponse } from 'microrouter'; +import { send } from 'micro'; +import { ParseAuthHeaderResult, parseAuthHeader } from '../util'; +import type { Server } from './base'; +import type { Device } from '../data/device'; + +const debug = createDebug('mock:server:base'); + +export function parsePassword(req: ServerRequest): ParseAuthHeaderResult { + return parseAuthHeader(req.headers.authorization); +} + +export async function auth( + server: Server, + req: ServerRequest, + res: ServerResponse, +): Promise { + const { username, password, error } = parsePassword(req); + if (error) { + debug('%s %s auth failed, error %j', req.method, req.url, error); + void send(res, 401, { error }); + return; + } + + const device = await server.auth(username ?? '', password ?? ''); + if (!device) { + debug('%s %s auth failed, need re-provisioning', req.method, req.url); + void send(res, 401, { error: 'Need re-provisioning' }); + return; + } + + return device; +} diff --git a/packages/mock-server/src/server/group.ts b/packages/mock-server/src/server/group.ts new file mode 100644 index 0000000000..39b02b5b55 --- /dev/null +++ b/packages/mock-server/src/server/group.ts @@ -0,0 +1,503 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { + GroupPublicParams, + GroupSendDerivedKeyPair, + GroupSendEndorsementsResponse, + ProfileKeyCredentialPresentation, + ServerSecretParams, + ServerZkProfileOperations, + UuidCiphertext, +} from '@signalapp/libsignal-client/zkgroup'; +import assert from 'assert'; + +import { signalservice as Proto } from '../../protos/compiled'; +import { Group } from '../data/group'; +import { GroupStateSchema } from '../data/schemas'; +import { daysToSeconds, fromBase64, getTodayInSeconds } from '../util'; + +export type ServerGroupOptions = Readonly<{ + profileOps: ServerZkProfileOperations; + zkSecret: ServerSecretParams; + state: Proto.Group.Params; +}>; + +export type ModifyGroupResult = Readonly< + | { + conflict: false; + signedChange: Proto.GroupChange.Params; + } + | { + conflict: true; + signedChange: undefined; + } +>; + +const { AccessRequired } = Proto.AccessControl; +const { Role } = Proto.Member; + +function getTodaysKey(zkSecret: ServerSecretParams): GroupSendDerivedKeyPair { + const startOfDay = getTodayInSeconds(); + const expiration = startOfDay + daysToSeconds(2); + return GroupSendDerivedKeyPair.forExpiration( + new Date(1000 * expiration), + zkSecret, + ); +} + +export class ServerGroup extends Group { + private readonly profileOps: ServerZkProfileOperations; + private readonly zkSecret: ServerSecretParams; + + constructor({ profileOps, zkSecret, state }: ServerGroupOptions) { + super(); + + const parsedState = GroupStateSchema.parse(state); + + this.privPublicParams = new GroupPublicParams( + Buffer.from(parsedState.publicKey), + ); + this.profileOps = profileOps; + this.zkSecret = zkSecret; + + const unrolledState = { ...state }; + + unrolledState.members = (state.members ?? []).map((member) => + this.unrollMember(member), + ); + + this.privChanges = { + groupChanges: [ + { + groupState: unrolledState, + groupChange: null, + }, + ], + groupSendEndorsementsResponse: null, + }; + } + + public getGroupSendEndorsementResponse( + sourceAci: UuidCiphertext, + ): Uint8Array | null { + const authMember = this.getMember(sourceAci); + if (!authMember) { + return null; + } + + const members = this.state.members ?? []; + + const groupCiphertexts = members.map((member) => { + assert(member.userId, 'Member must have a user ID'); + return new UuidCiphertext(Buffer.from(member.userId)); + }); + + const todaysKey = getTodaysKey(this.zkSecret); + return GroupSendEndorsementsResponse.issue( + groupCiphertexts, + todaysKey, + ).serialize(); + } + + public modify( + sourceAci: UuidCiphertext, + sourcePni: UuidCiphertext, + actions: Proto.GroupChange.Actions.Params, + ): ModifyGroupResult { + const appliedActions: Proto.GroupChange.Actions.Params = { + version: actions.version, + sourceUserId: sourceAci.serialize(), + groupId: fromBase64(this.id), + addMembers: null, + deleteMembers: null, + modifyMemberRoles: null, + modifyMemberProfileKeys: null, + addMembersPendingProfileKey: null, + deleteMembersPendingProfileKey: null, + promoteMembersPendingProfileKey: null, + modifyTitle: null, + modifyAvatar: null, + modifyDisappearingMessageTimer: null, + modifyAttributesAccess: null, + modifyMemberAccess: null, + modifyAddFromInviteLinkAccess: null, + addMembersPendingAdminApproval: null, + deleteMembersPendingAdminApproval: null, + promoteMembersPendingAdminApproval: null, + modifyInviteLinkPassword: null, + modifyDescription: null, + modifyAnnouncementsOnly: null, + addMembersBanned: null, + deleteMembersBanned: null, + promoteMembersPendingPniAciProfileKey: null, + modifyMemberLabels: null, + modifyMemberLabelAccess: null, + terminateGroup: null, + }; + + assert.ok(actions.version, 'Actions should have a new version'); + + const timestamp = BigInt(Date.now()); + + const newState = { + ...this.state, + version: actions.version, + }; + + const authMember = this.getMember(sourceAci); + const { accessControl } = newState; + + let changeEpoch = 1; + + if (actions.modifyTitle) { + this.verifyAccess( + 'title', + authMember, + accessControl?.attributes ?? AccessRequired.UNKNOWN, + ); + + appliedActions.modifyTitle = actions.modifyTitle; + newState.title = actions.modifyTitle.title; + } + + const deleteMembers = actions.deleteMembers ?? []; + for (const { deletedUserId } of deleteMembers) { + assert.ok(deletedUserId, 'Missing deletedUserId'); + + this.verifyAccess( + 'members', + authMember, + accessControl?.members ?? AccessRequired.UNKNOWN, + deletedUserId, + ); + + const member = this.getMember( + new UuidCiphertext(Buffer.from(deletedUserId)), + ); + assert.ok(member, 'Pending member not found for deletion'); + + newState.members = (newState.members ?? []).filter( + (entry) => entry !== member, + ); + + appliedActions.deleteMembers = [ + ...(appliedActions.deleteMembers ?? []), + { deletedUserId }, + ]; + } + + const addMembersPendingProfileKey = + actions.addMembersPendingProfileKey ?? []; + for (const { added } of addMembersPendingProfileKey) { + assert.ok(added, 'Missing addPendingMember.added'); + + const { member } = added; + assert.ok(member, 'Missing addMembersPendingProfileKey.added.member'); + + const { userId, role } = member; + assert.ok( + userId, + 'Missing addMembersPendingProfileKey.added.member.userId', + ); + assert.ok( + role != null, + 'Missing addMembersPendingProfileKey.added.member.role', + ); + + this.verifyAccess( + 'pendingMembers', + authMember, + accessControl?.members ?? AccessRequired.UNKNOWN, + ); + + const newPendingMember = { + member: { + userId, + role, + profileKey: null, + presentation: null, + joinedAtVersion: null, + labelEmoji: null, + labelString: null, + }, + addedByUserId: sourceAci.serialize(), + timestamp, + }; + + newState.membersPendingProfileKey = [ + ...(newState.membersPendingProfileKey ?? []), + newPendingMember, + ]; + + appliedActions.addMembersPendingProfileKey = [ + ...(appliedActions.addMembersPendingProfileKey ?? []), + { added: newPendingMember }, + ]; + } + + const deleteMembersPendingProfileKey = + actions.deleteMembersPendingProfileKey ?? []; + for (const { deletedUserId } of deleteMembersPendingProfileKey) { + assert.ok(deletedUserId, 'Missing deletedUserId'); + + assert.ok( + Buffer.from(deletedUserId).equals(sourceAci.serialize()) || + Buffer.from(deletedUserId).equals(sourcePni.serialize()), + 'Not a pending member', + ); + + const pendingMember = this.getPendingMember( + new UuidCiphertext(Buffer.from(deletedUserId)), + ); + assert.ok(pendingMember, 'Pending member not found for deletion'); + + newState.membersPendingProfileKey = ( + newState.membersPendingProfileKey ?? [] + ).filter((entry) => entry !== pendingMember); + + appliedActions.deleteMembersPendingProfileKey = [ + ...(appliedActions.deleteMembersPendingProfileKey ?? []), + { deletedUserId }, + ]; + } + + const promoteMembersPendingProfileKey = + actions.promoteMembersPendingProfileKey ?? []; + for (const { presentation } of promoteMembersPendingProfileKey) { + assert.ok( + presentation, + 'Missing presentation in deleteMembersPendingProfileKey', + ); + const presentationFFI = new ProfileKeyCredentialPresentation( + Buffer.from(presentation), + ); + this.profileOps.verifyProfileKeyCredentialPresentation( + this.publicParams, + presentationFFI, + ); + + assert.ok( + Buffer.from(presentationFFI.getUuidCiphertext().serialize()).equals( + sourceAci.serialize(), + ), + 'Not a pending member', + ); + + const pendingMember = this.getPendingMember( + presentationFFI.getUuidCiphertext(), + ); + assert.ok(pendingMember, 'No pending member'); + assert.ok( + !this.getMember(presentationFFI.getUuidCiphertext()), + 'Member is both pending and active', + ); + + newState.membersPendingProfileKey = ( + newState.membersPendingProfileKey ?? [] + ).filter((entry) => entry !== pendingMember); + + const userId = presentationFFI.getUuidCiphertext().serialize(); + const profileKey = presentationFFI.getProfileKeyCiphertext().serialize(); + + newState.members = [ + ...(newState.members ?? []), + { + role: Role.DEFAULT, + userId, + profileKey, + presentation: null, + joinedAtVersion: null, + labelEmoji: null, + labelString: null, + }, + ]; + + appliedActions.promoteMembersPendingProfileKey = [ + ...(appliedActions.promoteMembersPendingProfileKey ?? []), + { userId, profileKey, presentation: null }, + ]; + } + + const promotePNIMembers = actions.promoteMembersPendingPniAciProfileKey; + for (const { presentation } of promotePNIMembers ?? []) { + assert.ok( + presentation, + 'Missing presentation in promoteMembersPendingPniAciProfileKey', + ); + const presentationFFI = new ProfileKeyCredentialPresentation( + Buffer.from(presentation), + ); + + this.profileOps.verifyProfileKeyCredentialPresentation( + this.publicParams, + presentationFFI, + ); + + const aci = presentationFFI.getUuidCiphertext(); + const pni = sourcePni; + const profileKey = presentationFFI.getProfileKeyCiphertext(); + + assert.ok( + Buffer.from(aci.serialize()).equals(sourceAci.serialize()), + 'Not a pending member', + ); + + const pendingMember = this.getPendingMember(pni); + assert.ok(pendingMember, 'No pending pni member'); + assert.ok(!this.getMember(aci), 'ACI is already a member'); + + newState.membersPendingProfileKey = ( + newState.membersPendingProfileKey ?? [] + ).filter((entry) => entry !== pendingMember); + + newState.members = [ + ...(newState.members ?? []), + { + role: Role.DEFAULT, + userId: aci.serialize(), + profileKey: profileKey.serialize(), + presentation: null, + joinedAtVersion: null, + labelEmoji: null, + labelString: null, + }, + ]; + + changeEpoch = Math.max(changeEpoch, 5); + appliedActions.sourceUserId = sourcePni.serialize(); + appliedActions.promoteMembersPendingPniAciProfileKey = [ + ...(appliedActions.promoteMembersPendingPniAciProfileKey ?? []), + { + userId: aci.serialize(), + pni: pni.serialize(), + profileKey: profileKey.serialize(), + presentation: null, + }, + ]; + } + + if (actions.terminateGroup !== null) { + this.verifyAccess('terminated', authMember, AccessRequired.ADMINISTRATOR); + + appliedActions.terminateGroup = {}; + newState.terminated = true; + } + + if (actions.modifyDisappearingMessageTimer !== null) { + this.verifyAccess( + 'disappearingMessagesTimer', + authMember, + accessControl?.attributes ?? AccessRequired.UNKNOWN, + ); + + appliedActions.modifyDisappearingMessageTimer = + actions.modifyDisappearingMessageTimer; + newState.disappearingMessagesTimer = + actions.modifyDisappearingMessageTimer.timer; + } + + const { version: oldVersion } = this.state; + assert.ok( + typeof oldVersion === 'number', + 'Group must have existing version', + ); + if (actions.version !== oldVersion + 1) { + return { conflict: true, signedChange: undefined }; + } + + const encodedActions = Proto.GroupChange.Actions.encode(appliedActions); + const serverSignature = this.zkSecret + .sign(Buffer.from(encodedActions)) + .serialize(); + + const groupChange: Proto.GroupChange.Params = { + actions: encodedActions, + changeEpoch, + serverSignature: null, + }; + + assert.ok(this.privChanges?.groupChanges, 'Must be initialized'); + this.privChanges.groupChanges.push({ + groupChange, + groupState: newState, + }); + + return { + conflict: false, + signedChange: { + ...groupChange, + serverSignature, + }, + }; + } + + // + // Private + // + + private verifyAccess( + attribute: string, + member: Proto.Member.Params | undefined, + access: Proto.AccessControl['attributes'], + affectedUserId?: Uint8Array, + ): void { + // Changing something about ourselves is always allowed + if ( + member?.userId && + affectedUserId && + Buffer.from(member.userId).equals(affectedUserId) + ) { + return; + } + + switch (access) { + case AccessRequired.ANY: + break; + + case AccessRequired.MEMBER: + assert.ok(member, `Must be a member to access: ${attribute}`); + break; + + case AccessRequired.ADMINISTRATOR: + assert.strictEqual( + member?.role, + Role.ADMINISTRATOR, + `Must be an administrator to modify: ${attribute}`, + ); + break; + + case AccessRequired.UNSATISFIABLE: + throw new Error(`Unsatisfiable access attribute: ${attribute}`); + + case AccessRequired.UNKNOWN: + throw new Error(`Unknown access for attribute: ${attribute}`); + } + } + + private unrollMember({ + role, + presentation, + }: Proto.Member.Params): Proto.Member.Params { + assert.strictEqual(typeof role, 'number', 'Group member role is undefined'); + assert.ok(presentation, 'Group member presentation is undefined'); + + const presentationFFI = new ProfileKeyCredentialPresentation( + Buffer.from(presentation), + ); + this.profileOps.verifyProfileKeyCredentialPresentation( + this.publicParams, + presentationFFI, + ); + + return { + role, + userId: presentationFFI.getUuidCiphertext().serialize(), + profileKey: presentationFFI.getProfileKeyCiphertext().serialize(), + presentation: null, + joinedAtVersion: null, + labelEmoji: null, + labelString: null, + }; + } +} diff --git a/packages/mock-server/src/server/grpc.ts b/packages/mock-server/src/server/grpc.ts new file mode 100644 index 0000000000..bfbc2d7eca --- /dev/null +++ b/packages/mock-server/src/server/grpc.ts @@ -0,0 +1,621 @@ +// Copyright 2026 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import assert from 'assert'; +import type { ServerResponse } from 'http'; +import { Buffer } from 'buffer'; +import createDebug from 'debug'; +import { stringify as stringifyUuid, v4 as uuidv4 } from 'uuid'; +import { RequestHandler, buffer, send as sendRaw } from 'micro'; +import { + AugmentedRequestHandler as RouteHandler, + del, + get, + head, + options, + patch, + post, + put, + router, + type ServerRequest, +} from 'microrouter'; +import { ServiceId, Aci, Pni } from '@signalapp/libsignal-client'; +import SealedSenderMultiRecipientMessage from '@signalapp/libsignal-client/dist/SealedSenderMultiRecipientMessage'; +import { BackupHeaders, Message } from '../data/schemas'; +import type { Device } from '../data/device'; + +import { DeviceId, RegistrationId, ServiceIdString } from '../types'; +import { $services, org, signalservice as Proto } from '../../protos/compiled'; +import { AttachmentUploadForm, BackupAuthError, Server } from './base'; +import { parsePassword } from './common'; + +const debug = createDebug('mock:grpc'); + +async function auth( + server: Server, + req: ServerRequest, +): Promise { + const { username, password, error } = parsePassword(req); + if (error) { + debug('%s %s auth failed, error %j', req.method, req.url, error); + return; + } + + const device = await server.auth(username ?? '', password ?? ''); + if (!device) { + debug('%s %s auth failed, need re-provisioning', req.method, req.url); + return; + } + + return device; +} + +const ALL_METHODS = [get, post, put, patch, del, head, options] as const; + +function toServiceIdentifier( + string: ServiceIdString, +): org.signal.chat.common.ServiceIdentifier.Params { + const object = ServiceId.parseFromServiceIdString(string); + if (object instanceof Pni) { + return { + identityType: org.signal.chat.common.IdentityType.IDENTITY_TYPE_PNI, + uuid: object.getRawUuidBytes(), + }; + } + + if (object instanceof Aci) { + return { + identityType: org.signal.chat.common.IdentityType.IDENTITY_TYPE_ACI, + uuid: object.getRawUuidBytes(), + }; + } + + throw new Error(`Invalid service id: ${string}`); +} + +function toBackupHeaders( + signedPresentation: org.signal.chat.backup.SignedPresentation | null, +): BackupHeaders { + if (signedPresentation == null) { + throw new Error('Missing signedPresentation'); + } + + return { + 'x-signal-zk-auth': Buffer.from(signedPresentation.presentation), + 'x-signal-zk-auth-signature': Buffer.from( + signedPresentation.presentationSignature, + ), + }; +} + +// gRPC status codes used by the mock. +const GRPC_STATUS_OK = 0; +const GRPC_STATUS_UNKNOWN = 2; +const GRPC_STATUS_UNAUTHENTICATED = 16; + +class GrpcAuthError extends Error {} + +// A gRPC response over HTTP/2 always uses HTTP status 200; the actual gRPC +// status code is carried in the trailing HEADERS frame (`grpc-status`). `micro` +// has no notion of trailers, so we emit them via the HTTP/2 compat API. (Driving +// the raw stream directly conflicts with the Http2ServerResponse that `micro` +// holds and throws ERR_HTTP2_TRAILERS_ALREADY_SENT.) +function sendGrpcResponse( + res: ServerResponse, + body: Buffer, + status: number, + message?: string, +): void { + const trailers: Record = { + 'grpc-status': String(status), + }; + if (message !== undefined) { + // Per the gRPC spec, `grpc-message` is percent-encoded. + trailers['grpc-message'] = encodeURIComponent(message); + } + + res.writeHead(200, { 'content-type': 'application/grpc' }); + res.addTrailers(trailers); + res.end(body); +} + +type GrpcRequest = ReturnType< + (typeof $services)[Endpoint]['Request']['decode'] +>; + +type GrpcResponse = Parameters< + (typeof $services)[Endpoint]['Response']['encode'] +>[0]; + +export const createHandler = (server: Server): RequestHandler => { + function grpcRoute( + endpoint: Endpoint, + handler: ( + request: GrpcRequest, + device: Device | undefined, + ) => Promise>, + ) { + const definition = $services[endpoint]; + // TODO(indutny): enforce on type level + if (definition.isRequestStream || definition.isResponseStream) { + throw new Error(`Request/response stream is not supported`); + } + return post(`/${endpoint}`, async (httpReq, res) => { + try { + const raw = await buffer(httpReq); + assert(Buffer.isBuffer(raw)); + assert(raw.buffer instanceof ArrayBuffer); + + if (raw.length < 5) { + throw new Error('gRPC request is too short'); + } + + if (raw[0] !== 0) { + throw new Error('Unsupported request compression'); + } + + const len = raw.readUint32BE(1); + if (raw.length !== 5 + len) { + throw new Error('Invalid gRPC request size'); + } + + const grpcRequest = definition.Request.decode( + raw.subarray(5, 5 + len) as Uint8Array, + ); + + const device = await auth(server, httpReq); + + const response = await handler( + grpcRequest as Parameters[0], + device, + ); + + const data = ( + definition.Response.encode as ( + params: unknown, + ) => Uint8Array + )(response); + const header = Buffer.alloc(5); + header.writeUint32BE(data.length, 1); + sendGrpcResponse(res, Buffer.concat([header, data]), GRPC_STATUS_OK); + } catch (error) { + debug('gRPC handler error for %s', endpoint, error); + sendGrpcResponse( + res, + Buffer.alloc(0), + error instanceof GrpcAuthError + ? GRPC_STATUS_UNAUTHENTICATED + : GRPC_STATUS_UNKNOWN, + error instanceof Error ? error.message : String(error), + ); + } + }); + } + + function authenticatedGrpcRoute( + endpoint: Endpoint, + handler: ( + grpcRequest: GrpcRequest, + device: Device, + ) => Promise>, + ) { + return grpcRoute(endpoint, async (grpcRequest, device) => { + if (!device) { + throw new GrpcAuthError('incorrect credentials'); + } + + return handler(grpcRequest, device); + }); + } + + async function onMultiRecipientMessage( + request: + | org.signal.chat.messages.SendMultiRecipientMessageRequest + | org.signal.chat.messages.SendMultiRecipientStoryRequest, + ): Promise { + const { + message: givenMessage, + // TODO(indutny): check it at all? + // groupSendToken, + } = request; + + if (givenMessage == null) { + throw new Error('Missing message'); + } + + const { timestamp, payload } = givenMessage; + + const message = new SealedSenderMultiRecipientMessage(Buffer.from(payload)); + + const listByServiceId = new Map>(); + + const recipients = message.recipientsByServiceIdString(); + for (const [serviceId, recipient] of Object.entries(recipients)) { + let list: Array | undefined = listByServiceId.get( + serviceId as ServiceIdString, + ); + if (!list) { + list = []; + listByServiceId.set(serviceId as ServiceIdString, list); + } + + for (const [i, deviceId] of recipient.deviceIds.entries()) { + const registrationId = recipient.registrationIds.at(i); + + list.push({ + type: Proto.Envelope.Type.UNIDENTIFIED_SENDER, + destinationDeviceId: deviceId as DeviceId, + destinationRegistrationId: registrationId as RegistrationId, + content: Buffer.from(message.messageForRecipient(recipient)).toString( + 'base64', + ), + }); + } + } + + const results = await Promise.all( + Array.from(listByServiceId.entries()).map( + async ([serviceId, messages]) => { + return { + uuid: serviceId, + prepared: await server.prepareMultiDeviceMessage( + undefined, + serviceId, + messages, + timestamp, + ), + }; + }, + ), + ); + + const mismatchedDevices = results.filter(({ prepared }) => { + return prepared.status === 'incomplete' || prepared.status === 'stale'; + }); + + if (mismatchedDevices.length > 0) { + return { + response: { + mismatchedDevices: { + mismatchedDevices: mismatchedDevices.map(({ uuid, prepared }) => { + if (prepared.status === 'incomplete') { + return { + serviceIdentifier: toServiceIdentifier(uuid), + missingDevices: prepared.missingDevices.slice(), + extraDevices: prepared.extraDevices.slice(), + staleDevices: null, + }; + } + + assert.ok(prepared.status === 'stale'); + return { + serviceIdentifier: toServiceIdentifier(uuid), + missingDevices: null, + extraDevices: null, + staleDevices: prepared.staleDevices.slice(), + }; + }), + }, + }, + }; + } + + const uuids404 = results + .filter(({ prepared }) => prepared.status === 'unknown') + .map(({ uuid }) => uuid); + + const ok = results.filter(({ prepared }) => prepared.status === 'ok'); + + await Promise.all( + ok.map(({ prepared }) => { + assert.ok(prepared.status === 'ok'); + return server.handlePreparedMultiDeviceMessage( + undefined, + prepared.targetServiceId, + prepared.result, + ); + }), + ); + + return { + response: { + success: { + unresolvedRecipients: uuids404.map(toServiceIdentifier), + }, + }, + }; + } + + const onSendMultiRecipientMessage = grpcRoute( + 'org.signal.chat.messages.MessagesAnonymous/SendMultiRecipientMessage', + onMultiRecipientMessage, + ); + + const onSendMultiRecipientStory = grpcRoute( + 'org.signal.chat.messages.MessagesAnonymous/SendMultiRecipientStory', + onMultiRecipientMessage, + ); + + const onLookupUsernameHash = grpcRoute( + 'org.signal.chat.account.AccountsAnonymous/LookupUsernameHash', + async ({ usernameHash }) => { + const uuid = await server.lookupByUsernameHash(Buffer.from(usernameHash)); + + if (!uuid) { + return { + response: { + notFound: {}, + }, + }; + } + + return { + response: { + serviceIdentifier: toServiceIdentifier(uuid), + }, + }; + }, + ); + + const onLookupUsernameLink = grpcRoute( + 'org.signal.chat.account.AccountsAnonymous/LookupUsernameLink', + async ({ usernameLinkHandle }) => { + const usernameCiphertext = await server.lookupByUsernameLink( + stringifyUuid(usernameLinkHandle), + ); + + if (!usernameCiphertext) { + return { + response: { + notFound: {}, + }, + }; + } + + return { + response: { + usernameCiphertext, + }, + }; + }, + ); + + const onGetUploadForm = grpcRoute( + 'org.signal.chat.attachments.Attachments/GetUploadForm', + async () => { + const { cdn, key, headers, signedUploadLocation } = + await server.getAttachmentUploadForm('attachments', uuidv4()); + return { + outcome: { + uploadForm: { + cdn, + key, + headers: new Map(Object.entries(headers)), + signedUploadLocation, + }, + }, + }; + }, + ); + + const onSetBackupPublicKey = grpcRoute( + 'org.signal.chat.backup.BackupsAnonymous/SetPublicKey', + async ({ signedPresentation, publicKey }) => { + try { + await server.setBackupKey(toBackupHeaders(signedPresentation), { + backupIdPublicKey: Buffer.from(publicKey), + }); + } catch (error) { + if (error instanceof BackupAuthError) { + return { + response: { failedAuthentication: { description: error.message } }, + }; + } + throw error; + } + + return { response: { success: {} } }; + }, + ); + + const onRefreshBackup = grpcRoute( + 'org.signal.chat.backup.BackupsAnonymous/Refresh', + async ({ signedPresentation }) => { + try { + await server.refreshBackup(toBackupHeaders(signedPresentation)); + } catch (error) { + if (error instanceof BackupAuthError) { + return { + response: { failedAuthentication: { description: error.message } }, + }; + } + throw error; + } + + return { response: { success: {} } }; + }, + ); + + const onGetBackupCdnCredentials = grpcRoute( + 'org.signal.chat.backup.BackupsAnonymous/GetCdnCredentials', + async ({ signedPresentation, cdn }) => { + if (cdn !== 3) { + throw new Error(`Invalid cdn: ${cdn}`); + } + + let cdnHeaders: Record; + try { + cdnHeaders = await server.getBackupCDNAuth( + toBackupHeaders(signedPresentation), + ); + } catch (error) { + if (error instanceof BackupAuthError) { + return { + response: { failedAuthentication: { description: error.message } }, + }; + } + throw error; + } + + return { + response: { + cdnCredentials: { headers: new Map(Object.entries(cdnHeaders)) }, + }, + }; + }, + ); + + const onGetBackupUploadForm = grpcRoute( + 'org.signal.chat.backup.BackupsAnonymous/GetUploadForm', + async ({ signedPresentation, uploadType }) => { + if (uploadType == null) { + throw new Error('Missing uploadType'); + } + + const headers = toBackupHeaders(signedPresentation); + + let form: AttachmentUploadForm; + try { + if (uploadType.messages != null) { + form = await server.getBackupUploadForm(headers); + } else { + form = await server.getBackupMediaUploadForm(headers); + } + } catch (error) { + if (!(error instanceof BackupAuthError)) { + throw error; + } + + return { + response: { failedAuthentication: { description: error.message } }, + }; + } + + return { + response: { + uploadForm: { + cdn: form.cdn, + key: form.key, + headers: new Map(Object.entries(form.headers)), + signedUploadLocation: form.signedUploadLocation, + }, + }, + }; + }, + ); + + const onReserveUsername = authenticatedGrpcRoute( + 'org.signal.chat.account.Accounts/ReserveUsernameHash', + async ({ usernameHashes }, device) => { + const usernameHash = await server.reserveUsername(device.aci, { + usernameHashes, + }); + + if (!usernameHash) { + return { + response: { + usernameNotAvailable: {}, + }, + }; + } + + return { + response: { + usernameHash, + }, + }; + }, + ); + + const onConfirmUsername = authenticatedGrpcRoute( + 'org.signal.chat.account.Accounts/ConfirmUsernameHash', + async (body, device) => { + const result = await server.confirmUsername(device.aci, body); + if (!result) { + return { + response: { + reservationNotFound: { + description: + "Given username hash doesn't match the reserved one or no reservation found.", + }, + }, + }; + } + + return { + response: { + confirmedUsernameHash: result, + }, + }; + }, + ); + + const onDeleteUsername = authenticatedGrpcRoute( + 'org.signal.chat.account.Accounts/DeleteUsernameHash', + async (_body, device) => { + await server.deleteUsername(device.aci); + + return {}; + }, + ); + + const onSetUsernameLink = authenticatedGrpcRoute( + 'org.signal.chat.account.Accounts/SetUsernameLink', + async ({ usernameCiphertext, keepLinkHandle }, device) => { + const usernameLinkHandle = await server.replaceUsernameLink( + device.aci, + usernameCiphertext, + { keepLinkHandle }, + ); + + return { + response: { + usernameLinkHandle, + }, + }; + }, + ); + + const notFoundAfterAuth: RouteHandler = async (req, res) => { + const device = await auth(server, req); + if (!device) { + return sendRaw(res, 401, { error: 'Not authorized' }); + } + + debug('Unsupported request %s %s', req.method, req.url); + return sendRaw(res, 404, { error: 'Not supported yet' }); + }; + + const routes = router( + // gRPC + onSendMultiRecipientMessage, + onSendMultiRecipientStory, + onLookupUsernameHash, + onLookupUsernameLink, + onGetUploadForm, + onReserveUsername, + onConfirmUsername, + onDeleteUsername, + onSetUsernameLink, + onSetBackupPublicKey, + onRefreshBackup, + onGetBackupCdnCredentials, + onGetBackupUploadForm, + + ...ALL_METHODS.map((method) => method('/*', notFoundAfterAuth)), + ); + + return (req, res) => { + debug('got request %s %s', req.method, req.url); + try { + res.once('finish', () => { + debug('response %s %s', req.method, req.url, res.statusCode); + }); + return routes(req, res); + } catch (error) { + assert(error instanceof Error); + debug('request failure %s %s', req.method, req.url, error.stack); + return sendRaw(res, 500, error.message); + } + }; +}; diff --git a/packages/mock-server/src/server/http.ts b/packages/mock-server/src/server/http.ts new file mode 100644 index 0000000000..2e35d47ae0 --- /dev/null +++ b/packages/mock-server/src/server/http.ts @@ -0,0 +1,1001 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { UuidCiphertext } from '@signalapp/libsignal-client/zkgroup'; +import assert from 'assert'; +import { Buffer } from 'buffer'; +import createDebug from 'debug'; +import { RequestHandler, buffer, json, send } from 'micro'; +import { + AugmentedRequestHandler as RouteHandler, + ServerRequest, + ServerResponse, + del, + get, + head, + options, + patch, + post, + put, + router, + withNamespace, +} from 'microrouter'; +import { type FileHandle, open, readFile, stat } from 'node:fs/promises'; +import { pipeline } from 'node:stream/promises'; +import { Server as TusServer } from '@tus/server'; +import { FileStore } from '@tus/file-store'; + +import { signalservice as Proto } from '../../protos/compiled'; +import { Device } from '../data/device'; +import { + CreateCallLinkSchema, + DeleteCallLinkSchema, + PositiveInt, + UpdateCallLinkSchema, +} from '../data/schemas'; +import { AttachmentId } from '../types'; +import { CallLinkEntry, Server } from './base'; +import { ServerGroup } from './group'; +import { parsePassword, auth } from './common'; +import { join } from 'path'; +import { createHash } from 'crypto'; +import z from 'zod'; +import { + CALLING_SERVICE_SECRET, + CallingError, + CallingErrorCodesToHttpStatus, + CallingPublicKeySchema, + CallType, + CallingUserId, + CallingDemuxId, + CallingEraId, + generateCallingAuthToken, + parseCallingAuthHeader, + verifyCallingAuthToken, + decodeCallingPublicKey, + encodeCallingPublicKey, + HexString, +} from '../calling'; +import { + IcePassword, + IcePasswordSchema, + IceUsernameFragment, + IceUsernameFragmentSchema, +} from '../sfu/ice'; +import { SfuClientStatus } from '../sfu/call'; +import { Hostname, IpAddress, Port } from '../sfu/config'; + +const debug = createDebug('mock:http'); + +const ALL_METHODS = [get, post, put, patch, del, head, options] as const; + +function getContentType(filePath: string): string { + const ext = filePath.toLowerCase().split('.').pop(); + switch (ext) { + case 'json': + return 'application/json'; + case 'png': + return 'image/png'; + default: + return 'application/octet-stream'; + } +} + +export const createHandler = ( + server: Server, + { + cdn3Path, + updates2Path, + }: { cdn3Path: string | undefined; updates2Path: string | undefined }, +): RequestHandler => { + // + // CDN + // + + const tusServer = new TusServer({ + path: '/cdn3', + datastore: new FileStore({ directory: cdn3Path ?? '' }), + namingFunction: (req) => { + assert(req.url); + return req.url.replace(/^(\/cdn3)?\/+/, ''); + }, + }); + + const getResourcesAttachment = get('/updates2/*', async (req, res) => { + const thePath = req.params._; + + assert( + updates2Path, + 'updates2Path must be provided to retrieve from updates2', + ); + + if (!thePath) { + void send(res, 400, { error: 'Missing path' }); + return; + } + + let file: FileHandle | undefined; + try { + file = await open(join(updates2Path, thePath), 'r'); + + const { size, mtime } = await file.stat(); + const etag = `"${mtime.getTime().toString(16)}"`; + + res.writeHead(200, { + 'Content-Length': size, + 'Content-Type': getContentType(thePath), + ETag: etag, + }); + await pipeline(file.createReadStream(), res); + } catch (e) { + await file?.close(); + + assert(e instanceof Error); + if ('code' in e && e.code === 'ENOENT') { + return send(res, 404); + } + return send(res, 500, e.message); + } + }); + + const headResourcesAttachment = head('/updates2/*', async (req, res) => { + const thePath = req.params._; + + assert( + updates2Path, + 'updates2Path must be provided to retrieve from updates2', + ); + + if (!thePath) { + void send(res, 400, { error: 'Missing path' }); + return; + } + + const filePath = join(updates2Path, thePath); + + try { + const { size } = await stat(filePath); + const fileContent = await readFile(filePath); + const etag = createHash('md5') + .update(new Uint8Array(fileContent)) + .digest('hex'); + + res.writeHead(200, { + 'Content-Length': size, + ETag: etag, + }); + res.end(); + } catch (e) { + assert(e instanceof Error); + if ('code' in e && e.code === 'ENOENT') { + return send(res, 404); + } + return send(res, 500, e.message); + } + }); + + const getCdn3Attachment = get('/cdn3/:folder/*', async (req, res) => { + assert(cdn3Path, 'cdn3Path must be set'); + assert(req.params.folder != null, 'Missing folder param'); + assert(req.params._ != null, 'Missing extra params'); + + if (req.params.folder === 'backups') { + const { username, password, error } = parsePassword(req); + if (error) { + debug( + '%s %s backup cdn auth failed, error %j', + req.method, + req.url, + error, + ); + void send(res, 401, { error }); + return; + } + if (!username || !password) { + void send(res, 401, { error: 'Missing username and/or password' }); + return; + } + const authorized = await server.authorizeBackupCDN(username, password); + if (!authorized) { + void send(res, 403, { error: 'Invalid password' }); + return; + } + } + + let file: FileHandle | undefined; + try { + file = await open(join(cdn3Path, req.params.folder, req.params._), 'r'); + + const { size } = await file.stat(); + + res.writeHead(200, { + 'Content-Length': size, + }); + await pipeline(file.createReadStream(), res); + } catch (e) { + await file?.close(); + + assert(e instanceof Error); + if ('code' in e && e.code === 'ENOENT') { + return send(res, 404); + } + return send(res, 500, e.message); + } + }); + + const getAttachment = get('/attachments/:key', async (req, res) => { + // TODO(indutny): range requests + const { key } = req.params; + const result = await server.fetchAttachment(key as AttachmentId); + if (!result) { + return send(res, 404, { error: 'Attachment not found' }); + } + return result; + }); + + const getStickerPack = get( + '/stickers/:pack/manifest.proto', + async (req, res) => { + assert(req.params.pack != null, 'Missing pack param'); + const { pack } = req.params; + const result = await server.fetchStickerPack(pack); + if (!result) { + return send(res, 404, { error: 'Sticker pack not found' }); + } + return result; + }, + ); + + const getSticker = get('/stickers/:pack/full/:sticker', async (req, res) => { + assert(req.params.pack != null, 'Missing pack param'); + assert(req.params.sticker != null, 'Missing sticker param'); + const { pack, sticker } = req.params; + const result = await server.fetchSticker(pack, parseInt(sticker, 10)); + if (!result) { + return send(res, 404, { error: 'Sticker not found' }); + } + return result; + }); + + const notFound: RouteHandler = async (req, res) => { + debug('Unsupported request %s %s', req.method, req.url); + return send(res, 404, { error: 'Not supported yet' }); + }; + + // + // Calling + // + + type GetConferenceParticipantsResponseDeviceInfo = Readonly<{ + demuxId: CallingDemuxId; + opaqueUserId: CallingUserId; + }>; + + type GetConferenceParticipantsResponse = Readonly<{ + conferenceId: CallingEraId; + maxDevices: number; + creator: CallingUserId; + participants: ReadonlyArray; + pendingClients: ReadonlyArray; + callLinkState: null; + }>; + + const getConferenceParticipants = get( + '/v2/conference/participants', + async (req, res) => { + try { + const authToken = parseCallingAuthHeader(req.headers.authorization); + const auth = verifyCallingAuthToken(authToken, CALLING_SERVICE_SECRET); + + const roomIdHeader = req.headers['x-room-id']; + const epoch = req.headers.epoch; + + if (roomIdHeader != null || epoch != null) { + throw new Error('unimplemented for call links'); + } + + const info = await server.peekCall(auth.roomId, auth.userId); + + const response: GetConferenceParticipantsResponse = { + conferenceId: info.eraId, + maxDevices: info.maxClients, + participants: info.activeClients, + creator: info.creatorUserId, + pendingClients: info.pendingClients ?? [], + callLinkState: null, + }; + + await send(res, 200, response); + return; + } catch (error) { + if (error instanceof CallingError) { + const status = CallingErrorCodesToHttpStatus[error.code]; + return send(res, status, { message: error.message }); + } else { + debug('Error: %', error); + return send(res, 500); + } + } + }, + ); + + const JoinConferenceParticipantsRequestBody = z.object({ + adminPasskey: z.string().base64().optional(), + iceUfrag: IceUsernameFragmentSchema, + icePwd: IcePasswordSchema, + dhePublicKey: CallingPublicKeySchema, + hkdfExtraInfo: z.string().optional(), + }); + + type JoinConferenceParticipantsResponse = Readonly<{ + demuxId: CallingDemuxId; + ips: ReadonlyArray; + port: Port; + portTcp: Port; + portTls: Port | null; + hostname: Hostname | null; + iceUfrag: IceUsernameFragment; + icePwd: IcePassword; + dhePublicKey: HexString; + callCreator: CallingUserId; + conferenceId: CallingEraId; + clientStatus: SfuClientStatus; + }>; + + const joinConferenceParticipants = put( + '/v2/conference/participants', + async (req, res) => { + try { + const authToken = parseCallingAuthHeader(req.headers.authorization); + const auth = verifyCallingAuthToken(authToken, CALLING_SERVICE_SECRET); + + const roomIdHeader = req.headers['x-room-id']; + const epochHeader = req.headers.epoch; + + if (roomIdHeader != null || epochHeader != null) { + throw new Error('unimplemented for call links'); + } + + const body: unknown = await json(req); + const data = JoinConferenceParticipantsRequestBody.parse(body); + + const clientHkdfExtraInfo = + data.hkdfExtraInfo != null ? Buffer.from(data.hkdfExtraInfo) : null; + + const clientPublicKey = decodeCallingPublicKey(data.dhePublicKey); + + const result = await server.joinCall({ + roomId: auth.roomId, + userId: auth.userId, + isAllowedToInitiateGroupCall: auth.isAllowedToInitiateGroupCall, + clientIceUsernameFragment: data.iceUfrag, + clientIcePassword: data.icePwd, + clientPublicKey, + clientHkdfExtraInfo, + callType: CallType.Group, + newClientsRequireApproval: false, + isAdmin: false, + approvedUsers: null, + }); + + const response: JoinConferenceParticipantsResponse = { + demuxId: result.demuxId, + ips: result.serverMediaAddress.addresses, + port: result.serverMediaAddress.ports.udp, + portTcp: result.serverMediaAddress.ports.tcp, + portTls: result.serverMediaAddress.ports.tls, + hostname: result.serverMediaAddress.hostname, + iceUfrag: result.serverIceUsernameFragment, + icePwd: result.serverIcePassword, + dhePublicKey: encodeCallingPublicKey(result.serverPublicKey), + callCreator: result.callCreatorUserId, + conferenceId: result.callEraId, + clientStatus: result.clientStatus, + }; + + await send(res, 200, response); + return; + } catch (error) { + if (error instanceof CallingError) { + const status = CallingErrorCodesToHttpStatus[error.code]; + return send(res, status, { message: error.message }); + } else { + debug('Error: %', error); + return send(res, 500); + } + } + }, + ); + + function toCallLinkResponse(callLink: CallLinkEntry) { + return { + name: callLink.encryptedName, + restrictions: callLink.restrictions, + revoked: callLink.revoked, + expiration: Math.floor(callLink.expiration / 1000), // unix + }; + } + + const getCallLink = get('/v1/call-link/', async (req, res) => { + const roomId = req.headers['x-room-id']; + if (typeof roomId !== 'string') { + return send(res, 400, { error: 'Missing room ID' }); + } + + const callLink = await server.getCallLink(roomId); + if (!callLink) { + return send(res, 404, { error: 'Call link not found' }); + } + + return toCallLinkResponse(callLink); + }); + + const createOrUpdateCallLink = put('/v1/call-link', async (req, res) => { + const roomId = req.headers['x-room-id']; + if (typeof roomId !== 'string') { + return send(res, 400, { error: 'Missing room ID' }); + } + + const body: unknown = await json(req); + + let callLink: CallLinkEntry; + if (!server.hasCallLink(roomId)) { + const createParams = CreateCallLinkSchema.parse(body); + callLink = await server.createCallLink(roomId, createParams); + } else { + const updateParams = UpdateCallLinkSchema.parse(body); + callLink = await server.updateCallLink(roomId, updateParams); + } + + return toCallLinkResponse(callLink); + }); + + const deleteCallLink = del('/v1/call-link', async (req, res) => { + const roomId = req.headers['x-room-id']; + if (typeof roomId !== 'string') { + return send(res, 400, { error: 'Missing room ID' }); + } + const deleteParams = DeleteCallLinkSchema.parse(await json(req)); + await server.deleteCallLink(roomId, deleteParams); + return {}; + }); + + // + // Authorized requests + // + + type GroupAuthResult = Readonly<{ + publicParams: Buffer; + aciCiphertext: UuidCiphertext; + pniCiphertext: UuidCiphertext; + }>; + + async function groupAuth( + req: ServerRequest, + res: ServerResponse, + ): Promise { + const { error, username, password } = parsePassword(req); + + if (error) { + void send(res, 400, { error }); + return undefined; + } + if (!username || !password) { + void send(res, 400, { error: 'Invalid authorization header' }); + return undefined; + } + + const publicParams = Buffer.from(username, 'hex'); + const credential = Buffer.from(password, 'hex'); + + let aciCiphertext: UuidCiphertext; + let pniCiphertext: UuidCiphertext; + try { + const auth = await server.verifyGroupCredentials( + publicParams, + credential, + ); + + aciCiphertext = auth.getUuidCiphertext(); + const maybePni = auth.getPniCiphertext(); + assert(maybePni, 'Auth credentials must have PNI'); + pniCiphertext = maybePni; + } catch { + void send(res, 403, { error: 'Invalid credentials' }); + return undefined; + } + + return { publicParams, aciCiphertext, pniCiphertext }; + } + + type GroupAuthAndFetchResult = Readonly<{ + group: ServerGroup; + aciCiphertext: UuidCiphertext; + pniCiphertext: UuidCiphertext; + }>; + + async function groupAuthAndFetch( + req: ServerRequest, + res: ServerResponse, + ): Promise { + const auth = await groupAuth(req, res); + if (!auth) { + return; + } + + const group = await server.getGroup(auth.publicParams); + if (!group) { + void send(res, 404, { error: 'Group not found' }); + return undefined; + } + + return { group, ...auth }; + } + + async function storageAuth( + req: ServerRequest, + res: ServerResponse, + ): Promise { + const { error, username, password } = parsePassword(req); + + if (error) { + void send(res, 400, { error }); + return undefined; + } + if (!username || !password) { + void send(res, 400, { error: 'Invalid authorization header' }); + return undefined; + } + + const device = await server.storageAuth(username, password); + if (!device) { + debug('%s %s storage auth failed', req.method, req.url); + void send(res, 403, { error: 'Invalid authorization' }); + return undefined; + } + + return device; + } + + // + // GV2 + // + + const getGroup = get('/v2/groups', async (req, res) => { + const auth = await groupAuthAndFetch(req, res); + if (!auth) { + return; + } + const { group } = auth; + const groupSendEndorsementsResponse = group.getGroupSendEndorsementResponse( + auth.aciCiphertext, + ); + return send( + res, + 200, + Proto.GroupResponse.encode({ + group: group.state, + groupSendEndorsementsResponse, + }), + ); + }); + + const getGroupVersion = get( + '/v2/groups/joined_at_version', + async (req, res) => { + const auth = await groupAuthAndFetch(req, res); + if (!auth) { + return; + } + + const { group, aciCiphertext } = auth; + + const member = group.getMember(aciCiphertext); + + if (!member) { + return send(res, 403, { error: 'Not a member of this group' }); + } + + return send( + res, + 200, + Proto.Member.encode({ + userId: null, + role: null, + profileKey: null, + presentation: null, + joinedAtVersion: member.joinedAtVersion, + labelEmoji: null, + labelString: null, + }), + ); + }, + ); + + const SECONDS_IN_SIX_HOURS = 6 * 60 * 60; + + async function getGroupLogsInner( + req: ServerRequest, + res: ServerResponse, + ): Promise<{ + auth: GroupAuthAndFetchResult; + groupChanges: Proto.GroupChanges.Params; + } | void> { + const auth = await groupAuthAndFetch(req, res); + if (!auth) { + return; + } + + const { group, aciCiphertext } = auth; + const member = group.getMember(aciCiphertext); + if (!member) { + return send(res, 403, { error: 'Not a member of this group' }); + } + + assert(req.params.since != null, 'Missing since param'); + const since = parseInt(req.params.since, 10); + if (since < (member.joinedAtVersion ?? 0)) { + return send(res, 403, { error: '`since` is before joinedAtVersion' }); + } + + return { + auth, + groupChanges: group.getChangesSince(since), + }; + } + + const getGroupLogs = get('/v2/groups/logs/:since', async (req, res) => { + const result = await getGroupLogsInner(req, res); + if (!result) { + return; + } + + const { + groupChanges: { groupChanges }, + auth, + } = result; + const { group } = auth; + + const expirationResult = PositiveInt.safeParse( + req.headers['cached-send-endorsements'], + ); + + if (!expirationResult.success) { + return send(res, 400); + } + + const expirationTime = expirationResult.data; + const currentTime = Math.floor(Date.now() / 1000); + const expiresInLessThanSixHours = + expirationTime < currentTime + SECONDS_IN_SIX_HOURS; + + const membershipChange = groupChanges?.find((change) => { + const encodedActions = change.groupChange?.actions; + if (!encodedActions) { + return false; + } + const actions = Proto.GroupChange.Actions.decode(encodedActions); + return ( + actions.addMembers.length > 0 || + actions.deleteMembers.length > 0 || + actions.promoteMembersPendingPniAciProfileKey.length > 0 || + actions.promoteMembersPendingProfileKey.length > 0 + ); + }); + + let groupSendEndorsementsResponse: Uint8Array | null = null; + + if (membershipChange || expiresInLessThanSixHours) { + groupSendEndorsementsResponse = group.getGroupSendEndorsementResponse( + auth.aciCiphertext, + ); + } + + return send( + res, + 200, + Proto.GroupChanges.encode({ + groupChanges, + groupSendEndorsementsResponse, + }), + ); + }); + + async function createGroupInner( + req: ServerRequest, + res: ServerResponse, + ): Promise<{ auth: GroupAuthResult; group: ServerGroup } | void> { + const auth = await groupAuth(req, res); + if (!auth) { + return; + } + + const groupData = Proto.Group.decode(Buffer.from(await buffer(req))); + if (!groupData.title.length) { + return send(res, 400, { error: 'Missing group title' }); + } + if ( + !groupData.publicKey.length || + !auth.publicParams.equals(groupData.publicKey) + ) { + return send(res, 400, { error: 'Invalid group public key' }); + } + + const group = await server.createGroup(groupData); + + // TODO(indutny): verify that creator is a member + + return { auth, group }; + } + + const createGroup = put('/v2/groups', async (req, res) => { + const result = await createGroupInner(req, res); + if (!result) { + return; + } + const { group, auth } = result; + return send( + res, + 200, + Proto.GroupResponse.encode({ + group: group.state, + groupSendEndorsementsResponse: group.getGroupSendEndorsementResponse( + auth.aciCiphertext, + ), + }), + ); + }); + + async function modifyGroupInner( + req: ServerRequest, + res: ServerResponse, + ): Promise<{ + auth: GroupAuthAndFetchResult; + signedChange: Proto.GroupChange.Params; + } | void> { + const auth = await groupAuthAndFetch(req, res); + if (!auth) { + return; + } + + const actions = Proto.GroupChange.Actions.decode( + Buffer.from(await buffer(req)), + ); + + if (actions.groupId.length) { + return send(res, 400, { error: 'Bad Request' }); + } + + const { group, aciCiphertext, pniCiphertext } = auth; + + try { + const modifyResult = await server.modifyGroup({ + group, + aciCiphertext: aciCiphertext.serialize(), + pniCiphertext: pniCiphertext.serialize(), + actions, + }); + + if (modifyResult.conflict) { + await send(res, 409, { error: 'Conflict' }); + return; + } + + return { + auth, + signedChange: modifyResult.signedChange, + }; + } catch (error) { + assert(error instanceof Error); + + debug('Failed to modify group', error.stack); + + // TODO(indutny): would be nice to give 403 here + return send(res, 500, { error: error.stack }); + } + } + + const modifyGroup = patch('/v2/groups', async (req, res) => { + const result = await modifyGroupInner(req, res); + if (!result) { + return; + } + const { signedChange, auth } = result; + const { group, aciCiphertext } = auth; + return send( + res, + 200, + Proto.GroupChangeResponse.encode({ + groupChange: signedChange, + groupSendEndorsementsResponse: + group.getGroupSendEndorsementResponse(aciCiphertext), + }), + ); + }); + + // + // Storage Service + // + + const getGroupToken = get('/v2/groups/token', async (req, res) => { + const auth = await groupAuthAndFetch(req, res); + if (!auth) { + return; + } + + const { group } = auth; + + const member = group.getMember(auth.aciCiphertext); + if (member == null) { + return send(res, 403, { error: 'Not a member of this group' }); + } + + assert(member.userId != null, 'Missing member.userId'); + + const token = generateCallingAuthToken({ + userId: member.userId, + groupId: group.id, + isAllowedToInitiateGroupCall: true, + key: CALLING_SERVICE_SECRET, + }); + + return send(res, 200, Proto.ExternalGroupCredential.encode({ token })); + }); + + const getStorageManifest = get('/v1/storage/manifest', async (req, res) => { + const device = await storageAuth(req, res); + if (!device) { + return; + } + + const manifest = await server.getStorageManifest(device); + if (!manifest) { + return send(res, 404, { error: 'Manifest not found' }); + } + + return send(res, 200, Proto.StorageManifest.encode(manifest)); + }); + + const getStorageManifestByVersion = get( + '/v1/storage/manifest/version/:after', + async (req, res) => { + const device = await storageAuth(req, res); + if (!device) { + return; + } + + assert(req.params.after != null, 'Missing after param'); + const after = BigInt(req.params.after); + const manifest = await server.getStorageManifest(device); + if (manifest === undefined) { + return send(res, 404); + } + if (!manifest.version || manifest.version <= after) { + return send(res, 204); + } + + return send(res, 200, Proto.StorageManifest.encode(manifest)); + }, + ); + + const putStorage = put('/v1/storage/', async (req, res) => { + const device = await storageAuth(req, res); + if (!device) { + return; + } + + const writeOperation = Proto.WriteOperation.decode( + Buffer.from(await buffer(req)), + ); + + const result = await server.applyStorageWrite(device, writeOperation); + if ('error' in result) { + return send(res, 400, { error: result.error }); + } + + if (!result.updated) { + return send(res, 409, Proto.StorageManifest.encode(result.manifest)); + } + + return send(res, 200); + }); + + const putStorageRead = put('/v1/storage/read', async (req, res) => { + const device = await storageAuth(req, res); + if (!device) { + return; + } + + const readOperation = Proto.ReadOperation.decode( + Buffer.from(await buffer(req)), + ); + + const keys = readOperation.readKey.map((key) => Buffer.from(key)); + + const items = await server.getStorageItems(device, keys); + if (!items) { + return send(res, 413, { error: 'Requested too many items' }); + } + + return send( + res, + 200, + Proto.StorageItems.encode({ + items, + }), + ); + }); + + const notFoundAfterAuth: RouteHandler = async (req, res) => { + const device = await auth(server, req, res); + if (!device) { + return; + } + + debug('Unsupported request %s %s', req.method, req.url); + return send(res, 404, { error: 'Not supported yet' }); + }; + + const routes = router( + getAttachment, + getStickerPack, + getSticker, + + // Technically these should live on a separate server, but who cares + withNamespace('/storageService')( + // All storage service routes have the X-Signal-Timestamp header + ...ALL_METHODS.map((method) => + method('/*', (_req, res) => { + res.setHeader('X-Signal-Timestamp', Date.now()); + }), + ), + getGroup, + getGroupVersion, + getGroupLogs, + createGroup, + modifyGroup, + + getGroupToken, + + getStorageManifest, + getStorageManifestByVersion, + putStorage, + putStorageRead, + ), + + withNamespace('/callingService')( + getConferenceParticipants, + joinConferenceParticipants, + getCallLink, + createOrUpdateCallLink, + deleteCallLink, + ), + + ...[head, patch, post].map((method) => + method('/cdn3/*', async (req, res) => { + await tusServer.handle(req, res); + }), + ), + + getCdn3Attachment, + getResourcesAttachment, + headResourcesAttachment, + + get('/stickers/', notFound), + ...ALL_METHODS.map((method) => method('/*', notFoundAfterAuth)), + ); + + return (req, res) => { + debug('got request %s %s', req.method, req.url); + try { + res.once('finish', () => { + debug('response %s %s', req.method, req.url, res.statusCode); + }); + return routes(req, res); + } catch (error) { + assert(error instanceof Error); + debug('request failure %s %s', req.method, req.url, error.stack); + return send(res, 500, error.message); + } + }; +}; diff --git a/packages/mock-server/src/server/ws/connection.ts b/packages/mock-server/src/server/ws/connection.ts new file mode 100644 index 0000000000..9bae5da2ee --- /dev/null +++ b/packages/mock-server/src/server/ws/connection.ts @@ -0,0 +1,1304 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import assert from 'assert'; +import { Buffer } from 'buffer'; +import { Http2ServerRequest } from 'http2'; +import { timingSafeEqual } from 'crypto'; +import createDebug from 'debug'; +import { + CreateCallLinkCredentialRequest, + ProfileKeyCredentialRequest, +} from '@signalapp/libsignal-client/zkgroup'; +import { stringify as stringifyUuid, v4 as uuidv4 } from 'uuid'; +import { KEMPublicKey, PublicKey } from '@signalapp/libsignal-client'; + +import WebSocket from 'ws'; + +import { signalservice as Proto } from '../../../protos/compiled'; +import { Device } from '../../data/device'; +import { + AtomicLinkingDataSchema, + BackupHeadersSchema, + BackupMediaBatchSchema, + CreateCallLinkAuthSchema, + CreateVerificationSessionSchema, + DeviceKeysSchema, + MessageListSchema, + ModifyVerificationSessionSchema, + RegisterAccountResponse, + RegisterAccountSchema, + RequestVerificationCodeSchema, + SetBackupIdSchema, + SubmitVerificationCodeSchema, + UpdateProfileSchema, + UploadProfileResponse, + UsernameConfirmationSchema, + VerificationSession, +} from '../../data/schemas'; +import { + DeviceId, + ProvisionIdString, + ProvisioningCode, + ServiceIdKind, + ServiceIdString, + untagPni, +} from '../../types'; +import { + decodeKyberPreKey, + decodePreKey, + decodeSignedPreKey, + generateAccessKeyVerifier, + hashRemoteConfig, +} from '../../crypto'; +import { Server } from '../base'; +import { + booleanFromQuery, + getDevicesKeysResult, + parseAuthHeader, + serviceIdKindFromQuery, + toBase64, +} from '../../util'; + +import { Service, WSRequest, WSResponse } from './service'; +import { Handler, Router } from './router'; + +const debug = createDebug('mock:ws:connection'); + +export class Connection extends Service { + private device: Device | undefined; + private readonly router = new Router({ + beforeRequest: (verb, path, headers) => { + return this.handleAuth(verb, path, headers); + }, + }); + + constructor( + private readonly request: Http2ServerRequest, + ws: WebSocket, + private readonly server: Server, + ) { + super(ws); + + const getProfile: Handler = async ( + params, + _, + headers, + { credentialType } = {}, + ) => { + const serviceId = params.serviceId as ServiceIdString; + + const target = await this.server.getDeviceByServiceId(serviceId); + if (!target) { + return [404, { error: 'Device not found' }]; + } + + if (this.server.isUnregistered(serviceId)) { + return [404, { error: 'Unregistered' }]; + } + + const accessError = this.checkAccessKey(target, headers); + if (accessError !== undefined) { + return [401, { error: accessError }]; + } + + let credential: Buffer | undefined; + if (params.request) { + const request = new ProfileKeyCredentialRequest( + Buffer.from(params.request, 'hex'), + ); + if (credentialType === 'expiringProfileKey') { + credential = await this.server.issueExpiringProfileKeyCredential( + target, + request, + ); + } else { + return [400, { error: 'Unsupported credential type' }]; + } + } + + const serviceIdKind = target.getServiceIdKind(serviceId); + const identityKey = await target.getIdentityKey(serviceIdKind); + + return [ + 200, + { + name: target.profileName?.toString('base64'), + identityKey: Buffer.from(identityKey.serialize()).toString('base64'), + unrestrictedUnidentifiedAccess: false, + unidentifiedAccess: target.accessKey + ? generateAccessKeyVerifier(target.accessKey).toString('base64') + : undefined, + capabilities: target.capabilities, + credential: credential?.toString('base64'), + }, + ]; + }; + this.router.get('/v1/profile/:serviceId', getProfile); + this.router.get('/v1/profile/:serviceId/:version', getProfile); + this.router.get('/v1/profile/:serviceId/:version/:request', getProfile); + + const requireAuth = (handler: Handler): Handler => { + return async (params, body, headers, query) => { + if (!this.device) { + return [401, { error: 'Not authorized' }]; + } + + return handler(params, body, headers, query); + }; + }; + + this.router.put( + '/v1/profile', + requireAuth(async (_params, body) => { + if (!body) { + return [400, { error: 'Missing body' }]; + } + + const parsedResult = UpdateProfileSchema.safeParse( + JSON.parse(Buffer.from(body).toString()), + ); + if (parsedResult.error) { + debug('/v1/profile malformed body', parsedResult.error.message); + + return [400, { error: 'body is malformed' }]; + } + + const primaryDevice = this.device; + if (!primaryDevice) { + return [400, { error: 'missing device!' }]; + } + + const { data } = parsedResult; + const { name } = data; + + primaryDevice.profileName = name + ? Buffer.from(name, 'base64') + : undefined; + // Note: The other fields on UpdateProfileSchema are currently not saved on device + + const result: UploadProfileResponse = 'ok'; + return [200, result]; + }), + ); + + this.router.get( + '/v1/config', + requireAuth(async () => { + return [ + 200, + { + config: [...this.server.getRemoteConfig().entries()].map( + ([key, value]) => { + return { name: key, ...value }; + }, + ), + serverEpochTime: Date.now() / 1000, + }, + ] as const; + }), + ); + + this.router.get( + '/v2/config', + requireAuth(async (_params, _body, headers) => { + const enabledEntries = [...this.server.getRemoteConfig().entries()] + .filter((entry) => entry[1].enabled) + .map(([name, { value }]) => [name, value ?? 'true'] as const); + // Sort by name then value. + enabledEntries.sort(([n1, v1], [n2, v2]) => { + if (n1 === n2) { + return v1 < v2 ? -1 : v1 > v2 ? 1 : 0; + } + return n1 < n2 ? -1 : 1; + }); + const hash = hashRemoteConfig(enabledEntries).toString('hex'); + + const replyHeaders = { etag: hash }; + + if (headers['if-none-match'] === hash) { + return [304, '', replyHeaders]; + } + + return [ + 200, + { + config: Object.fromEntries(enabledEntries), + }, + replyHeaders, + ] as const; + }), + ); + + this.router.put( + '/v1/messages/:serviceId', + async (params, body, headers, query = {}) => { + if (!body) { + return [400, { error: 'Missing body' }]; + } + + const { messages, timestamp } = MessageListSchema.parse( + JSON.parse(Buffer.from(body).toString()), + ); + + const targetServiceId = params.serviceId as ServiceIdString; + const target = await this.server.getDeviceByServiceId(targetServiceId); + if (!target) { + return [404, { error: 'Device not found' }]; + } + + if (query.story !== 'true') { + const accessError = this.checkAccessKey(target, headers); + if (accessError !== undefined) { + return [401, { error: accessError }]; + } + } + + if (this.server.isUnregistered(targetServiceId)) { + return [404, { error: 'Unregistered' }]; + } + + if ( + this.device && + this.server.isSendRateLimited({ + source: this.device.aci, + target: targetServiceId, + }) + ) { + return [428, { token: 'token', options: ['captcha'] }]; + } + + const prepared = await this.server.prepareMultiDeviceMessage( + this.device, + params.serviceId as ServiceIdString, + messages, + BigInt(timestamp), + ); + + switch (prepared.status) { + case 'ok': + await this.server.handlePreparedMultiDeviceMessage( + this.device, + prepared.targetServiceId, + prepared.result, + ); + return [200, { ok: true }]; + case 'unknown': + return [404, { error: 'Not found' }]; + case 'incomplete': + return [ + 409, + { + missingDevices: prepared.missingDevices, + extraDevices: prepared.extraDevices, + }, + ]; + case 'stale': + return [410, { staleDevices: prepared.staleDevices }]; + } + }, + ); + + this.router.put( + '/v1/devices/capabilities', + requireAuth(async () => { + return [200, { ok: true }]; + }), + ); + + this.router.put( + '/v1/devices/unauthenticated_delivery', + requireAuth(async () => { + return [200, { ok: true }]; + }), + ); + + this.router.get( + '/v1/certificate/delivery', + requireAuth(async (_params, _rawBody, _headers, query) => { + const certificate = await this.server.getSenderCertificate( + this.getDevice(), + { includeE164: booleanFromQuery(query?.includeE164, true) }, + ); + + return [ + 200, + { + certificate: Buffer.from(certificate.serialize()).toString( + 'base64', + ), + }, + ]; + }), + ); + + this.router.put( + '/v2/keys', + requireAuth(async (_params, rawBody, _headers, query) => { + if (!rawBody) { + return [422, { error: 'Missing body' }]; + } + + const serviceIdKind = serviceIdKindFromQuery(query); + + const body = DeviceKeysSchema.parse(JSON.parse(rawBody.toString())); + try { + await server.updateDeviceKeys(this.getDevice(), serviceIdKind, { + preKeys: body.preKeys.map(decodePreKey), + kyberPreKeys: body.pqPreKeys?.map(decodeKyberPreKey), + lastResortKey: body.pqLastResortPreKey + ? decodeKyberPreKey(body.pqLastResortPreKey) + : undefined, + signedPreKey: body.signedPreKey + ? decodeSignedPreKey(body.signedPreKey) + : undefined, + }); + } catch (error) { + assert(error instanceof Error); + debug('updateDeviceKeys error', error.stack); + return [400, { error: error.message }]; + } + + return [200, { ok: true }]; + }), + ); + + this.router.get( + '/v2/keys', + requireAuth(async (_params, _rawBody, _headers, query) => { + const device = this.getDevice(); + const serviceIdKind = serviceIdKindFromQuery(query); + + return [ + 200, + { + count: await device.getPreKeyCount(serviceIdKind), + pqCount: await device.getKyberPreKeyCount(serviceIdKind), + }, + ]; + }), + ); + + this.router.get('/v2/keys/:serviceId/:deviceId', async (params) => { + const serviceId = params.serviceId as ServiceIdString | undefined; + const deviceId = parseInt(params.deviceId ?? '', 10) as DeviceId; + if (!serviceId || deviceId.toString() !== params.deviceId) { + return [400, { error: 'Invalid request parameters' }]; + } + + const device = await server.getDeviceByServiceId(serviceId, deviceId); + if (!device) { + return [404, { error: 'Device not found' }]; + } + + const serviceIdKind = device.getServiceIdKind(serviceId); + return [200, await getDevicesKeysResult(serviceIdKind, [device])]; + }); + + this.router.get('/v2/keys/:serviceId(/\\*)', async (params) => { + const serviceId = params.serviceId as ServiceIdString | undefined; + if (!serviceId) { + return [400, { error: 'Invalid request parameters' }]; + } + + const devices = await server.getAllDevicesByServiceId(serviceId); + if (devices.length === 0) { + return [404, { error: 'Account not found' }]; + } + + const device = devices[0]; + assert(device != null, `Missing first device for serviceId ${serviceId}`); + const serviceIdKind = device.getServiceIdKind(serviceId); + return [200, await getDevicesKeysResult(serviceIdKind, devices)]; + }); + + this.router.put('/v1/devices/link', async (_params, body, headers) => { + const { error, username, password } = parseAuthHeader( + headers.authorization, + ); + if (error) { + return [400, { error }]; + } + if (!username || !password) { + return [400, { error: 'Invalid authorization header' }]; + } + if (!body) { + return [400, { error: 'Missing body' }]; + } + + const { + verificationCode, + accountAttributes, + aciSignedPreKey, + pniSignedPreKey, + aciPqLastResortPreKey, + pniPqLastResortPreKey, + } = AtomicLinkingDataSchema.parse( + JSON.parse(Buffer.from(body).toString()), + ); + + const { registrationId, pniRegistrationId } = accountAttributes; + + const device = await server.provisionDevice({ + number: username, + password, + provisioningCode: verificationCode as ProvisioningCode, + registrationId, + pniRegistrationId, + }); + + const primary = await server.getDeviceByServiceId(device.aci); + if (!primary) { + throw new Error('Primary device not found'); + } + + await server.updateDeviceKeys(device, ServiceIdKind.ACI, { + lastResortKey: decodeKyberPreKey(aciPqLastResortPreKey), + signedPreKey: decodeSignedPreKey(aciSignedPreKey), + }); + await server.updateDeviceKeys(device, ServiceIdKind.PNI, { + lastResortKey: decodeKyberPreKey(pniPqLastResortPreKey), + signedPreKey: decodeSignedPreKey(pniSignedPreKey), + }); + + return [ + 200, + { + deviceId: device.deviceId, + uuid: device.aci, + pni: untagPni(device.pni), + }, + ]; + }); + + this.router.get( + '/v1/devices/transfer_archive', + requireAuth(async () => { + return [200, await server.getTransferArchive(this.getDevice())]; + }), + ); + + // + // Verification and Account Create + // + + this.router.post('/v1/verification/session', async (_params, body) => { + if (!body) { + debug('missing body'); + return [400, { error: 'missing body' }]; + } + + const parsedResult = CreateVerificationSessionSchema.safeParse( + JSON.parse(Buffer.from(body).toString()), + ); + if (parsedResult.error) { + debug( + '/v1/verification/session malformed body', + parsedResult.error.message, + ); + return [400, { error: 'malformed body' }]; + } + + const { data } = parsedResult; + + const session: VerificationSession = { + id: uuidv4(), + nextSms: 60, + nextCall: 60, + nextVerificationAttempt: null, + allowedToRequestCode: false, + requestedInformation: ['captcha'], + verified: false, + }; + this.server.saveVerificationSession({ + number: data.number, + session, + }); + + return [200, session]; + }); + + this.router.get('/v1/verification/session/:sessionId', async (params) => { + const { sessionId } = params; + if (!sessionId) { + return [400, { error: 'sessionId parameter is missing' }]; + } + + const storage = this.server.getVerificationSession(sessionId); + if (!storage) { + return [404, { error: `No session found with sessionId ${sessionId}` }]; + } + + return [200, storage.session]; + }); + + this.router.patch( + '/v1/verification/session/:sessionId', + async (params, body) => { + if (!body) { + return [400, { error: 'missing body' }]; + } + + const { sessionId } = params; + if (!sessionId) { + return [400, { error: 'sessionId parameter is missing' }]; + } + + const storage = this.server.getVerificationSession(sessionId); + if (!storage) { + return [ + 404, + { error: `No session found with sessionId ${sessionId}` }, + ]; + } + + const parsedResult = ModifyVerificationSessionSchema.safeParse( + JSON.parse(Buffer.from(body).toString()), + ); + if (parsedResult.error) { + debug( + '/v1/verification/session/:sessionId malformed body', + parsedResult.error.message, + ); + return [400, { error: 'malformed body' }]; + } + + const { data } = parsedResult; + const { session } = storage; + if (data.captcha) { + session.allowedToRequestCode = true; + } + + this.server.saveVerificationSession({ ...storage, session }); + + return [200, session]; + }, + ); + + this.router.put( + '/v1/verification/session/:sessionId/code', + async (params, body) => { + if (!body) { + return [400, { error: 'missing body' }]; + } + + const { sessionId } = params; + if (!sessionId) { + return [400, { error: 'sessionId parameter is missing' }]; + } + + const storage = this.server.getVerificationSession(sessionId); + if (!storage) { + return [ + 404, + { error: `No session found with sessionId ${sessionId}` }, + ]; + } + + const parsedResult = SubmitVerificationCodeSchema.safeParse( + JSON.parse(Buffer.from(body).toString()), + ); + if (parsedResult.error) { + debug( + '/v1/verification/session/:sessionId/code malformed body', + parsedResult.error.message, + ); + return [400, { error: 'malformed body' }]; + } + + if (storage.lastRequestedCode !== parsedResult.data.code) { + const { session } = storage; + session.verified = false; + + this.server.saveVerificationSession(storage); + + return [200, session]; + } + + storage.lastRequestedCode = undefined; + storage.lastRequestedTransport = undefined; + + const { session } = storage; + session.verified = true; + + this.server.saveVerificationSession(storage); + + return [200, session]; + }, + ); + + this.router.post( + '/v1/verification/session/:sessionId/code', + async (params, body) => { + if (!body) { + return [400, { error: 'missing body' }]; + } + + const { sessionId } = params; + if (!sessionId) { + return [400, { error: 'sessionId parameter is missing' }]; + } + + const storage = this.server.getVerificationSession(sessionId); + if (!storage) { + return [ + 404, + { error: `No session found with sessionId ${sessionId}` }, + ]; + } + + const parsedResult = RequestVerificationCodeSchema.safeParse( + JSON.parse(Buffer.from(body).toString()), + ); + if (parsedResult.error) { + debug( + '/v1/verification/session/:sessionId/code malformed body', + parsedResult.error.message, + ); + return [400, { error: 'malformed body' }]; + } + + const { data } = parsedResult; + storage.lastRequestedCode = '111111'; + storage.lastRequestedTransport = data.transport; + + const { session } = storage; + session.nextCall = 60; + session.nextSms = 60; + + this.server.saveVerificationSession(storage); + + return [200, session]; + }, + ); + + this.router.post('/v1/registration', async (_params, body, headers) => { + const { error, password } = parseAuthHeader(headers.authorization); + + if (error) { + return [400, { error }]; + } + if (!password) { + return [400, { error: 'password not provided' }]; + } + + if (!body) { + return [400, { error: 'missing body' }]; + } + + const parsedResult = RegisterAccountSchema.safeParse( + JSON.parse(Buffer.from(body).toString()), + ); + if (parsedResult.error) { + debug('/v1/registration malformed body', parsedResult.error.message); + return [400, { error: 'malformed body' }]; + } + + const { data } = parsedResult; + const { accountAttributes, sessionId } = data; + const { pniRegistrationId, registrationId } = accountAttributes; + + const storage = this.server.getVerificationSession(sessionId); + if (!storage) { + return [404, { error: `No session found with sessionId ${sessionId}` }]; + } + + const { session } = storage; + if (!session.verified) { + return [400, { error: 'session is not verified' }]; + } + + const hardcodedError = this.server.getRegisterResponseError(); + if (hardcodedError) { + return [hardcodedError.code, hardcodedError.data]; + } + + const { number } = storage; + + const provisionId = await server.generateProvisionId(); + const primaryDevice = await server.registerDevice({ + provisionId, + number, + password, + pniRegistrationId, + registrationId, + }); + + const { + aciSignedPreKey, + aciPqLastResortPreKey, + aciIdentityKey, + pniSignedPreKey, + pniPqLastResortPreKey, + pniIdentityKey, + } = data; + + await primaryDevice.setKeys(ServiceIdKind.ACI, { + identityKey: PublicKey.deserialize( + Buffer.from(aciIdentityKey, 'base64'), + ), + signedPreKey: { + keyId: aciSignedPreKey.keyId, + publicKey: PublicKey.deserialize( + Buffer.from(aciSignedPreKey.publicKey, 'base64'), + ), + signature: Buffer.from(aciSignedPreKey.signature, 'base64'), + }, + lastResortKey: { + keyId: aciPqLastResortPreKey.keyId, + publicKey: KEMPublicKey.deserialize( + Buffer.from(aciPqLastResortPreKey.publicKey, 'base64'), + ), + signature: Buffer.from(aciPqLastResortPreKey.signature, 'base64'), + }, + }); + + await primaryDevice.setKeys(ServiceIdKind.PNI, { + identityKey: PublicKey.deserialize( + Buffer.from(pniIdentityKey, 'base64'), + ), + signedPreKey: { + keyId: pniSignedPreKey.keyId, + publicKey: PublicKey.deserialize( + Buffer.from(pniSignedPreKey.publicKey, 'base64'), + ), + signature: Buffer.from(pniSignedPreKey.signature, 'base64'), + }, + lastResortKey: { + keyId: pniPqLastResortPreKey.keyId, + publicKey: KEMPublicKey.deserialize( + Buffer.from(pniPqLastResortPreKey.publicKey, 'base64'), + ), + signature: Buffer.from(pniPqLastResortPreKey.signature, 'base64'), + }, + }); + + const mixinData = this.server.getRegisterResponseData(); + + const result: RegisterAccountResponse = { + uuid: primaryDevice.aci.toString(), + number, + pni: primaryDevice.pni.toString().replace(/^PNI:/i, ''), + storageCapable: false, + entitlements: { + badges: [], + }, + reregistration: false, + ...mixinData, + }; + + return [200, result]; + }); + + // + // Groups + // + + this.router.get( + '/v1/certificate/auth/group', + async (_params, _body, _headers, query = {}) => { + const device = this.device; + if (!device) { + debug( + '/v1/certificate/auth/group: No support for unauthorized delivery', + ); + return [401, { error: 'Not authorized' }]; + } + + const { redemptionStartSeconds: from, redemptionEndSeconds: to } = + query; + + return [ + 200, + { + credentials: await this.server.getGroupCredentials(device, { + from: parseInt(from as string, 10), + to: parseInt(to as string, 10), + }), + callLinkAuthCredentials: + await this.server.getCallLinkAuthCredentials(device, { + from: parseInt(from as string, 10), + to: parseInt(to as string, 10), + }), + pni: untagPni(device.pni), + }, + ]; + }, + ); + + // + // Storage Service + // + + this.router.get( + '/v1/storage/auth', + requireAuth(async () => { + return [200, await server.getStorageAuth(this.getDevice())]; + }), + ); + + // + // Backups + // + + this.router.get( + '/v2/backup/auth', + requireAuth(async () => { + return [200, this.server.getBackupAuth()]; + }), + ); + + this.router.put( + '/v1/archives/backupid', + requireAuth(async (_params, body) => { + if (!body) { + return [400, { error: 'Missing body' }]; + } + + const backupId = SetBackupIdSchema.parse(JSON.parse(body.toString())); + await server.setBackupId(this.getDevice(), backupId); + return [200, { ok: true }]; + }), + ); + + this.router.get( + '/v1/archives/auth', + requireAuth(async (_params, _body, _headers, query = {}) => { + const { redemptionStartSeconds: from, redemptionEndSeconds: to } = + query; + + const credentials = await this.server.getBackupCredentials( + this.getDevice(), + { + from: parseInt(from as string, 10), + to: parseInt(to as string, 10), + }, + ); + if (credentials === undefined) { + return [404, { error: 'backup id not set' }]; + } + + return [ + 200, + { + credentials, + }, + ]; + }), + ); + + this.router.get('/v1/archives', async (_params, _body, headers) => { + if (this.device) { + return [400, { error: 'Extraneous authentication' }]; + } + + return [ + 200, + await server.getBackupInfo(BackupHeadersSchema.parse(headers)), + ]; + }); + + this.router.get( + '/v1/archives/upload/form', + async (_params, _body, headers) => { + if (this.device) { + return [400, { error: 'Extraneous authentication' }]; + } + + return [ + 200, + await this.server.getBackupUploadForm( + BackupHeadersSchema.parse(headers), + ), + ]; + }, + ); + + this.router.get( + '/v1/archives/media', + async (_params, _body, headers, query = {}) => { + if (this.device) { + return [400, { error: 'Extraneous authentication' }]; + } + + if (typeof query.limit !== 'string') { + return [400, { error: 'Missing limit param' }]; + } + + const limit = parseInt(query.limit, 10); + if (limit <= 0) { + return [400, { error: 'Invalid limit' }]; + } + + const cursor = query.cursor; + + return [ + 200, + await this.server.listBackupMedia( + BackupHeadersSchema.parse(headers), + { cursor: cursor != null ? String(cursor) : undefined, limit }, + ), + ]; + }, + ); + + this.router.get( + '/v1/archives/media/upload/form', + async (_params, _body, headers) => { + if (this.device) { + return [400, { error: 'Extraneous authentication' }]; + } + + return [ + 200, + await this.server.getBackupMediaUploadForm( + BackupHeadersSchema.parse(headers), + ), + ]; + }, + ); + + this.router.put( + '/v1/archives/media/batch', + async (_params, body, headers) => { + if (this.device) { + return [400, { error: 'Extraneous authentication' }]; + } + + if (!body) { + return [400, { error: 'Missing body' }]; + } + + const batch = BackupMediaBatchSchema.parse(JSON.parse(body.toString())); + + return [ + 200, + await this.server.backupMediaBatch( + BackupHeadersSchema.parse(headers), + batch, + ), + ]; + }, + ); + + // + // Keepalive + // + + this.router.get('/v1/keepalive', async () => { + return [200, { ok: true }]; + }); + + // + // Accounts + // + + this.router.get( + '/v1/accounts/whoami', + requireAuth(async () => { + const device = this.getDevice(); + return [ + 200, + { + uuid: device.aci, + pni: untagPni(device.pni), + number: device.number, + }, + ]; + }), + ); + + // + // Call links + // + + this.router.post( + '/v1/call-link/create-auth', + requireAuth(async (_params, rawBody) => { + if (!rawBody) { + return [422, { error: 'Missing body' }]; + } + + const body = CreateCallLinkAuthSchema.parse( + JSON.parse(rawBody.toString()), + ); + const request = new CreateCallLinkCredentialRequest( + body.createCallLinkCredentialRequest, + ); + const response = await server.createCallLinkAuth( + this.getDevice(), + request, + ); + + return [ + 200, + { + redemptionTime: -Date.now(), + credential: toBase64(response.serialize()), + }, + ]; + }), + ); + + // + // Captcha + // + this.router.put( + '/v1/challenge', + requireAuth(async () => { + const response = server.getResponseForChallenges(); + if (response) { + return [response.code, response.data ?? {}]; + } + + return [200, { ok: true }]; + }), + ); + + this.router.get( + '/v2/calling/relays', + requireAuth(async () => [ + 200, + { + relays: [ + { + username: 'ignored', + password: 'ignored', + ttl: 43200, + urls: ['turn:localhost'], + urlsWithIps: ['turn:127.0.0.1'], + hostname: 'localhost', + }, + ], + }, + ]), + ); + + // Temporary endpoint until gRPC migration is completed + this.router.put( + '/v1/accounts/username_hash/confirm', + requireAuth(async (_params, rawBody) => { + if (!rawBody) { + return [422, { error: 'Missing body' }]; + } + + const { + usernameHash, + zkProof, + encryptedUsername: usernameCiphertext, + } = UsernameConfirmationSchema.parse(JSON.parse(rawBody.toString())); + + const result = await server.confirmUsername(this.getDevice().aci, { + usernameHash, + zkProof, + usernameCiphertext, + }); + + if (!result) { + return [ + 409, + { + error: + "Given username hash doesn't match the reserved one or no reservation found.", + }, + ]; + } + + return [ + 200, + { + usernameLinkHandle: stringifyUuid(result.usernameLinkHandle), + }, + ]; + }), + ); + } + + public async start(socket: WebSocket): Promise { + debug('Got a websocket connection', this.request.url); + const url = this.request.url; + if (!url) { + throw new Error('Request must have url'); + } + // Use a fixed string instead of constructing the URL from the HOST header + // since we don't actually care about anything but the path. + const path = new URL(url, 'http://localhost').pathname; + + if (path.startsWith('/v1/websocket/provisioning')) { + const id = await this.server.generateProvisionId(); + try { + await this.handleProvision(id, socket); + } catch (error) { + await this.server.releaseProvisionId(id); + throw error; + } + return; + } + + if (path === '/v1/websocket/') { + await this.handleAuthHeaders(this.request.headers); + return; + } + + debug('websocket connection has unexpected URL %s', url); + } + + public async sendMessage( + message: Buffer | 'empty', + ): Promise { + let response; + if (message === 'empty') { + response = await this.send('PUT', '/api/v1/queue/empty', {}); + } else { + response = await this.send('PUT', '/api/v1/message', { + body: message, + }); + } + + assert.strictEqual( + response.status, + 200, + `WebSocket send error ${response.status} ${response.message}`, + ); + } + + public close(code: number): void { + this.ws.close(code); + } + + // + // Service implementation + // + + protected async handleRequest(request: WSRequest): Promise { + return this.router.run(request); + } + + // + // Private + // + + private async handleProvision(id: ProvisionIdString, socket: WebSocket) { + { + const { status } = await this.send('PUT', '/v1/address', { + body: Proto.ProvisioningAddress.encode({ + address: id, + }), + }); + assert.strictEqual(status, 200); + } + + const controller = new AbortController(); + socket.on('close', () => { + debug('provision websocket closed; shutting down provisioning'); + controller.abort(); + }); + + { + const { envelope } = await this.server.getProvisioningResponse( + id, + controller.signal, + ); + const { status } = await this.send('PUT', '/v1/message', { + body: envelope, + }); + assert.strictEqual(status, 200); + } + } + + private async handleAuth( + verb: string, + path: string, + headers: Record, + ) { + // We are actively linking device + if (verb === 'PUT' && path === '/v1/devices/link') { + return; + } + // We are actively registering an account + if (verb === 'POST' && path === '/v1/registration') { + return; + } + + await this.handleAuthHeaders(headers); + } + + private async handleAuthHeaders( + headers: Record | undefined>, + ) { + const authHeaders = headers.authorization; + if (authHeaders === undefined) { + debug('Websocket connection does not include Authorization header'); + return; + } + if (Array.isArray(authHeaders)) { + debug('Websocket connection includes multiple Authorization headers'); + return; + } + const { error, username, password } = parseAuthHeader(authHeaders, { + allowEmptyPassword: true, + }); + + if (error || !username) { + debug( + 'Invalid Authorization header for websocket connection @ %s: %s', + error, + authHeaders, + ); + return; + } + + const device = await this.server.auth(username, password); + if (!device) { + debug('Invalid WebSocket credentials @ %j', { + username, + password, + }); + this.ws.close(3000); + return; + } + + if (this.device !== undefined) { + assert.strictEqual(this.device, device, 'Cannot change active device'); + return; + } + + this.device = device; + this.router.setIsAuthenticated(true); + + this.ws.once('close', () => { + this.server.removeWebSocket(device, this); + }); + + await this.server.addWebSocket(device, this); + } + + private getDevice(): Device { + assert(this.device); + return this.device; + } + + private checkAccessKey( + target: Device, + headers: Record, + ): string | undefined { + if (this.device) { + // Authenticated + } else if (headers['group-send-token']) { + // Unchecked + return undefined; + } else if (!target.accessKey || !headers['unidentified-access-key']) { + return 'Not authenticated'; + } else { + const accessKey = Buffer.from( + headers['unidentified-access-key'], + 'base64', + ); + if (!timingSafeEqual(accessKey, target.accessKey)) { + return 'Invalid access key'; + } + } + + return undefined; + } +} diff --git a/packages/mock-server/src/server/ws/index.ts b/packages/mock-server/src/server/ws/index.ts new file mode 100644 index 0000000000..23759071db --- /dev/null +++ b/packages/mock-server/src/server/ws/index.ts @@ -0,0 +1,4 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +export { Connection } from './connection'; diff --git a/packages/mock-server/src/server/ws/router.ts b/packages/mock-server/src/server/ws/router.ts new file mode 100644 index 0000000000..a94c4e2b5f --- /dev/null +++ b/packages/mock-server/src/server/ws/router.ts @@ -0,0 +1,162 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import createDebug from 'debug'; +import { parse as parseURL } from 'url'; +import { ParsedUrlQuery, parse as parseQS } from 'querystring'; + +import { WSRequest, WSResponse } from './service'; + +import { JsonValue, PartialDeep } from 'type-fest'; +import URLPattern from 'url-pattern'; +import { assertJsonValue } from '../../util'; +import assert from 'assert'; + +const debug = createDebug('mock:ws:router'); + +export type AbbreviatedResponse = Readonly< + | [number, PartialDeep] + | [number, PartialDeep, Record] +>; + +export type Handler = ( + params: Record, + body: Uint8Array | undefined, + headers: Record, + query?: ParsedUrlQuery, +) => Promise; + +type Route = Readonly<{ + method: string; + pattern: URLPattern; + handler: Handler; +}>; + +export type RouterOptions = Readonly<{ + beforeRequest: ( + verb: string, + path: string, + headers: Record, + ) => Promise; +}>; + +export class Router { + private readonly routes: Array = []; + + private isAuthenticated = false; + + constructor(private options: RouterOptions) {} + + public register(method: string, pattern: string, handler: Handler): void { + this.routes.push({ + method, + pattern: new URLPattern(pattern, { + segmentValueCharset: ':a-zA-Z0-9-_~ %', + }), + handler, + }); + } + + public get(pattern: string, handler: Handler): void { + this.register('GET', pattern, handler); + } + + public patch(pattern: string, handler: Handler): void { + this.register('PATCH', pattern, handler); + } + + public put(pattern: string, handler: Handler): void { + this.register('PUT', pattern, handler); + } + + public post(pattern: string, handler: Handler): void { + this.register('POST', pattern, handler); + } + + public del(pattern: string, handler: Handler): void { + this.register('DELETE', pattern, handler); + } + + public async run(request: WSRequest): Promise { + const headers: Record = {}; + for (const pair of request.headers ?? []) { + const [field, value = ''] = pair.split(/\s*:\s*/, 2); + assert(field != null, 'Missing field name for header'); + headers[field.toLowerCase()] = value; + } + + let response: AbbreviatedResponse = [404, { error: 'Not found' }]; + + debug( + 'got request %s %s %s', + this.isAuthenticated ? '(auth)' : '(unauth)', + request.verb, + request.path, + ); + + const { pathname, query } = parseURL(request.path ?? ''); + + await this.options.beforeRequest( + request.verb ?? '', + pathname ?? '', + headers, + ); + + for (const { method, pattern, handler } of this.routes) { + if (method !== request.verb) { + continue; + } + + const params: unknown = pattern.match(pathname ?? ''); + if (params == null) { + continue; + } + + const decodedParams: Record = {}; + for (const [key, value] of Object.entries(params)) { + decodedParams[key] = decodeURIComponent(String(value)); + } + + response = await handler( + decodedParams, + request.body ?? undefined, + headers, + query === null ? undefined : parseQS(query), + ); + break; + } + + const [status, json, responseHeaders = {}] = response; + + debug('response %s %s status=%d', request.verb, request.path, status); + + const timestampHeader = `X-Signal-Timestamp:${Date.now()}`; + const replyHeaders = [timestampHeader].concat( + Object.entries(responseHeaders).map( + ([name, value]) => `${name}:${value}`, + ), + ); + + if (json instanceof Uint8Array) { + return { + id: request.id, + status, + message: null, + headers: ['Content-Type:application/x-protobuf'].concat(replyHeaders), + body: Buffer.from(json), + }; + } + + assertJsonValue(json); + return { + id: request.id, + status, + message: null, + headers: replyHeaders.concat(['Content-Type:application/json']), + body: Buffer.from(JSON.stringify(json)), + }; + } + public setIsAuthenticated(value: boolean): void { + this.isAuthenticated = value; + } +} diff --git a/packages/mock-server/src/server/ws/service.ts b/packages/mock-server/src/server/ws/service.ts new file mode 100644 index 0000000000..b73e17aa7d --- /dev/null +++ b/packages/mock-server/src/server/ws/service.ts @@ -0,0 +1,142 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import assert from 'assert'; +import WebSocket from 'ws'; +import createDebug from 'debug'; + +import { signalservice as SignalService } from '../../../protos/compiled'; + +export type WSRequest = SignalService.WebSocketRequestMessage.Params; +export type WSResponse = SignalService.WebSocketResponseMessage.Params; + +const debug = createDebug('mock:ws:service'); + +const WSMessage = SignalService.WebSocketMessage; + +interface RequestOptions { + readonly body?: Uint8Array; + readonly headers?: Array | null; +} + +export abstract class Service { + private readonly requests = new Map void>(); + private lastSentId = 0n; + + constructor(protected readonly ws: WebSocket) { + this.ws = ws; + + this.ws.on('message', async (message) => { + try { + await this.onMessage(message); + } catch (error) { + assert(error instanceof Error); + debug('onMessage error', error.stack); + } + }); + this.ws.once('close', () => { + this.onClose(); + }); + } + + public async send( + verb: string, + path: string, + options: RequestOptions, + ): Promise { + const id = this.lastSentId++; + + const packet = WSMessage.encode({ + type: WSMessage.Type.REQUEST, + request: { + headers: options.headers ?? null, + body: options.body ?? null, + verb, + path, + id, + }, + response: null, + }); + + this.ws.send(packet); + + return new Promise((resolve) => this.requests.set(id, resolve)); + } + + private async onMessage(raw: WebSocket.Data): Promise { + if (!(raw instanceof Uint8Array)) { + throw new Error('Unexpected input'); + } + + // @ts-expect-error -- Can't refine to Uint8Array + const message = WSMessage.decode(raw); + + if (message.type === WSMessage.Type.RESPONSE) { + const response = message.response; + if (!response) { + throw new Error('Expected response in message'); + } + + const id = response.id ?? 0n; + + const resolve = this.requests.get(id); + if (!resolve) { + throw new Error(`Unexpected response: ${id}`); + } + + resolve(response); + } else if (message.type === WSMessage.Type.REQUEST) { + const request = message.request; + if (!request) { + throw new Error('Expected request in message'); + } + + let response: WSResponse; + try { + response = await this.handleRequest(request); + } catch (error) { + assert(error instanceof Error); + console.error('handleRequest error', error.stack); + response = { + id: request.id, + status: 500, + message: null, + headers: null, + body: Buffer.from( + JSON.stringify({ + error: error.stack, + }), + ), + }; + } + + // Keepalive responses + const packet = WSMessage.encode({ + type: WSMessage.Type.RESPONSE, + request: null, + response: { + ...response, + id: request.id, + }, + }); + + this.ws.send(packet); + } else { + debug('unsupported message', message); + } + } + + private onClose(): void { + for (const [id, resolve] of this.requests.entries()) { + resolve({ + id, + status: 500, + message: 'WebSocket is gone', + headers: null, + body: null, + }); + } + } + + protected abstract handleRequest(request: WSRequest): Promise; +} diff --git a/packages/mock-server/src/sfu/call.ts b/packages/mock-server/src/sfu/call.ts new file mode 100644 index 0000000000..3828d20b60 --- /dev/null +++ b/packages/mock-server/src/sfu/call.ts @@ -0,0 +1,222 @@ +// Copyright 2026 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { CallData, CallDataOptions } from '../data/call'; +import { + CallInfo, + CallInfoClient, + CallingDemuxId, + CallingEraId, + CallingUserId, +} from '../calling'; + +export enum SfuClientStatus { + Active = 'ACTIVE', + Pending = 'PENDING', + Blocked = 'BLOCKED', + Rejected = 'REJECTED', +} + +export type SfuClient = Readonly<{ + userId: CallingUserId; + demuxId: CallingDemuxId; + isAdmin: boolean; +}>; + +function toCallInfoClients( + clients: ReadonlyArray, +): ReadonlyArray { + return clients.map((client) => { + return { + demuxId: client.demuxId, + opaqueUserId: client.userId, + }; + }); +} + +type TakeClientsResult = Readonly<{ + userId: CallingUserId; + matches: Array; + remaining: Array; +}>; + +function takeClients( + existing: ReadonlyArray, + demuxId: CallingDemuxId, +): TakeClientsResult | null { + const found = existing.find((client) => client.demuxId === demuxId); + if (found == null) { + return null; + } + const { userId } = found; + const matches: Array = []; + const remaining: Array = []; + + for (const client of existing) { + if (client.demuxId === demuxId) { + matches.push(client); + } else { + remaining.push(client); + } + } + + return { userId, matches, remaining }; +} + +export type SfuCallOptions = Readonly< + CallDataOptions & { + eraId: CallingEraId; + creatorUserId: CallingUserId; + maxClients: number; + newClientsRequireApproval: boolean; + persistApprovalForAllUsersWhoJoin: boolean; + approvedUsers: ReadonlyArray | null; + } +>; + +export class SfuCall extends CallData { + #eraId: CallingEraId; + #creatorUserId: CallingUserId; + #maxClients: number; + #newClientsRequireApproval: boolean; + #persistApprovalForAllUsersWhoJoin: boolean; + + #activeClients: Array = []; + #pendingClients: Array = []; + #removedClients: Array = []; + + #blockedUsers = new Set(); + #deniedUsers = new Set(); + #approvedUsers: Set; + + constructor(options: SfuCallOptions) { + super(options); + this.#eraId = options.eraId; + this.#creatorUserId = options.creatorUserId; + this.#maxClients = options.maxClients; + this.#newClientsRequireApproval = options.newClientsRequireApproval; + this.#persistApprovalForAllUsersWhoJoin = + options.persistApprovalForAllUsersWhoJoin; + + this.#approvedUsers = new Set(options.approvedUsers); + } + + public get eraId(): CallingEraId { + return this.#eraId; + } + + public getInfo(includePendingClients: boolean): CallInfo { + const activeClients = toCallInfoClients(this.#activeClients); + const pendingClients = includePendingClients + ? toCallInfoClients(this.#pendingClients) + : null; + + return { + eraId: this.eraId, + maxClients: this.#maxClients, + creatorUserId: this.#creatorUserId, + activeClients, + pendingClients, + }; + } + + public isAdmin(userId: string): boolean { + return this.#activeClients.some((client) => { + return client.userId === userId && client.isAdmin; + }); + } + + public addClient(client: SfuClient): SfuClientStatus { + const count = this.#activeClients.length + this.#pendingClients.length; + if (count >= this.#maxClients) { + return SfuClientStatus.Rejected; + } + + if (this.#blockedUsers.has(client.userId)) { + this.#removedClients.push(client); + return SfuClientStatus.Blocked; + } + + const canAutoJoin = + client.isAdmin || + !this.#newClientsRequireApproval || + this.#approvedUsers.has(client.userId); + + if (canAutoJoin) { + if (this.#persistApprovalForAllUsersWhoJoin) { + this.#approvedUsers.add(client.userId); + } + + this.#activeClients.push(client); + return SfuClientStatus.Active; + } else { + this.#pendingClients.push(client); + return SfuClientStatus.Pending; + } + } + + public hasClient(demuxId: CallingDemuxId): boolean { + return ( + this.#activeClients.some((client) => client.demuxId === demuxId) || + this.#pendingClients.some((client) => client.demuxId === demuxId) || + this.#removedClients.some((client) => client.demuxId === demuxId) + ); + } + + public approvePendingDemuxId(demuxId: CallingDemuxId): void { + const result = takeClients(this.#pendingClients, demuxId); + if (result != null) { + this.#pendingClients = result.remaining; + for (const client of result.matches) { + this.#activeClients.push(client); + } + this.#deniedUsers.delete(result.userId); + this.#approvedUsers.add(result.userId); + } + } + + public denyPendingDemuxId(demuxId: CallingDemuxId): void { + const result = takeClients(this.#pendingClients, demuxId); + if (result != null) { + this.#pendingClients = result.remaining; + for (const client of result.matches) { + this.#removedClients.push(client); + } + const isDenied = this.#deniedUsers.has(result.userId); + if (isDenied) { + this.#blockedUsers.add(result.userId); + } else { + this.#deniedUsers.add(result.userId); + } + } + } + + public dropDemuxId(demuxId: CallingDemuxId): void { + const activeClients = takeClients(this.#activeClients, demuxId); + const pendingClients = takeClients(this.#pendingClients, demuxId); + const removedClients = takeClients(this.#removedClients, demuxId); + if (activeClients != null) { + this.#activeClients = activeClients.remaining; + } + if (pendingClients != null) { + this.#pendingClients = pendingClients.remaining; + } + if (removedClients != null) { + this.#removedClients = removedClients.remaining; + } + } + + public blockDemuxId(demuxId: CallingDemuxId): void { + const result = takeClients(this.#activeClients, demuxId); + if (result != null) { + this.#activeClients = result.remaining; + + for (const client of result.matches) { + this.#removedClients.push(client); + } + + this.#approvedUsers.delete(result.userId); + this.#blockedUsers.add(result.userId); + } + } +} diff --git a/packages/mock-server/src/sfu/config.ts b/packages/mock-server/src/sfu/config.ts new file mode 100644 index 0000000000..9ca71cdca8 --- /dev/null +++ b/packages/mock-server/src/sfu/config.ts @@ -0,0 +1,18 @@ +// Copyright 2026 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +export type IpAddress = string & { IpAddress: never }; +export type Hostname = string & { Hostname: never }; +export type Port = number & { Port: never }; + +export type MediaPorts = Readonly<{ + udp: Port; + tcp: Port; + tls: Port | null; +}>; + +export type ServerMediaAddress = Readonly<{ + addresses: ReadonlyArray; + ports: MediaPorts; + hostname: Hostname | null; +}>; diff --git a/packages/mock-server/src/sfu/connection.ts b/packages/mock-server/src/sfu/connection.ts new file mode 100644 index 0000000000..4595833829 --- /dev/null +++ b/packages/mock-server/src/sfu/connection.ts @@ -0,0 +1,75 @@ +// Copyright 2026 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { CallingDemuxId, CallingEraId } from '../calling'; +import { IcePassword } from './ice'; +import { StrpKeyMaterial } from './srtp'; + +export type SfuConnectionId = `${CallingEraId}:${CallingDemuxId}` & { + SfuConnectionId: never; +}; + +export function getSfuConnectionId(params: { + eraId: CallingEraId; + demuxId: CallingDemuxId; +}): SfuConnectionId { + return `${params.eraId}:${params.demuxId}` as SfuConnectionId; +} + +export type SfuConnectionOptions = Readonly<{ + connectionId: SfuConnectionId; + demuxId: CallingDemuxId; + serverIceUsername: string; + clientIceUsername: string; + serverIcePassword: IcePassword; + clientIcePassword: IcePassword; + strpKeyMaterial: StrpKeyMaterial; +}>; + +export class SfuConnection { + #connectionId: SfuConnectionId; + #demuxId: CallingDemuxId; + #serverIceUsername: string; + #clientIceUsername: string; + #serverIcePassword: IcePassword; + #clientIcePassword: IcePassword; + #strpKeyMaterial: StrpKeyMaterial; + + constructor(options: SfuConnectionOptions) { + this.#connectionId = options.connectionId; + this.#demuxId = options.demuxId; + this.#serverIceUsername = options.serverIceUsername; + this.#clientIceUsername = options.clientIceUsername; + this.#serverIcePassword = options.serverIcePassword; + this.#clientIcePassword = options.clientIcePassword; + this.#strpKeyMaterial = options.strpKeyMaterial; + } + + get connectionId(): SfuConnectionId { + return this.#connectionId; + } + + get demuxId(): CallingDemuxId { + return this.#demuxId; + } + + get serverIceUsername(): string { + return this.#serverIceUsername; + } + + get clientIceUsername(): string { + return this.#clientIceUsername; + } + + get serverIcePassword(): IcePassword { + return this.#serverIcePassword; + } + + get clientIcePassword(): IcePassword { + return this.#clientIcePassword; + } + + get strpKeyMaterial(): StrpKeyMaterial { + return this.#strpKeyMaterial; + } +} diff --git a/packages/mock-server/src/sfu/crypto.ts b/packages/mock-server/src/sfu/crypto.ts new file mode 100644 index 0000000000..b91e5f62b7 --- /dev/null +++ b/packages/mock-server/src/sfu/crypto.ts @@ -0,0 +1,118 @@ +// Copyright 2026 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { + createPrivateKey, + createPublicKey, + diffieHellman, + generateKeyPair, + KeyObject, +} from 'node:crypto'; +import { promisify } from 'node:util'; + +/** + * These classses are a reimplementation of libsignal's PublicKey/PrivateKey + * because they expect a tag at the start of their bytes. + */ + +const generateKeyPairAsync = promisify(generateKeyPair); + +export class CallingPrivateKey { + #privateKey: KeyObject; + + constructor(privateKey: KeyObject) { + this.#privateKey = privateKey; + } + + static getPrivateKeyObject(privateKey: CallingPrivateKey): KeyObject { + return privateKey.#privateKey; + } + + static fromBytes(bytes: Uint8Array): CallingPrivateKey { + return new CallingPrivateKey( + // @ts-expect-error @types/node for 24 doesn't have raw-private + createPrivateKey({ + key: bytes, + format: 'raw-private', + asymmetricKeyType: 'x25519', + }), + ); + } + + public toBytes(): Uint8Array { + return this.#privateKey.export({ + // @ts-expect-error @types/node for 24 doesn't have raw-private + format: 'raw-private', + }); + } + + public agree(publicKey: CallingPublicKey): Uint8Array { + return diffieHellman({ + privateKey: this.#privateKey, + publicKey: CallingPublicKey.getPublicKeyObject(publicKey), + }); + } +} + +export class CallingPublicKey { + #publicKey: KeyObject; + + constructor(publicKey: KeyObject) { + this.#publicKey = publicKey; + } + + static getPublicKeyObject(publicKey: CallingPublicKey): KeyObject { + return publicKey.#publicKey; + } + + static fromBytes(bytes: Uint8Array): CallingPublicKey { + return new CallingPublicKey( + // @ts-expect-error @types/node for 24 doesn't have raw-public + createPublicKey({ + key: bytes, + format: 'raw-public', + asymmetricKeyType: 'x25519', + }), + ); + } + + getKeyObject(): KeyObject { + return this.#publicKey; + } + + toBytes(): Uint8Array { + return this.#publicKey.export({ + // @ts-expect-error @types/node for 24 doesn't have raw-public + format: 'raw-public', + }); + } +} + +export class CallingKeyPair { + #privateKey: CallingPrivateKey; + #publicKey: CallingPublicKey; + + private constructor(params: { + privateKey: CallingPrivateKey; + publicKey: CallingPublicKey; + }) { + this.#privateKey = params.privateKey; + this.#publicKey = params.publicKey; + } + + get privateKey(): CallingPrivateKey { + return this.#privateKey; + } + + get publicKey(): CallingPublicKey { + return this.#publicKey; + } + + static async generate(): Promise { + const keys = await generateKeyPairAsync('x25519'); + return new CallingKeyPair({ + privateKey: new CallingPrivateKey(keys.privateKey), + publicKey: new CallingPublicKey(keys.publicKey), + }); + } +} diff --git a/packages/mock-server/src/sfu/ice.ts b/packages/mock-server/src/sfu/ice.ts new file mode 100644 index 0000000000..73383b599d --- /dev/null +++ b/packages/mock-server/src/sfu/ice.ts @@ -0,0 +1,87 @@ +// Copyright 2026 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import assert from 'node:assert'; +import { randomBytes } from 'node:crypto'; +import z from 'zod'; + +function getRandomBase64String(size: number): string { + assert(size % 4 === 0, 'Must be multiple of 4'); + const byteLength = (size * 6) / 8; + const bytes = randomBytes(byteLength); + const base64 = bytes.toString('base64'); + assert(base64.length === size, 'Must be exact length'); + return base64; +} + +export type IceUsernameFragment = string & { + IceUsernameFragment: never; +}; + +export const ICE_USERNAME_FRAGMENT_SIZE = 4; + +export function getRandomIceUsernameFragment(): IceUsernameFragment { + return getRandomBase64String( + ICE_USERNAME_FRAGMENT_SIZE, + ) as IceUsernameFragment; +} + +export const IceUsernameFragmentSchema = z + .string() + .min(4) + .max(256) + .transform((input) => input as IceUsernameFragment); + +export type IceUsername = `${IceUsernameFragment}:${IceUsernameFragment}` & { + IceUsername: never; +}; + +export type IceUsernamesParams = Readonly<{ + serverIceUsernameFragment: IceUsernameFragment; + clientIceUsernameFragment: IceUsernameFragment; +}>; + +export type IceUsernames = Readonly<{ + serverIceUsername: IceUsername; + clientIceUsername: IceUsername; +}>; + +function toIceUsername( + a: IceUsernameFragment, + b: IceUsernameFragment, +): IceUsername { + return `${a}:${b}` as IceUsername; +} + +export function getIceUsernames(params: IceUsernamesParams): IceUsernames { + const serverIceUsername = toIceUsername( + params.serverIceUsernameFragment, + params.clientIceUsernameFragment, + ); + const clientIceUsername = toIceUsername( + params.clientIceUsernameFragment, + params.serverIceUsernameFragment, + ); + return { serverIceUsername, clientIceUsername }; +} + +export function getClientIceUsername(params: { + serverIceUsernameFragment: IceUsernameFragment; + clientIceUsernameFragment: IceUsernameFragment; +}): IceUsername { + return `${params.clientIceUsernameFragment}:${params.serverIceUsernameFragment}` as IceUsername; +} + +export type IcePassword = string & { IcePassword: never }; + +export const ICE_PASSWORD_SIZE = 32; + +export function getRandomIcePassword(): IcePassword { + return getRandomBase64String(ICE_PASSWORD_SIZE) as IcePassword; +} + +export const IcePasswordSchema = z + .string() + .min(22) + .max(256) + .transform((input) => input as IcePassword); diff --git a/packages/mock-server/src/sfu/service.ts b/packages/mock-server/src/sfu/service.ts new file mode 100644 index 0000000000..bc43b3d024 --- /dev/null +++ b/packages/mock-server/src/sfu/service.ts @@ -0,0 +1,151 @@ +// Copyright 2026 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { SfuCall, SfuClientStatus } from './call'; +import { + CallInfo, + CallingDemuxId, + CallingEraId, + CallingError, + CallingErrorCode, + CallingRoomId, + CallingUserId, + CallType, +} from '../calling'; +import { + getSfuConnectionId, + SfuConnection, + SfuConnectionId, +} from './connection'; +import { getStrpKeyMaterial } from './srtp'; +import { getIceUsernames, IcePassword, IceUsernameFragment } from './ice'; +import { CallingKeyPair, CallingPublicKey } from './crypto'; + +export type SfuJoinCallRequest = Readonly<{ + eraId: CallingEraId; + roomId: CallingRoomId | null; + userId: CallingUserId; + + demuxId: CallingDemuxId; + + clientIceUsernameFragment: IceUsernameFragment; + clientIcePassword: IcePassword; + clientPublicKey: CallingPublicKey; + clientHkdfExtraInfo: Uint8Array | null; + + serverIceUsernameFragment: IceUsernameFragment; + serverIcePassword: IcePassword; + + callType: CallType; + isAdmin: boolean; + newClientsRequireApproval: boolean; + approvedUsers: ReadonlyArray | null; +}>; + +export type SfuJoinCallResponse = Readonly<{ + serverPublicKey: CallingPublicKey; + clientStatus: SfuClientStatus; +}>; + +export type SfuPeekCallRequest = Readonly<{ + eraId: CallingEraId; + userId: CallingUserId; +}>; + +export type SfuPeekCallResponse = Readonly<{ + info: CallInfo; +}>; + +/** + * Selective Forwarding Unit + */ +export class SfuService { + #calls = new Map(); + #connections = new Map(); + + public async joinCall( + request: SfuJoinCallRequest, + ): Promise { + let call = this.#calls.get(request.eraId); + + if (call == null) { + call = new SfuCall({ + creatorUserId: request.userId, + roomId: request.roomId, + eraId: request.eraId, + maxClients: 30, + newClientsRequireApproval: request.newClientsRequireApproval, + persistApprovalForAllUsersWhoJoin: true, + approvedUsers: request.approvedUsers, + }); + + this.#calls.set(call.eraId, call); + } + + if (call.hasClient(request.demuxId)) { + throw new CallingError(CallingErrorCode.DuplicateDemuxIdDetected); + } + + const { serverIceUsername, clientIceUsername } = getIceUsernames({ + serverIceUsernameFragment: request.serverIceUsernameFragment, + clientIceUsernameFragment: request.clientIceUsernameFragment, + }); + + const clientStatus = call.addClient({ + userId: request.userId, + demuxId: request.demuxId, + isAdmin: request.isAdmin, + }); + + if (clientStatus === SfuClientStatus.Rejected) { + throw new CallingError(CallingErrorCode.TooManyClients); + } + + const serverKeys = await CallingKeyPair.generate(); + const serverSecret = serverKeys.privateKey; + const serverPublicKey = serverKeys.publicKey; + + const sharedSecret = serverSecret.agree(request.clientPublicKey); + + const strpKeyMaterial = getStrpKeyMaterial({ + sharedSecret, + clientHkdfExtraInfo: request.clientHkdfExtraInfo, + }); + + const connectionId = getSfuConnectionId({ + eraId: call.eraId, + demuxId: request.demuxId, + }); + + const connection = new SfuConnection({ + connectionId, + demuxId: request.demuxId, + serverIceUsername, + clientIceUsername, + serverIcePassword: request.serverIcePassword, + clientIcePassword: request.clientIcePassword, + strpKeyMaterial, + }); + + this.#connections.set(connectionId, connection); + + return { + serverPublicKey, + clientStatus, + }; + } + + public async peekCall( + request: SfuPeekCallRequest, + ): Promise { + const call = this.#calls.get(request.eraId); + if (call == null) { + throw new CallingError(CallingErrorCode.CallNotFound); + } + + const includePendingUserIds = call.isAdmin(request.userId); + const info = call.getInfo(includePendingUserIds); + + return { info }; + } +} diff --git a/packages/mock-server/src/sfu/srtp.ts b/packages/mock-server/src/sfu/srtp.ts new file mode 100644 index 0000000000..3f6a123a44 --- /dev/null +++ b/packages/mock-server/src/sfu/srtp.ts @@ -0,0 +1,89 @@ +// Copyright 2026 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { hkdf } from '@signalapp/libsignal-client'; + +const KEY_LABEL = Buffer.from( + 'Signal_Group_Call_20211105_SignallingDH_SRTPKey_KDF', +); + +const KEY_LENGTH = 16; +const SALT_LENGTH = 12; + +// In the order [client_key, client_salt, server_key, server_salt] +const KEY_MATERIAL_LENGTH = KEY_LENGTH + SALT_LENGTH + KEY_LENGTH + SALT_LENGTH; + +export type StrpKeyMaterial = Uint8Array & { + StrpKeyMaterial: Uint8Array; +}; + +export function getStrpKeyMaterial(params: { + sharedSecret: Uint8Array; + clientHkdfExtraInfo: Uint8Array | null; +}): StrpKeyMaterial { + const clientHkdfExtraInfo = params.clientHkdfExtraInfo ?? Buffer.alloc(0); + + const keyMaterial = hkdf( + KEY_MATERIAL_LENGTH, + params.sharedSecret, + Buffer.concat([KEY_LABEL, clientHkdfExtraInfo]), + null, + ); + + return keyMaterial as StrpKeyMaterial; +} + +type Key = Uint8Array & { Key: never }; +type Salt = Uint8Array & { Salt: never }; + +type KeyPair = Readonly<{ + key: Key; + salt: Salt; +}>; + +type KeyPairs = Readonly<{ + rtp: KeyPair; + rtcp: KeyPair; +}>; + +type ClientAndServer = Readonly<{ + client: KeyPairs; // decrypt + server: KeyPairs; // encrypt +}>; + +export function deriveStrpClientAndServer( + keyMaterial: StrpKeyMaterial, +): ClientAndServer { + const mid = KEY_LENGTH + SALT_LENGTH; + return { + client: deriveKeyPairs({ + key: keyMaterial.subarray(0, KEY_LENGTH) as Key, + salt: keyMaterial.subarray(KEY_LENGTH, mid) as Salt, + }), + server: deriveKeyPairs({ + key: keyMaterial.subarray(mid, mid + KEY_LENGTH) as Key, + salt: keyMaterial.subarray(mid + KEY_LENGTH) as Salt, + }), + }; +} + +function deriveKeyPairs(master: KeyPair): KeyPairs { + return { + rtp: { + key: deriveKey(master, 0), + salt: deriveSalt(master, 2), + }, + rtcp: { + key: deriveKey(master, 3), + salt: deriveSalt(master, 5), + }, + }; +} + +function deriveKey(_keyPair: KeyPair, _label: number): Key { + throw new Error('unimplemented'); +} + +function deriveSalt(_keyPair: KeyPair, _label: number): Salt { + throw new Error('unimplemented'); +} diff --git a/packages/mock-server/src/types.ts b/packages/mock-server/src/types.ts new file mode 100644 index 0000000000..7a1be880c8 --- /dev/null +++ b/packages/mock-server/src/types.ts @@ -0,0 +1,46 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { KEMPublicKey, PublicKey } from '@signalapp/libsignal-client'; + +export type AciString = string & { __aci: never }; +export type PniString = string & { __pni: never }; +export type UntaggedPniString = string & { __untagged_pni: never }; +export type ServiceIdString = AciString | PniString; + +export type ProvisionIdString = string & { __provision_id: never }; + +export type ProvisioningCode = string & { __provisioning_code: never }; +export type RegistrationId = number & { __reg_id: never }; +export type DeviceId = number & { __device_id: never }; +export type AttachmentId = string & { __attachment_id: never }; + +export enum ServiceIdKind { + ACI = 'ACI', + PNI = 'PNI', +} + +export type SignedPreKey = Readonly<{ + keyId: number; + publicKey: PublicKey; + signature: Buffer; +}>; + +export type KyberPreKey = Readonly<{ + keyId: number; + publicKey: KEMPublicKey; + signature: Buffer; +}>; + +export type PreKey = Readonly<{ + keyId: number; + publicKey: PublicKey; +}>; + +export function untagPni(pni: PniString): UntaggedPniString { + return pni.replace(/^PNI:/, '') as UntaggedPniString; +} + +export function tagPni(pni: UntaggedPniString): PniString { + return `PNI:${pni}` as PniString; +} diff --git a/packages/mock-server/src/util.ts b/packages/mock-server/src/util.ts new file mode 100644 index 0000000000..be23c032b3 --- /dev/null +++ b/packages/mock-server/src/util.ts @@ -0,0 +1,391 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { ProtocolAddress } from '@signalapp/libsignal-client'; +import assert from 'assert'; +import isPlainObject from 'is-plain-obj'; +import crypto from 'node:crypto'; +import util from 'node:util'; +import type { JsonValue } from 'type-fest'; + +import { DAY_IN_SECONDS } from './constants'; +import { type RegistrationId, ServiceIdKind } from './types'; +import { ParsedUrlQuery } from 'node:querystring'; +import { Device } from './data/device'; + +type PromiseQueueEntry = Readonly<{ + value: T; + cancel: () => void; + resolvePush?: () => void; +}>; +type ResolveEntry = { resolve: (value: T) => void; cancel: () => void }; + +export type PromiseQueueConfig = Readonly<{ + timeout?: number; + name: string; +}>; + +export function generateRandomE164(): string { + // Generate random number + let number = '+141549'; + for (let i = 0; i < 5; i++) { + number += Math.floor(Math.random() * 10).toString(); + } + return number; +} + +export type ParseAuthHeaderResult = + | { + username: string; + password: string; + error?: undefined; + } + | { + username?: undefined; + password?: undefined; + error: string; + }; + +function splitOnce(input: string, splitter: string): [string, string] | null { + const index = input.indexOf(splitter); + if (index === -1) { + return null; + } + return [input.slice(0, index), input.slice(index + 1)]; +} + +export function parseAuthHeader( + header?: string, + options?: { allowEmptyPassword?: boolean }, +): ParseAuthHeaderResult { + if (!header) { + return { error: 'Missing Authorization header' }; + } + + const [basic, base64] = header.split(/\s+/g, 2); + if (basic?.toLowerCase() !== 'basic') { + return { error: `Unsupported authorization type ${basic}` }; + } + + let decoded: string; + try { + assert(base64 != null, 'Missing base64 for basic authorization'); + decoded = Buffer.from(base64, 'base64').toString(); + } catch (error) { + assert(error instanceof Error); + return { error: error.message }; + } + + const parts = splitOnce(decoded, ':'); + if (parts == null) { + return { error: 'Invalid basic auth' }; + } + const [username, password] = parts; + + if (!username) { + return { error: 'Missing username' }; + } + + if (!password && !options?.allowEmptyPassword) { + return { error: 'Missing password' }; + } + + return { username, password }; +} + +export class PromiseQueue { + private readonly defaultTimeout: number | undefined; + private readonly entries: Array> = []; + private readonly resolvers: Array> = []; + private readonly name; + + constructor(config: PromiseQueueConfig) { + this.defaultTimeout = config.timeout; + this.name = config.name; + } + + public get size(): number { + return this.entries.length; + } + + public stop(): void { + while (this.entries.length > 0) { + const entry = this.entries[0]; + if (entry) { + entry.cancel(); + this.entries.shift(); + } else { + break; + } + } + + while (this.resolvers.length > 0) { + const entry = this.resolvers.shift(); + if (entry) { + entry.cancel(); + } else { + break; + } + } + } + + public pushAndWait( + value: T, + timeout: number | undefined = this.defaultTimeout, + ): { promise: Promise; cancel: () => void } { + // We were waiting for `.shift()` already + const resolveEntry = this.resolvers.shift(); + if (resolveEntry) { + resolveEntry.resolve(value); + return { promise: Promise.resolve(), cancel: () => undefined }; + } + + // Not waiting for `.shift()` - queue. + const { promise, resolve, reject } = Promise.withResolvers(); + let timer: NodeJS.Timeout | undefined; + + const cancel = () => { + if (timer) { + clearTimeout(timer); + timer = undefined; + } + + const index = this.entries.indexOf(entry); + if (index === -1) { + return; + } + this.entries.splice(index, 1); + + reject(new Error(`PromiseQueue(${this.name}) pushAndWait timeout`)); + }; + + if (timeout !== undefined) { + timer = setTimeout(cancel, timeout); + } + + const entry = { + value, + cancel, + resolvePush() { + if (timer !== undefined) { + clearTimeout(timer); + } + timer = undefined; + + resolve(); + }, + }; + + this.entries.push(entry); + + return { promise, cancel }; + } + + public push(value: T): void { + // We were waiting for `.shift()` already + const resolveEntry = this.resolvers.shift(); + if (resolveEntry) { + resolveEntry.resolve(value); + return; + } + + this.entries.push({ value, cancel: () => undefined }); + } + + public async shift( + timeout: number | undefined = this.defaultTimeout, + ): Promise { + // `.pushAndWait()` was called before us + const entry = this.entries.shift(); + if (entry) { + if (entry.resolvePush) { + entry.resolvePush(); + } + return entry.value; + } + + return new Promise((resolve, reject) => { + let timer: NodeJS.Timeout | undefined; + + const resolveEntry = (value: T) => { + if (timer !== undefined) { + clearTimeout(timer); + } + timer = undefined; + + resolve(value); + }; + + const cancel = () => { + const index = this.resolvers.indexOf(entry); + if (index === -1) { + throw new Error( + `PromiseQueue(${this.name}) resolvers bookkeeping error`, + ); + } + this.resolvers.splice(index, 1); + + reject(new Error(`PromiseQueue(${this.name}) shift timeout`)); + }; + + if (timeout !== undefined) { + timer = setTimeout(cancel, timeout); + } + + const entry = { + cancel, + resolve: resolveEntry, + }; + + this.resolvers.push(entry); + }); + } +} + +export function addressToString(address: ProtocolAddress): string { + return `${address.name()}.${address.deviceId()}`; +} + +export function getTodayInSeconds(): number { + const now = Date.now() / 1000; + + return now - (now % DAY_IN_SECONDS); +} + +export function daysToSeconds(days: number): number { + return days * DAY_IN_SECONDS; +} + +export function generateRegistrationId(): RegistrationId { + return Math.max(1, (Math.random() * 0x4000) | 0) as RegistrationId; +} + +export function generateDevicePassword(): string { + return crypto.randomBytes(10).toString('hex'); +} + +export function toBase64(buf: Uint8Array): string { + return Buffer.from(buf).toString('base64'); +} + +export function toURLSafeBase64(buf: Uint8Array): string { + return toBase64(buf) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/g, ''); +} + +export function fromBase64(base64: string): Buffer { + return Buffer.from(base64, 'base64'); +} + +export function fromURLSafeBase64(base64: string): Buffer { + const source = base64.replace(/-/g, '+').replace(/_/g, '/'); + + // Note that `Buffer.from()` ignores padding anyway so we don't need to + // restore it. + return fromBase64(source); +} + +export function assertJsonValue(root: unknown): asserts root is JsonValue { + const issues: Array = []; + + function visit(node: unknown, path: ReadonlyArray) { + if ( + node === null || + typeof node === 'boolean' || + (typeof node === 'number' && Number.isFinite(node)) || + typeof node === 'string' + ) { + return; + } else if (Array.isArray(node)) { + node.forEach((item, index) => { + visit(item, path.concat(index)); + }); + return; + } else if (isPlainObject(node)) { + Object.entries(node).forEach(([key, item]) => { + // ignore undefined properties + if (typeof item !== 'undefined') { + visit(item, path.concat(key)); + } + }); + } else { + issues.push(`${path.join('.')}: ${util.inspect(node)}`); + } + } + + visit(root, ['value']); + + if (issues.length > 0) { + throw new TypeError(`Invalid JsonValue:\n${issues.join('\n')}`); + } +} + +export function serviceIdKindFromQuery( + query: Record | ParsedUrlQuery | undefined, +): ServiceIdKind { + if (query && (query.identity === 'pni' || query.identity === 'PNI')) { + return ServiceIdKind.PNI; + } + + return ServiceIdKind.ACI; +} + +export function booleanFromQuery( + value: string | ReadonlyArray | undefined, + defaultValue: boolean, +): boolean { + const single = typeof value === 'string' ? value : value?.[0]; + if (single === undefined) { + return defaultValue; + } + + return single.toLowerCase() === 'true'; +} + +export async function getDevicesKeysResult( + serviceIdKind: ServiceIdKind, + devices: ReadonlyArray, +): Promise { + const [primary] = devices; + assert(primary !== undefined, 'Empty device list'); + + const identityKey = await primary.getIdentityKey(serviceIdKind); + + return { + identityKey: Buffer.from(identityKey.serialize()).toString('base64'), + devices: await Promise.all( + devices.map(async (device) => { + const { signedPreKey, preKey, pqPreKey } = + await device.popSingleUseKey(serviceIdKind); + return { + deviceId: device.deviceId, + registrationId: device.getRegistrationId(serviceIdKind), + signedPreKey: { + keyId: signedPreKey.keyId, + publicKey: Buffer.from(signedPreKey.publicKey.serialize()).toString( + 'base64', + ), + signature: signedPreKey.signature.toString('base64'), + }, + pqPreKey: { + keyId: pqPreKey.keyId, + publicKey: Buffer.from(pqPreKey.publicKey.serialize()).toString( + 'base64', + ), + signature: pqPreKey.signature.toString('base64'), + }, + preKey: preKey + ? { + keyId: preKey.keyId, + publicKey: Buffer.from(preKey.publicKey.serialize()).toString( + 'base64', + ), + } + : null, + }; + }), + ), + }; +} diff --git a/packages/mock-server/test/crypto-test.ts b/packages/mock-server/test/crypto-test.ts new file mode 100644 index 0000000000..8cc85f6078 --- /dev/null +++ b/packages/mock-server/test/crypto-test.ts @@ -0,0 +1,41 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import assert from 'assert'; +import { PrivateKey } from '@signalapp/libsignal-client'; + +import { deriveAccessKey, generateServerCertificate } from '../src/crypto'; + +describe('crypto', () => { + // Verify that the generated certificate is valid within our trust root + it('should create ServerCertificate', () => { + const root = PrivateKey.generate(); + + const { certificate } = generateServerCertificate(root); + + if (!certificate.signature || !certificate.certificate) { + throw new Error('Invalid cert'); + } + + assert.ok( + root + .getPublicKey() + .verify( + Buffer.from(certificate.certificate), + Buffer.from(certificate.signature), + ), + ); + }); + + // Make sure that access key has correct value when derived from a constant + // input. + it('should derive access key', () => { + const profileKey = Buffer.alloc(32).fill(42); + const accessKey = deriveAccessKey(profileKey); + + assert.strictEqual( + accessKey.toString('base64'), + '2KEiuqkfT794/nwyqqVUYQ==', + ); + }); +}); diff --git a/packages/mock-server/test/primary-device-test.ts b/packages/mock-server/test/primary-device-test.ts new file mode 100644 index 0000000000..381aea1f3e --- /dev/null +++ b/packages/mock-server/test/primary-device-test.ts @@ -0,0 +1,145 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import assert from 'assert'; +import { v4 as uuidv4 } from 'uuid'; +import { PrivateKey } from '@signalapp/libsignal-client'; +import { ServerSecretParams } from '@signalapp/libsignal-client/zkgroup'; + +import { + generateSenderCertificate, + generateServerCertificate, +} from '../src/crypto'; +import { Device } from '../src/data/device'; +import { + AciString, + DeviceId, + PniString, + RegistrationId, + ServiceIdKind, +} from '../src/types'; +import { PrimaryDevice } from '../src/api/primary-device'; + +const trustRoot = PrivateKey.generate(); +const serverCert = generateServerCertificate(trustRoot); +const serverSecretParams = ServerSecretParams.generate(); + +async function createPrimaryDevice(name: string): Promise { + const aci = uuidv4() as AciString; + const pni = `PNI:${uuidv4()}` as PniString; + + const device = new Device({ + aci, + pni, + number: '+1', + deviceId: 1 as DeviceId, + registrationId: 1 as RegistrationId, + pniRegistrationId: 2 as RegistrationId, + isProvisioned: false, + }); + + const primary = new PrimaryDevice(device, { + trustRoot: trustRoot.getPublicKey(), + serverPublicParams: serverSecretParams.getPublicParams(), + profileName: name, + contacts: { + attachmentIdentifier: null, + clientUuid: null, + contentType: null, + key: null, + size: null, + thumbnail: null, + digest: null, + incrementalMac: null, + chunkSize: null, + fileName: null, + flags: null, + width: null, + height: null, + caption: null, + blurHash: null, + uploadTimestamp: null, + cdnNumber: null, + }, + + async getSenderCertificate() { + return generateSenderCertificate(serverCert, { + number: device.number, + aci: device.aci, + deviceId: device.deviceId, + identityKey: await device.getIdentityKey(ServiceIdKind.ACI), + }); + }, + + async generateNumber() { + throw new Error('Should not be called'); + }, + async generatePni() { + throw new Error('Should not be called'); + }, + async changeDeviceNumber() { + throw new Error('Should not be called'); + }, + async send() { + throw new Error('Should not be called'); + }, + async getDeviceByServiceId() { + throw new Error('Not implemented'); + }, + async issueExpiringProfileKeyCredential() { + throw new Error('Not implemented'); + }, + async getGroup() { + throw new Error('Not implemented'); + }, + async createGroup() { + throw new Error('Not implemented'); + }, + async modifyGroup() { + throw new Error('Not implemented'); + }, + async waitForGroupUpdate() { + throw new Error('Not implemented'); + }, + async getStorageManifest() { + throw new Error('Not implemented'); + }, + async getStorageItem() { + throw new Error('Not implemented'); + }, + async getAllStorageKeys() { + throw new Error('Not implemented'); + }, + async waitForStorageManifest() { + throw new Error('Not implemented'); + }, + async applyStorageWrite() { + throw new Error('Not implemented'); + }, + }); + + await primary.init(); + + return primary; +} + +// The idea of the test here is to verify that PrimaryDevice is capable of: +// - Generating prekeys +// - Adding prekeys from other accounts +// - Encrypting/decrypting messages +describe('PrimaryDevice', () => { + it('should send and receive messages', async () => { + const alice = await createPrimaryDevice('Alice'); + const bob = await createPrimaryDevice('Bob'); + + const key = await bob.device.popSingleUseKey(ServiceIdKind.ACI); + await alice.addSingleUseKey(bob.device, key); + + const encrypted = await alice.encryptText(bob.device, 'Hello'); + await bob.receive(alice.device, encrypted); + + const message = await bob.waitForMessage(); + assert.strictEqual(message.body, 'Hello'); + assert.strictEqual(message.source, alice.device); + }); +}); diff --git a/packages/mock-server/test/util-test.ts b/packages/mock-server/test/util-test.ts new file mode 100644 index 0000000000..c58b5365e3 --- /dev/null +++ b/packages/mock-server/test/util-test.ts @@ -0,0 +1,178 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import assert from 'assert'; + +import { PromiseQueue, assertJsonValue } from '../src/util'; + +describe('util', () => { + describe('PromiseQueue', () => { + it('should pushAndWait and shift', async () => { + const q = new PromiseQueue({ name: 'test' }); + + const { promise } = q.pushAndWait(42); + + assert.strictEqual(await q.shift(), 42); + await promise; + }); + + it('should push and shift', async () => { + const q = new PromiseQueue({ name: 'test' }); + + q.push(42); + + assert.strictEqual(await q.shift(), 42); + }); + + it('should shift and pushAndWait', async () => { + const q = new PromiseQueue({ name: 'test' }); + + const shift = q.shift(); + + const { promise } = q.pushAndWait(23); + await promise; + + assert.strictEqual(await shift, 23); + }); + + it('should shift and push', async () => { + const q = new PromiseQueue({ name: 'test' }); + + const shift = q.shift(); + + q.push(23); + + assert.strictEqual(await shift, 23); + }); + + it('should timeout on push', async () => { + const q = new PromiseQueue({ name: 'test' }); + + await assert.rejects( + async () => { + const { promise } = q.pushAndWait(23, 10); + await promise; + }, + { message: 'PromiseQueue(test) pushAndWait timeout' }, + ); + }); + + it('should not timeout on push', async () => { + const q = new PromiseQueue({ name: 'test' }); + + const { promise } = q.pushAndWait(15, 1000); + + assert.strictEqual(await q.shift(), 15); + await promise; + }); + + it('should timeout on shift', async () => { + const q = new PromiseQueue({ name: 'test' }); + + await assert.rejects( + async () => { + await q.shift(10); + }, + { message: 'PromiseQueue(test) shift timeout' }, + ); + }); + + it('should not timeout on shift', async () => { + const q = new PromiseQueue({ name: 'test' }); + + const shift = q.shift(1000); + + const { promise } = q.pushAndWait(17); + await promise; + + assert.strictEqual(await shift, 17); + }); + + it('should apply default timeouts on push', async () => { + const q = new PromiseQueue({ timeout: 10, name: 'test' }); + + await assert.rejects( + async () => { + const { promise } = q.pushAndWait(23); + await promise; + }, + { message: 'PromiseQueue(test) pushAndWait timeout' }, + ); + }); + + it('should apply default timeouts on shift', async () => { + const q = new PromiseQueue({ timeout: 10, name: 'test' }); + + await assert.rejects( + async () => { + await q.shift(); + }, + { message: 'PromiseQueue(test) shift timeout' }, + ); + }); + }); + + describe('assertJsonValue', () => { + function valid(value: unknown) { + assert.doesNotThrow(() => assertJsonValue(value)); + } + + function invalid(value: unknown, predicate: RegExp) { + assert.throws(() => assertJsonValue(value), predicate); + } + + it('should accept valid json', () => { + valid(null); + valid(true); + valid(false); + valid(0); + valid(42); + valid(-42); + valid(''); + valid('hi'); + valid([]); + valid([null, true, 42, 'hi', [1, 2, 3], { a: 'b' }]); + valid([1, [2, [3, 4], 5], 6]); + valid({}); + valid({ a: null, b: true, c: 42, d: 'hi', e: [1, 2, 3], f: { a: 'b' } }); + valid({ a: undefined, b: { c: undefined } }); + }); + + it('should not accept invalid json', () => { + invalid(undefined, /value: undefined/); + invalid(Number.NEGATIVE_INFINITY, /value: -Infinity/); + invalid(Number.POSITIVE_INFINITY, /value: Infinity/); + invalid(Number.NaN, /value: NaN/); + invalid(0n, /value: 0n/); + invalid(24n, /value: 24n/); + invalid([undefined], /value\.0: undefined/); + invalid([1, [42n]], /value\.1\.0: 42n/); + invalid({ a: 42n }, /value\.a: 42n/); + invalid({ a: { b: 42n } }, /value\.a\.b: 42n/); + invalid(() => 'hi', /value: \[Function \(anonymous\)\]/); + invalid(Buffer.from('hi'), /value: /); + invalid(new Uint8Array([68, 69]), /value: Uint8Array\(2\) \[ 68, 69 \]/); + // eslint-disable-next-line @typescript-eslint/no-extraneous-class + invalid(class Foo {}, /value: \[class Foo\]/); + // eslint-disable-next-line @typescript-eslint/no-extraneous-class + invalid(new (class Foo {})(), /value: Foo {}/); + invalid(42n, /value: 42n/); + }); + + it('should report multiple errors', () => { + assert.throws( + () => { + assertJsonValue({ a: 42n, b: { c: 42n, d: [42n, 42n] } }); + }, + (error) => { + assert(error instanceof TypeError); + assert.match(error.message, /value\.a: 42n/); + assert.match(error.message, /value\.b\.c: 42n/); + assert.match(error.message, /value\.b\.d\.0: 42n/); + assert.match(error.message, /value\.b\.d\.1: 42n/); + return true; + }, + ); + }); + }); +}); diff --git a/packages/mock-server/tsconfig.json b/packages/mock-server/tsconfig.json new file mode 100644 index 0000000000..647fd5e999 --- /dev/null +++ b/packages/mock-server/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + /* Visit https://aka.ms/tsconfig.json to read more about this file */ + + /* Basic Options */ + "incremental": true /* Enable incremental compilation */, + "target": "esnext" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */, + "module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */, + "declaration": true /* Generates corresponding '.d.ts' file. */, + + /* Strict Type-Checking Options */ + "strict": true /* Enable all strict type-checking options. */, + "noUncheckedIndexedAccess": true, + + /* Additional Checks */ + "noUnusedLocals": true /* Report errors on unused locals. */, + "noImplicitOverride": true, + "noImplicitReturns": true, + + "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */, + + /* Advanced Options */ + "skipLibCheck": true /* Skip type checking of declaration files. */, + "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ + }, + "include": ["src/**/*", "test/**/*"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 47da303e15..92653c4645 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -162,8 +162,8 @@ importers: specifier: 1.0.1 version: 1.0.1 '@signalapp/mock-server': - specifier: 25.2.0 - version: 25.2.0(bufferutil@4.0.9)(utf-8-validate@5.0.10) + specifier: workspace:* + version: link:packages/mock-server '@signalapp/parchment-cjs': specifier: 3.0.1 version: 3.0.1 @@ -717,6 +717,85 @@ importers: packages/lame: {} + packages/mock-server: + dependencies: + '@indutny/parallel-prettier': + specifier: ^3.0.0 + version: 3.0.0(prettier@3.8.3) + '@indutny/protopiler': + specifier: 4.0.0 + version: 4.0.0 + '@signalapp/libsignal-client': + specifier: ^0.92.1 + version: 0.92.2 + '@tus/file-store': + specifier: ^1.4.0 + version: 1.5.1 + '@tus/server': + specifier: ^1.7.0 + version: 1.10.2 + debug: + specifier: ^4.3.2 + version: 4.4.3(supports-color@8.1.1) + is-plain-obj: + specifier: 3.0.0 + version: 3.0.0 + micro: + specifier: ^9.3.4 + version: 9.4.1 + microrouter: + specifier: ^3.1.3 + version: 3.1.3 + prettier: + specifier: ^3.3.3 + version: 3.8.3 + type-fest: + specifier: ^4.26.1 + version: 4.26.1 + url-pattern: + specifier: ^1.0.3 + version: 1.0.3 + uuid: + specifier: ^8.3.2 + version: 8.3.2 + ws: + specifier: ^8.4.2 + version: 8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10) + zod: + specifier: ^3.20.2 + version: 3.25.76 + devDependencies: + '@types/debug': + specifier: ^4.1.7 + version: 4.1.13 + '@types/long': + specifier: ^4.0.1 + version: 4.0.2 + '@types/micro': + specifier: ^7.3.6 + version: 7.3.7 + '@types/microrouter': + specifier: ^3.1.1 + version: 3.1.6 + '@types/mocha': + specifier: ^10.0.10 + version: 10.0.10 + '@types/node': + specifier: ^24.2.0 + version: 24.12.0 + '@types/uuid': + specifier: ^8.3.0 + version: 8.3.4 + '@types/ws': + specifier: ^8.2.2 + version: 8.18.1 + mocha: + specifier: ^11.7.5 + version: 11.7.5 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + packages/mute-state-change: dependencies: bindings: @@ -4485,9 +4564,6 @@ packages: '@signalapp/minimask@1.0.1': resolution: {integrity: sha512-QAwo0joA60urTNbW9RIz6vLKQjy+jdVtH7cvY0wD9PVooD46MAjE40MLssp4xUJrph91n2XvtJ3pbEUDrmT2AA==, tarball: https://registry.npmjs.org/@signalapp/minimask/-/minimask-1.0.1.tgz} - '@signalapp/mock-server@25.2.0': - resolution: {integrity: sha512-QnJr1fYiVOgvL7OaY/Rrj6L5Hj01B8CsG6yNubpgms94eKleuOGT9VmbmltEt4sB9yitaOLgdnhXZpu+mi+woA==, tarball: https://registry.npmjs.org/@signalapp/mock-server/-/mock-server-25.2.0.tgz} - '@signalapp/parchment-cjs@3.0.1': resolution: {integrity: sha512-hSBMQ1M7wE4GcC8ZeNtvpJF+DAJg3eIRRf1SiHS3I3Algav/sgJJNm6HIYm6muHuK7IJmuEjkL3ILSXgmu0RfQ==, tarball: https://registry.npmjs.org/@signalapp/parchment-cjs/-/parchment-cjs-3.0.1.tgz} @@ -5091,12 +5167,21 @@ packages: '@types/lodash@4.14.106': resolution: {integrity: sha512-tOSvCVrvSqFZ4A/qrqqm6p37GZoawsZtoR0SJhlF7EonNZUgrn8FfT+RNQ11h+NUpMt6QVe36033f3qEKBwfWA==, tarball: https://registry.npmjs.org/@types/lodash/-/lodash-4.14.106.tgz} + '@types/long@4.0.2': + resolution: {integrity: sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==, tarball: https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz} + '@types/memoizee@0.4.12': resolution: {integrity: sha512-EdtpwNYNhe3kZ+4TlXj/++pvBoU0KdrAICMzgI7vjWgu9sIvvUhu9XR8Ks4L6Wh3sxpZ22wkZR7yCLAqUjnZuQ==, tarball: https://registry.npmjs.org/@types/memoizee/-/memoizee-0.4.12.tgz} + '@types/micro@7.3.7': + resolution: {integrity: sha512-MFsX7eCj0Tg3TtphOQvANNvNtFpya+s/rYOCdV6o+DFjOQPFi2EVRbBALjbbgZTXUaJP1Q281MJiJOD40d0UxQ==, tarball: https://registry.npmjs.org/@types/micro/-/micro-7.3.7.tgz} + '@types/micromatch@4.0.10': resolution: {integrity: sha512-5jOhFDElqr4DKTrTEbnW8DZ4Hz5LRUEmyrGpCMrD/NphYv3nUnaF08xmSLx1rGGnyEs/kFnhiw6dCgcDqMr5PQ==, tarball: https://registry.npmjs.org/@types/micromatch/-/micromatch-4.0.10.tgz} + '@types/microrouter@3.1.6': + resolution: {integrity: sha512-V+VX5guSnHIlECKnnnl72ZLeXJCJE5hZfI9/Y9CZ0Redmg+OxLHGRtEV3fjo4T3lGd3oQXpghndiQfanRocaIw==, tarball: https://registry.npmjs.org/@types/microrouter/-/microrouter-3.1.6.tgz} + '@types/mocha@10.0.10': resolution: {integrity: sha512-xPyYSz1cMPnJQhl0CLMH68j3gprKZaTjG3s5Vi+fDgx+uhG9NOXwbVt52eFS8ECyXhyKcjDLCBEqBExKuiZb7Q==, tarball: https://registry.npmjs.org/@types/mocha/-/mocha-10.0.10.tgz} @@ -5168,6 +5253,9 @@ packages: '@types/use-sync-external-store@0.0.6': resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==, tarball: https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz} + '@types/uuid@8.3.4': + resolution: {integrity: sha512-c/I8ZRb51j+pYGAu5CrFMRxqZ2ke4y2grEBO5AUjgSkSk+qT2Ea+OdWElz/OiMf5MNpn2b17kuVBwZLQJXzihw==, tarball: https://registry.npmjs.org/@types/uuid/-/uuid-8.3.4.tgz} + '@types/uuid@9.0.8': resolution: {integrity: sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==, tarball: https://registry.npmjs.org/@types/uuid/-/uuid-9.0.8.tgz} @@ -5180,6 +5268,9 @@ packages: '@types/write-file-atomic@4.0.3': resolution: {integrity: sha512-qdo+vZRchyJIHNeuI1nrpsLw+hnkgqP/8mlaN6Wle/NKhydHmUN9l4p3ZE8yP90AJNJW4uB8HQhedb4f1vNayQ==, tarball: https://registry.npmjs.org/@types/write-file-atomic/-/write-file-atomic-4.0.3.tgz} + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==, tarball: https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz} + '@types/yargs-parser@21.0.3': resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==, tarball: https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz} @@ -5429,10 +5520,12 @@ packages: '@xmldom/xmldom@0.8.13': resolution: {integrity: sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==, tarball: https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz} engines: {node: '>=10.0.0'} + deprecated: this version has critical issues, please update to the latest version '@xmldom/xmldom@0.9.10': resolution: {integrity: sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==, tarball: https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.10.tgz} engines: {node: '>=14.6'} + deprecated: this version has critical issues, please update to the latest version '@xtuc/ieee754@1.2.0': resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==, tarball: https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz} @@ -10846,6 +10939,11 @@ packages: engines: {node: '>=14.17'} hasBin: true + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==, tarball: https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz} + engines: {node: '>=14.17'} + hasBin: true + typescript@6.0.3: resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==, tarball: https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz} engines: {node: '>=14.17'} @@ -15261,28 +15359,6 @@ snapshots: '@signalapp/minimask@1.0.1': {} - '@signalapp/mock-server@25.2.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)': - dependencies: - '@indutny/parallel-prettier': 3.0.0(prettier@3.8.3) - '@indutny/protopiler': 4.0.0 - '@signalapp/libsignal-client': 0.92.2 - '@tus/file-store': 1.5.1 - '@tus/server': 1.10.2 - debug: 4.4.3(supports-color@8.1.1) - is-plain-obj: 3.0.0 - micro: 9.4.1 - microrouter: 3.1.3 - prettier: 3.8.3 - type-fest: 4.26.1 - url-pattern: 1.0.3 - uuid: 8.3.2 - ws: 8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10) - zod: 3.25.76 - transitivePeerDependencies: - - bufferutil - - supports-color - - utf-8-validate - '@signalapp/parchment-cjs@3.0.1': {} '@signalapp/quill-cjs@2.1.2': @@ -16002,12 +16078,24 @@ snapshots: '@types/lodash@4.14.106': {} + '@types/long@4.0.2': {} + '@types/memoizee@0.4.12': {} + '@types/micro@7.3.7': + dependencies: + '@types/node': 24.12.0 + '@types/micromatch@4.0.10': dependencies: '@types/braces': 3.0.5 + '@types/microrouter@3.1.6': + dependencies: + '@types/micro': 7.3.7 + '@types/node': 24.12.0 + url-pattern: 1.0.3 + '@types/mocha@10.0.10': {} '@types/ms@2.1.0': {} @@ -16079,6 +16167,8 @@ snapshots: '@types/use-sync-external-store@0.0.6': {} + '@types/uuid@8.3.4': {} + '@types/uuid@9.0.8': {} '@types/verror@1.10.10': @@ -16092,6 +16182,10 @@ snapshots: dependencies: '@types/node': 24.12.0 + '@types/ws@8.18.1': + dependencies: + '@types/node': 24.12.0 + '@types/yargs-parser@21.0.3': {} '@types/yargs@17.0.33': @@ -22731,6 +22825,8 @@ snapshots: typescript@5.6.1-rc: {} + typescript@5.9.3: {} + typescript@6.0.3: {} uc.micro@2.1.0: {} diff --git a/ts/services/username.preload.ts b/ts/services/username.preload.ts index 7d5ebb422b..6291ba8979 100644 --- a/ts/services/username.preload.ts +++ b/ts/services/username.preload.ts @@ -9,6 +9,7 @@ import { import { singleProtoJobQueue } from '../jobs/singleProtoJobQueue.preload.ts'; import { strictAssert } from '../util/assert.std.ts'; +import { SECOND } from '../util/durations/index.std.ts'; import { sleep } from '../util/sleep.std.ts'; import { getMinNickname, getMaxNickname } from '../util/Username.dom.ts'; import { bytesToUuid, uuidToBytes } from '../util/uuidToBytes.std.ts'; @@ -137,57 +138,52 @@ export async function reserveUsername( reservation: { previousUsername, username, hash: usernameHash }, }; } catch (error) { - if (error instanceof HTTPError) { - if (error.code === 422) { - return { ok: false, error: ReserveUsernameError.Unprocessable }; - } - if (error.code === 409) { + if (error instanceof LibSignalErrorBase) { + if (error.is(ErrorCode.UsernameNotAvailable)) { return { ok: false, error: ReserveUsernameError.Conflict }; } - if (error.code === 413 || error.code === 429) { + if (error.is(ErrorCode.RateLimitedError)) { return { ok: false, error: ReserveUsernameError.TooManyAttempts, }; } - } - if (error instanceof LibSignalErrorBase) { if ( - error.code === ErrorCode.NicknameCannotBeEmpty || - error.code === ErrorCode.NicknameTooShort + error.is(ErrorCode.NicknameCannotBeEmpty) || + error.is(ErrorCode.NicknameTooShort) ) { return { ok: false, error: ReserveUsernameError.NotEnoughCharacters, }; } - if (error.code === ErrorCode.NicknameTooLong) { + if (error.is(ErrorCode.NicknameTooLong)) { return { ok: false, error: ReserveUsernameError.TooManyCharacters, }; } - if (error.code === ErrorCode.CannotStartWithDigit) { + if (error.is(ErrorCode.CannotStartWithDigit)) { return { ok: false, error: ReserveUsernameError.CheckStartingCharacter, }; } - if (error.code === ErrorCode.BadNicknameCharacter) { + if (error.is(ErrorCode.BadNicknameCharacter)) { return { ok: false, error: ReserveUsernameError.CheckCharacters, }; } - if (error.code === ErrorCode.DiscriminatorCannotBeZero) { + if (error.is(ErrorCode.DiscriminatorCannotBeZero)) { return { ok: false, error: ReserveUsernameError.AllZeroDiscriminator, }; } - if (error.code === ErrorCode.DiscriminatorCannotHaveLeadingZeros) { + if (error.is(ErrorCode.DiscriminatorCannotHaveLeadingZeros)) { return { ok: false, error: ReserveUsernameError.LeadingZeroDiscriminator, @@ -195,10 +191,10 @@ export async function reserveUsername( } if ( - error.code === ErrorCode.DiscriminatorCannotBeEmpty || - error.code === ErrorCode.DiscriminatorCannotBeSingleDigit || + error.is(ErrorCode.DiscriminatorCannotBeEmpty) || + error.is(ErrorCode.DiscriminatorCannotBeSingleDigit) || // This is handled on UI level - error.code === ErrorCode.DiscriminatorTooLarge + error.is(ErrorCode.DiscriminatorTooLarge) ) { return { ok: false, @@ -312,6 +308,19 @@ export async function confirmUsername( return ConfirmUsernameResult.ConflictOrGone; } } + if (error instanceof LibSignalErrorBase) { + if (error.is(ErrorCode.RateLimitedError)) { + const time = error.retryAfterSecs * SECOND; + log.warn(`confirmUsername: rate limited, waiting ${time}ms`); + await sleep(time, abortSignal); + + return confirmUsername(reservation, abortSignal); + } + + if (error.is(ErrorCode.UsernameNotSet)) { + return ConfirmUsernameResult.ConflictOrGone; + } + } throw error; } diff --git a/ts/test-mock/pnp/username_test.node.ts b/ts/test-mock/pnp/username_test.node.ts index 12b12e9656..07f9937ae4 100644 --- a/ts/test-mock/pnp/username_test.node.ts +++ b/ts/test-mock/pnp/username_test.node.ts @@ -367,10 +367,9 @@ describe('pnp/username', function (this: Mocha.Suite) { const linkUrl = contactByEncryptedUsernameRoute .toWebUrl({ - encryptedUsername: Buffer.concat([ - entropy, - uuidToBytes(serverId), - ]).toString('base64url'), + encryptedUsername: Buffer.concat([entropy, serverId]).toString( + 'base64url' + ), }) .toString(); diff --git a/ts/textsecure/WebAPI.preload.ts b/ts/textsecure/WebAPI.preload.ts index 889230a3b4..0fd3cbef6e 100644 --- a/ts/textsecure/WebAPI.preload.ts +++ b/ts/textsecure/WebAPI.preload.ts @@ -41,10 +41,7 @@ import type { ExplodePromiseResultType } from '../util/explodePromise.std.ts'; import { explodePromise } from '../util/explodePromise.std.ts'; import { getUserAgent } from '../util/getUserAgent.node.ts'; import { getTimeoutStream } from '../util/getStreamWithTimeout.node.ts'; -import { - toWebSafeBase64, - fromWebSafeBase64, -} from '../util/webSafeBase64.std.ts'; +import { toWebSafeBase64 } from '../util/webSafeBase64.std.ts'; import { getBasicAuth } from '../util/getBasicAuth.std.ts'; import { createHTTPSAgent } from '../util/createHTTPSAgent.node.ts'; import { createProxyAgent } from '../util/createProxyAgent.node.ts'; @@ -821,10 +818,7 @@ const CHAT_CALLS = { subscriptions: 'v1/subscription', subscriptionConfiguration: 'v1/subscription/configuration', transferArchive: 'v1/devices/transfer_archive', - username: 'v1/accounts/username_hash', - reserveUsername: 'v1/accounts/username_hash/reserve', confirmUsername: 'v1/accounts/username_hash/confirm', - usernameLink: 'v1/accounts/username_link', whoami: 'v1/accounts/whoami', }; @@ -1063,16 +1057,6 @@ export type VerifyServiceIdResponseType = z.infer< typeof verifyServiceIdResponse >; -export type ReserveUsernameOptionsType = Readonly<{ - hashes: ReadonlyArray>; - abortSignal?: AbortSignal; -}>; - -export type ReplaceUsernameLinkOptionsType = Readonly<{ - encryptedUsername: Uint8Array; - keepLinkHandle: boolean; -}>; - export type ConfirmUsernameOptionsType = Readonly<{ hash: Uint8Array; proof: Uint8Array; @@ -1080,15 +1064,6 @@ export type ConfirmUsernameOptionsType = Readonly<{ abortSignal?: AbortSignal; }>; -const reserveUsernameResultZod = z.object({ - usernameHash: z - .string() - .transform(x => Bytes.fromBase64(fromWebSafeBase64(x))), -}); -export type ReserveUsernameResultType = z.infer< - typeof reserveUsernameResultZod ->; - const confirmUsernameResultZod = z.object({ usernameLinkHandle: z.string(), }); @@ -1096,13 +1071,6 @@ export type ConfirmUsernameResultType = z.infer< typeof confirmUsernameResultZod >; -const replaceUsernameLinkResultZod = z.object({ - usernameLinkHandle: z.string(), -}); -export type ReplaceUsernameLinkResultType = z.infer< - typeof replaceUsernameLinkResultZod ->; - export type ResolveUsernameByLinkOptionsType = Readonly<{ entropy: Uint8Array; uuid: string; @@ -2662,29 +2630,36 @@ export async function getAvatar( } export async function deleteUsername(abortSignal?: AbortSignal): Promise { - await _ajax({ - host: 'chatService', - call: 'username', - httpType: 'DELETE', - abortSignal, - }); + await _retry( + async () => { + const chat = await socketManager.getAuthenticatedApi(); + return chat.deleteUsernameHash({ abortSignal }); + }, + { abortSignal } + ); } export async function reserveUsername({ hashes, abortSignal, -}: ReserveUsernameOptionsType): Promise { - return _ajax({ - host: 'chatService', - call: 'reserveUsername', - httpType: 'PUT', - jsonData: { - usernameHashes: hashes.map(hash => toWebSafeBase64(Bytes.toBase64(hash))), +}: { + hashes: Array>; + abortSignal?: AbortSignal; +}): Promise<{ + usernameHash: Uint8Array; +}> { + const usernameHash = await _retry( + async () => { + const chat = await socketManager.getAuthenticatedApi(); + return chat.reserveUsernameHash( + { usernameHashes: hashes }, + { abortSignal } + ); }, - responseType: 'json', - abortSignal, - zodSchema: reserveUsernameResultZod, - }); + { abortSignal } + ); + + return { usernameHash }; } export async function confirmUsername({ hash, @@ -2710,20 +2685,21 @@ export async function confirmUsername({ export async function replaceUsernameLink({ encryptedUsername, keepLinkHandle, -}: ReplaceUsernameLinkOptionsType): Promise { - return _ajax({ - host: 'chatService', - call: 'usernameLink', - httpType: 'PUT', - responseType: 'json', - jsonData: { - usernameLinkEncryptedValue: toWebSafeBase64( - Bytes.toBase64(encryptedUsername) - ), +}: { + encryptedUsername: Uint8Array; + keepLinkHandle: boolean; +}): Promise<{ + usernameLinkHandle: string; +}> { + const usernameLinkHandle = await _retry(async () => { + const chat = await socketManager.getAuthenticatedApi(); + return chat.setUsernameLink({ + usernameCiphertext: encryptedUsername, keepLinkHandle, - }, - zodSchema: replaceUsernameLinkResultZod, + }); }); + + return { usernameLinkHandle }; } export async function resolveUsernameLink({ diff --git a/ts/util/lint/license_comments.node.ts b/ts/util/lint/license_comments.node.ts index 4b0d0ac71c..89c9b62976 100644 --- a/ts/util/lint/license_comments.node.ts +++ b/ts/util/lint/license_comments.node.ts @@ -39,6 +39,7 @@ const FILES_TO_IGNORE = [ '.github/ISSUE_TEMPLATE/bug_report.md', '.github/PULL_REQUEST_TEMPLATE.md', '.smartling-source.sh', + 'packages/mock-server/protos/README.md', 'packages/mute-state-change/dist/acknowledgments.md', 'packages/lame/dist/acknowledgments.md', 'sticker-creator/src/util/protos.d.ts',