Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e556f913f | ||
|
|
fb9770f781 | ||
|
|
6588848faa | ||
|
|
3ee74eac12 | ||
|
|
3f82acc35a | ||
|
|
f12ff0b3de | ||
|
|
5055037e59 | ||
|
|
d87d2d5dd9 | ||
|
|
805c07ca1b | ||
|
|
dfeafcb058 | ||
|
|
0631853445 | ||
|
|
dee38e55be | ||
|
|
e5c366be15 | ||
|
|
534206223d | ||
|
|
68bcf5d8ef | ||
|
|
a254dafa1c | ||
|
|
60903b3f0d | ||
|
|
7b8104d873 | ||
|
|
eec4d66f68 | ||
|
|
226c3c4f04 | ||
|
|
46e64649f9 | ||
|
|
5548fe2bb7 | ||
|
|
d7a1b5852c |
10
.github/workflows/publish.yaml
vendored
10
.github/workflows/publish.yaml
vendored
@ -24,6 +24,8 @@ jobs:
|
||||
- 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:
|
||||
@ -31,13 +33,13 @@ jobs:
|
||||
registry-url: 'https://registry.npmjs.org/'
|
||||
|
||||
- name: Install node_modules
|
||||
run: npm ci
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
run: pnpm run lint
|
||||
|
||||
- name: Test
|
||||
run: npm test
|
||||
run: pnpm test
|
||||
|
||||
- name: Publish
|
||||
run: npm publish --access public
|
||||
run: pnpm publish --access public --no-git-checks
|
||||
|
||||
8
.github/workflows/test.yaml
vendored
8
.github/workflows/test.yaml
vendored
@ -16,16 +16,18 @@ jobs:
|
||||
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: npm install
|
||||
run: pnpm install
|
||||
|
||||
- name: Run lint
|
||||
run: npm run lint
|
||||
run: pnpm run lint
|
||||
|
||||
- name: Run tests
|
||||
run: npm test
|
||||
run: pnpm test
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -9,6 +9,7 @@ test/**/*.js
|
||||
test/**/*.d.ts
|
||||
protos/*.js
|
||||
protos/*.d.ts
|
||||
protos/server/**/*.md
|
||||
scripts/**/*.js
|
||||
scripts/**/*.d.ts
|
||||
*.tsbuildinfo
|
||||
|
||||
3230
package-lock.json
generated
3230
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
14
package.json
14
package.json
@ -1,6 +1,7 @@
|
||||
{
|
||||
"name": "@signalapp/mock-server",
|
||||
"version": "18.3.0",
|
||||
"packageManager": "pnpm@10.18.1",
|
||||
"version": "22.0.0",
|
||||
"description": "Mock Signal Server for writing tests",
|
||||
"main": "src/index.js",
|
||||
"types": "src/index.d.ts",
|
||||
@ -14,7 +15,7 @@
|
||||
"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/*.proto",
|
||||
"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",
|
||||
@ -44,8 +45,8 @@
|
||||
"homepage": "https://github.com/signalapp/Mock-Signal-Server#readme",
|
||||
"dependencies": {
|
||||
"@indutny/parallel-prettier": "^3.0.0",
|
||||
"@indutny/protopiler": "3.2.1",
|
||||
"@signalapp/libsignal-client": "^0.89.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",
|
||||
@ -73,5 +74,10 @@
|
||||
"mocha": "^9.2.0",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.44.1"
|
||||
},
|
||||
"pnpm": {
|
||||
"patchedDependencies": {
|
||||
"@types/ws": "patches/@types__ws.patch"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
30
patches/@types__ws.patch
Normal file
30
patches/@types__ws.patch
Normal file
@ -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;
|
||||
2207
pnpm-lock.yaml
generated
Normal file
2207
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -86,7 +86,8 @@ message Group {
|
||||
bytes inviteLinkPassword = 10;
|
||||
bool announcements_only = 12;
|
||||
repeated MemberBanned members_banned = 13;
|
||||
// next: 14
|
||||
bool terminated = 14;
|
||||
// next: 15
|
||||
}
|
||||
|
||||
message GroupAttributeBlob {
|
||||
@ -237,6 +238,8 @@ message GroupChange {
|
||||
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.
|
||||
@ -246,7 +249,7 @@ message GroupChange {
|
||||
repeated AddMemberAction addMembers = 3;
|
||||
repeated DeleteMemberAction deleteMembers = 4;
|
||||
repeated ModifyMemberRoleAction modifyMemberRoles = 5;
|
||||
repeated ModifyMemberLabelAction modifyMemberLabels = 26; // change epoch = 6;
|
||||
repeated ModifyMemberLabelAction modifyMemberLabels = 26; // change epoch = 6
|
||||
repeated ModifyMemberProfileKeyAction modifyMemberProfileKeys = 6;
|
||||
repeated AddMemberPendingProfileKeyAction addMembersPendingProfileKey = 7;
|
||||
repeated DeleteMemberPendingProfileKeyAction deleteMembersPendingProfileKey = 8;
|
||||
@ -267,7 +270,8 @@ message GroupChange {
|
||||
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
|
||||
// next: 28
|
||||
TerminateGroupAction terminate_group = 28; // change epoch = 7
|
||||
// next: 29
|
||||
}
|
||||
|
||||
bytes actions = 1;
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
# Protobufs
|
||||
|
||||
Files in this directory are a copy of `protos` folder in [Signal-Desktop][0]
|
||||
repository.
|
||||
repository and `server` folder is from [Signal-Server][1].
|
||||
|
||||
[0]: https://github.com/signalapp/Signal-Desktop/tree/development/protos
|
||||
[0]: https://github.com/signalapp/Signal-Desktop/tree/main/protos
|
||||
[1]: https://github.com/signalapp/Signal-Server/tree/main/service/src/main/proto
|
||||
|
||||
128
protos/server/CallQualitySurveyPubSub.proto
Normal file
128
protos/server/CallQualitySurveyPubSub.proto
Normal file
@ -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;
|
||||
}
|
||||
15
protos/server/DisconnectionRequests.proto
Normal file
15
protos/server/DisconnectionRequests.proto
Normal file
@ -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;
|
||||
}
|
||||
62
protos/server/DonationsPubsub.proto
Normal file
62
protos/server/DonationsPubsub.proto
Normal file
@ -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;
|
||||
}
|
||||
399
protos/server/KeyTransparencyService.proto
Normal file
399
protos/server/KeyTransparencyService.proto
Normal file
@ -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;
|
||||
}
|
||||
24
protos/server/PubSubMessage.proto
Normal file
24
protos/server/PubSubMessage.proto
Normal file
@ -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;
|
||||
}
|
||||
431
protos/server/RegistrationService.proto
Normal file
431
protos/server/RegistrationService.proto
Normal file
@ -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;
|
||||
}
|
||||
77
protos/server/TextSecure.proto
Normal file
77
protos/server/TextSecure.proto
Normal file
@ -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;
|
||||
}
|
||||
39
protos/server/WebSocketConnectionEvent.proto
Normal file
39
protos/server/WebSocketConnectionEvent.proto
Normal file
@ -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 {
|
||||
}
|
||||
21
protos/server/google.proto
Normal file
21
protos/server/google.proto
Normal file
@ -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 {}
|
||||
267
protos/server/org/signal/chat/account.proto
Normal file
267
protos/server/org/signal/chat/account.proto
Normal file
@ -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"];
|
||||
}
|
||||
}
|
||||
39
protos/server/org/signal/chat/attachments.proto
Normal file
39
protos/server/org/signal/chat/attachments.proto
Normal file
@ -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"];
|
||||
}
|
||||
}
|
||||
539
protos/server/org/signal/chat/backups.proto
Normal file
539
protos/server/org/signal/chat/backups.proto
Normal file
@ -0,0 +1,539 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
syntax = "proto3";
|
||||
|
||||
option java_multiple_files = true;
|
||||
|
||||
package org.signal.chat.backup;
|
||||
|
||||
import "google/protobuf/empty.proto";
|
||||
import "org/signal/chat/common.proto";
|
||||
import "org/signal/chat/errors.proto";
|
||||
import "org/signal/chat/require.proto";
|
||||
import "org/signal/chat/tag.proto";
|
||||
|
||||
// Service for backup operations that require account authentication.
|
||||
//
|
||||
// Most actual backup operations operate on the backup-id and cannot be linked
|
||||
// to the caller's account, but setting up anonymous credentials and changing
|
||||
// backup tier requires account authentication.
|
||||
service Backups {
|
||||
option (require.auth) = AUTH_ONLY_AUTHENTICATED;
|
||||
|
||||
// Set (blinded) backup-id(s) for the account.
|
||||
//
|
||||
// Each account may have a single active backup-id for each credential type
|
||||
// that can be used to store and retrieve backups. Once the backup-id is set,
|
||||
// BackupAuthCredentials can be generated using GetBackupAuthCredentials.
|
||||
//
|
||||
// The blinded backup-id and the key-pair used to blind it must be derived
|
||||
// from a recoverable secret.
|
||||
//
|
||||
// At least one of the credential types must be set on the request.
|
||||
// Only the primary device can set a blinded backup-id.
|
||||
rpc SetBackupId(SetBackupIdRequest) returns (SetBackupIdResponse) {}
|
||||
|
||||
// Redeem a receipt acquired from /v1/subscription/{subscriberId}/receipt_credentials
|
||||
// to mark the account as eligible for the paid backup tier.
|
||||
//
|
||||
// After successful redemption, subsequent requests to
|
||||
// GetBackupAuthCredentials will return credentials with the level on the
|
||||
// provided receipt until the expiration time on the receipt.
|
||||
rpc RedeemReceipt(RedeemReceiptRequest) returns (RedeemReceiptResponse) {}
|
||||
|
||||
// After setting a blinded backup-id with PUT /v1/archives/, this fetches
|
||||
// credentials that can be used to perform operations against that backup-id.
|
||||
// Clients may (and should) request up to 7 days of credentials at a time.
|
||||
//
|
||||
// The redemption_start and redemption_end seconds must be UTC day aligned, and
|
||||
// must not span more than 7 days.
|
||||
//
|
||||
// Each credential contains a receipt level which indicates the backup level
|
||||
// the credential is good for. If the account has paid backup access that
|
||||
// expires at some point in the provided redemption window, credentials with
|
||||
// redemption times after the expiration may be on a lower backup level.
|
||||
//
|
||||
// Clients must validate the receipt level on the credential matches a known
|
||||
// receipt level before using it.
|
||||
rpc GetBackupAuthCredentials(GetBackupAuthCredentialsRequest) returns (GetBackupAuthCredentialsResponse) {}
|
||||
}
|
||||
|
||||
message SetBackupIdRequest {
|
||||
// A BackupAuthCredentialRequest containing a blinded encrypted backup-id,
|
||||
// encoded in standard padded base64. This backup-id should be used for
|
||||
// message backups only, and must have the message backup type set on the
|
||||
// credential. If absent, the message credential request will not be updated.
|
||||
bytes messages_backup_auth_credential_request = 1;
|
||||
|
||||
// A BackupAuthCredentialRequest containing a blinded encrypted backup-id,
|
||||
// encoded in standard padded base64. This backup-id should be used for
|
||||
// media only, and must have the media type set on the credential. If absent,
|
||||
// the media credential request will not be updated.
|
||||
bytes media_backup_auth_credential_request = 2;
|
||||
}
|
||||
|
||||
message SetBackupIdResponse {}
|
||||
|
||||
|
||||
message RedeemReceiptRequest {
|
||||
// Presentation for a previously acquired receipt, serialized with libsignal
|
||||
bytes presentation = 1;
|
||||
}
|
||||
|
||||
message RedeemReceiptResponse {
|
||||
oneof response {
|
||||
// The receipt was successfully redeemed
|
||||
google.protobuf.Empty success = 1;
|
||||
|
||||
// The target account does not have a backup-id commitment
|
||||
errors.FailedPrecondition account_missing_commitment = 2 [(tag.reason) = "account_missing_commitment"];
|
||||
|
||||
// The provided receipt presentation was malformed or expired
|
||||
errors.FailedPrecondition invalid_receipt = 3 [(tag.reason) = "invalid_receipt"];
|
||||
}
|
||||
}
|
||||
|
||||
message GetBackupAuthCredentialsRequest {
|
||||
// The redemption time for the first credential. This must be a day-aligned
|
||||
// seconds since epoch in UTC.
|
||||
int64 redemption_start = 1 [(require.range).min = 1];
|
||||
|
||||
// The redemption time for the last credential. This must be a day-aligned
|
||||
// seconds since epoch in UTC. The span between redemptionStart and
|
||||
// redemptionEnd must not exceed 7 days.
|
||||
int64 redemption_stop = 2 [(require.range).min = 1];
|
||||
}
|
||||
|
||||
message GetBackupAuthCredentialsResponse {
|
||||
message Credentials {
|
||||
// The requested message backup ZkCredentials indexed by the start of their
|
||||
// validity period. The smallest key should be for the requested
|
||||
// redemption_start, the largest for the requested redemption_end.
|
||||
map<int64, common.ZkCredential> message_credentials = 1;
|
||||
|
||||
// The requested media backup ZkCredentials indexed by the start of their
|
||||
// validity period. The smallest key should be for the requested
|
||||
// redemption_start, the largest for the requested redemption_end.
|
||||
map<int64, common.ZkCredential> media_credentials = 2;
|
||||
}
|
||||
|
||||
// The requested credentials. If absent, there was no existing blinded
|
||||
// backup id associated with the provided account.
|
||||
Credentials credentials = 1;
|
||||
}
|
||||
|
||||
// Service for backup operations with anonymous credentials
|
||||
//
|
||||
// This service never requires account authentication. It instead requires a
|
||||
// backup-id authenticated with an anonymous credential that cannot be linked
|
||||
// to the account.
|
||||
//
|
||||
// To register an anonymous credential:
|
||||
// 1. Set a backup-id on the authenticated channel via Backups::SetBackupId
|
||||
// 2. Retrieve BackupAuthCredentials via Backups::GetBackupAuthCredentials
|
||||
// 3. Generate a key pair and set the public key via
|
||||
// BackupsAnonymous::SetPublicKey
|
||||
//
|
||||
// Unless otherwise noted, requests for this service require a
|
||||
// SignedPresentation, which includes:
|
||||
// - a presentation generated from a BackupAuthCredential issued by
|
||||
// GetBackupAuthCredentials
|
||||
// - a signature of that presentation using the private key of a key pair
|
||||
// previously set with SetPublicKey.
|
||||
service BackupsAnonymous {
|
||||
option (require.auth) = AUTH_ONLY_ANONYMOUS;
|
||||
|
||||
// Retrieve credentials used to read objects stored on the backup cdn
|
||||
rpc GetCdnCredentials(GetCdnCredentialsRequest) returns (GetCdnCredentialsResponse) {}
|
||||
|
||||
// Retrieve credentials used to interact with the SecureValueRecoveryB service
|
||||
rpc GetSvrBCredentials(GetSvrBCredentialsRequest) returns (GetSvrBCredentialsResponse) {}
|
||||
|
||||
// Retrieve information about the currently stored message backup
|
||||
rpc GetMessageBackupInfo(GetBackupInfoRequest) returns (GetMessageBackupInfoResponse) {}
|
||||
|
||||
// Retrieve information about the currently stored media backup
|
||||
rpc GetMediaBackupInfo(GetBackupInfoRequest) returns (GetMediaBackupInfoResponse) {}
|
||||
|
||||
// Permanently set the public key of an ED25519 key-pair for the backup-id.
|
||||
// All requests (including this one!) must sign their BackupAuthCredential
|
||||
// presentations with the private key corresponding to the provided public key.
|
||||
rpc SetPublicKey(SetPublicKeyRequest) returns (SetPublicKeyResponse) {}
|
||||
|
||||
// Refresh the backup, indicating that the backup is still active. Clients
|
||||
// must periodically upload new backups or perform a refresh. If a backup has
|
||||
// not been active for 30 days, it may be deleted.
|
||||
rpc Refresh(RefreshRequest) returns (RefreshResponse) {}
|
||||
|
||||
// Retrieve an upload form that can be used to perform a resumable upload
|
||||
rpc GetUploadForm(GetUploadFormRequest) returns (GetUploadFormResponse) {}
|
||||
|
||||
// Copy and re-encrypt media from the attachments cdn into the backup cdn.
|
||||
// The original, already encrypted, attachments will be encrypted with the
|
||||
// provided key material before being copied.
|
||||
//
|
||||
// The copy operation is not atomic and responses will be returned as copy
|
||||
// operations complete with detailed information about the outcome. If an
|
||||
// error is encountered, not all requests may be reflected in the responses.
|
||||
//
|
||||
// On retries, a particular destination media id must not be reused with a
|
||||
// different source media id or different encryption parameters.
|
||||
rpc CopyMedia(CopyMediaRequest) returns (stream CopyMediaResponse) {}
|
||||
|
||||
// Retrieve a page of media objects stored for this backup-id. A client may
|
||||
// have previously stored media objects that are no longer referenced in their
|
||||
// current backup. To reclaim storage space used by these orphaned objects,
|
||||
// perform a list operation and remove any unreferenced media objects
|
||||
// via DeleteMedia.
|
||||
rpc ListMedia(ListMediaRequest) returns (ListMediaResponse) {}
|
||||
|
||||
// Delete media objects stored with this backup-id. Streams the locations of
|
||||
// media items back when the item has successfully been removed.
|
||||
rpc DeleteMedia(DeleteMediaRequest) returns (stream DeleteMediaResponse) {}
|
||||
|
||||
// Delete all backup metadata, objects, and stored public key. To use
|
||||
// backups again, a public key must be resupplied.
|
||||
rpc DeleteAll(DeleteAllRequest) returns (DeleteAllResponse) {}
|
||||
}
|
||||
|
||||
message SignedPresentation {
|
||||
// Presentation of a BackupAuthCredential previously retrieved from
|
||||
// GetBackupAuthCredentials on the authenticated channel
|
||||
bytes presentation = 1 [(require.nonEmpty) = true];
|
||||
|
||||
// The presentation signed with the private key corresponding to the public
|
||||
// key set with SetPublicKey
|
||||
bytes presentation_signature = 2 [(require.nonEmpty) = true];
|
||||
}
|
||||
|
||||
message SetPublicKeyRequest {
|
||||
SignedPresentation signed_presentation = 1;
|
||||
|
||||
// The public key, serialized in libsignal's elliptic-curve public key format.
|
||||
bytes public_key = 2 [(require.nonEmpty) = true];
|
||||
}
|
||||
|
||||
message SetPublicKeyResponse {
|
||||
oneof response {
|
||||
// The public key was successfully set
|
||||
google.protobuf.Empty success = 1;
|
||||
|
||||
// The provided backup auth credential presentation could not be
|
||||
// authenticated. Either, the presentation could not be verified, or
|
||||
// the public key signature was invalid, or there is no backup associated
|
||||
// with the backup-id in the presentation.
|
||||
//
|
||||
// This may also be returned if there was an existing public key and the
|
||||
// provided public key did not match.
|
||||
errors.FailedZkAuthentication failed_authentication = 2 [(tag.reason) = "failed_authentication"];
|
||||
}
|
||||
}
|
||||
|
||||
message GetCdnCredentialsRequest {
|
||||
SignedPresentation signed_presentation = 1;
|
||||
uint32 cdn = 2;
|
||||
}
|
||||
message GetCdnCredentialsResponse {
|
||||
message CdnCredentials {
|
||||
map<string, string> headers = 1;
|
||||
}
|
||||
oneof response {
|
||||
// Headers to include with requests to the read from the backup CDN. Includes
|
||||
// time limited read-only credentials.
|
||||
CdnCredentials cdn_credentials = 1;
|
||||
|
||||
// The provided backup auth credential presentation could not be
|
||||
// authenticated. Either, the presentation could not be verified, or
|
||||
// the public key signature was invalid, or there is no backup associated
|
||||
// with the backup-id in the presentation.
|
||||
errors.FailedZkAuthentication failed_authentication = 2 [(tag.reason) = "failed_authentication"];
|
||||
}
|
||||
}
|
||||
|
||||
message GetSvrBCredentialsRequest {
|
||||
SignedPresentation signed_presentation = 1;
|
||||
}
|
||||
|
||||
message GetSvrBCredentialsResponse {
|
||||
message SvrBCredentials {
|
||||
// A username that can be presented to authenticate with SVRB
|
||||
string username = 1;
|
||||
|
||||
// A password that can be presented to authenticate with SVRB
|
||||
string password = 2;
|
||||
}
|
||||
|
||||
oneof response {
|
||||
SvrBCredentials svrb_credentials = 1;
|
||||
|
||||
// The provided backup auth credential presentation could not be
|
||||
// authenticated. Either, the presentation could not be verified, or
|
||||
// the public key signature was invalid, or there is no backup associated
|
||||
// with the backup-id in the presentation.
|
||||
errors.FailedZkAuthentication failed_authentication = 2 [(tag.reason) = "failed_authentication"];
|
||||
}
|
||||
}
|
||||
|
||||
message GetBackupInfoRequest {
|
||||
SignedPresentation signed_presentation = 1;
|
||||
}
|
||||
message GetMessageBackupInfoResponse {
|
||||
message MessageBackupInfo {
|
||||
// The base directory of your backup data on the cdn. The message backup can
|
||||
// be found in the returned cdn at /backup_dir/backup_name
|
||||
string backup_dir = 1;
|
||||
|
||||
// The CDN type where the message backup is stored. Media may be stored
|
||||
// elsewhere.
|
||||
uint32 cdn = 2;
|
||||
|
||||
// The location of the message backup on the cdn. If a backup was previously
|
||||
// uploaded and unexpired, it can be found at /backup_dir/backup_name.
|
||||
string backup_name = 3;
|
||||
}
|
||||
|
||||
oneof response {
|
||||
MessageBackupInfo backup_info = 1;
|
||||
|
||||
// The provided backup auth credential presentation could not be
|
||||
// authenticated. Either, the presentation could not be verified, or
|
||||
// the public key signature was invalid, or there is no backup associated
|
||||
// with the backup-id in the presentation.
|
||||
errors.FailedZkAuthentication failed_authentication = 2 [(tag.reason) = "failed_authentication"];
|
||||
}
|
||||
}
|
||||
message GetMediaBackupInfoResponse {
|
||||
message MediaBackupInfo {
|
||||
// The base directory of your backup data on the cdn.
|
||||
string backup_dir = 1;
|
||||
|
||||
// The prefix path component for media objects on a cdn. Stored media for a
|
||||
// media_id can be found at /backup_dir/media_dir/media_id, where the media_id
|
||||
// is encoded in unpadded url-safe base64.
|
||||
string media_dir = 2;
|
||||
|
||||
// The amount of space used to store media
|
||||
uint64 used_space = 3;
|
||||
}
|
||||
|
||||
oneof response {
|
||||
MediaBackupInfo backup_info = 1;
|
||||
|
||||
// The provided backup auth credential presentation could not be
|
||||
// authenticated. Either, the presentation could not be verified, or
|
||||
// the public key signature was invalid, or there is no backup associated
|
||||
// with the backup-id in the presentation.
|
||||
errors.FailedZkAuthentication failed_authentication = 2 [(tag.reason) = "failed_authentication"];
|
||||
}
|
||||
}
|
||||
|
||||
message RefreshRequest {
|
||||
SignedPresentation signed_presentation = 1;
|
||||
}
|
||||
message RefreshResponse {
|
||||
oneof response {
|
||||
// The backup was successfully refreshed
|
||||
google.protobuf.Empty success = 1;
|
||||
|
||||
// The provided backup auth credential presentation could not be
|
||||
// authenticated. Either, the presentation could not be verified, or
|
||||
// the public key signature was invalid, or there is no backup associated
|
||||
// with the backup-id in the presentation.
|
||||
errors.FailedZkAuthentication failed_authentication = 2 [(tag.reason) = "failed_authentication"];
|
||||
}
|
||||
}
|
||||
|
||||
message GetUploadFormRequest {
|
||||
SignedPresentation signed_presentation = 1;
|
||||
|
||||
message MessagesUploadType {}
|
||||
message MediaUploadType {}
|
||||
oneof upload_type {
|
||||
// Retrieve an upload form that can be used to perform a resumable upload of
|
||||
// a message backup. The finished upload will be available on the backup cdn.
|
||||
MessagesUploadType messages = 2;
|
||||
|
||||
// Retrieve an upload form for a temporary location that can be used to
|
||||
// perform a resumable upload of an attachment. After uploading, the
|
||||
// attachment can be copied into the backup via CopyMedia.
|
||||
//
|
||||
// Behaves identically to the account authenticated version at /attachments.
|
||||
MediaUploadType media = 3;
|
||||
}
|
||||
|
||||
// The length of the attachment for the requested upload form. Uploads
|
||||
// performed with this form will be limited to the provided length.
|
||||
uint64 uploadLength = 4 [(require.range) = {min: 1}];
|
||||
}
|
||||
message GetUploadFormResponse {
|
||||
oneof response {
|
||||
common.UploadForm upload_form = 1;
|
||||
|
||||
// The provided backup auth credential presentation could not be
|
||||
// authenticated. Either, the presentation could not be verified, or
|
||||
// the public key signature was invalid, or there is no backup associated
|
||||
// with the backup-id in the presentation.
|
||||
errors.FailedZkAuthentication failed_authentication = 2 [(tag.reason) = "failed_authentication"];
|
||||
|
||||
// The request size was larger than the maximum supported upload size. The
|
||||
// maximum upload size is subject to change and is governed by
|
||||
// `global.attachments.maxBytes`
|
||||
errors.FailedPrecondition exceeds_max_upload_length = 3 [(tag.reason) = "oversize_upload"];
|
||||
}
|
||||
}
|
||||
|
||||
message CopyMediaItem {
|
||||
// The attachment cdn of the object to copy into the backup
|
||||
uint32 source_attachment_cdn = 1 [(require.range).min = 1, (require.range).max = 3];
|
||||
|
||||
// The attachment key of the object to copy into the backup
|
||||
string source_key = 2 [(require.nonEmpty) = true];
|
||||
|
||||
// The length of the source attachment before the encryption applied by the
|
||||
// copy operation
|
||||
uint32 object_length = 3;
|
||||
|
||||
// media_id to copy on to the backup CDN
|
||||
bytes media_id = 4 [(require.exactlySize) = 15];
|
||||
|
||||
// A 32-byte key for the MAC
|
||||
bytes hmac_key = 5 [(require.exactlySize) = 32];
|
||||
|
||||
// A 32-byte encryption key for AES
|
||||
bytes encryption_key = 6 [(require.exactlySize) = 32];
|
||||
}
|
||||
|
||||
message CopyMediaRequest {
|
||||
SignedPresentation signed_presentation = 1;
|
||||
|
||||
// Items to copy
|
||||
repeated CopyMediaItem items = 2 [(require.size) = {min: 1, max: 1000}];
|
||||
}
|
||||
|
||||
message CopyMediaResponse {
|
||||
message SourceNotFound {}
|
||||
message WrongSourceLength {}
|
||||
message OutOfSpace {}
|
||||
message CopySuccess {
|
||||
// The backup cdn where this media object is stored
|
||||
uint32 cdn = 1;
|
||||
}
|
||||
|
||||
// The 15-byte media_id from the corresponding CopyMediaItem in the request
|
||||
bytes media_id = 1;
|
||||
|
||||
oneof response {
|
||||
// The media item was successfully copied into the backup
|
||||
CopySuccess success = 2;
|
||||
|
||||
// The provided backup auth credential presentation could not be
|
||||
// authenticated. Either, the presentation could not be verified, or
|
||||
// the public key signature was invalid, or there is no backup associated
|
||||
// with the backup-id in the presentation.
|
||||
errors.FailedZkAuthentication failed_authentication = 3 [(tag.reason) = "failed_authentication"];
|
||||
|
||||
// The source object was not found
|
||||
SourceNotFound source_not_found = 4 [(tag.reason) = "source_not_found"];
|
||||
|
||||
// The provided object length was incorrect
|
||||
WrongSourceLength wrong_source_length = 5 [(tag.reason) = "wrong_source_length"];
|
||||
|
||||
// All media capacity has been consumed. Free some space to continue.
|
||||
OutOfSpace out_of_space = 6 [(tag.reason) = "out_of_space"];
|
||||
}
|
||||
}
|
||||
|
||||
message ListMediaRequest {
|
||||
SignedPresentation signed_presentation = 1;
|
||||
|
||||
// A cursor returned by a previous call to ListMedia, absent on the first call
|
||||
optional string cursor = 2;
|
||||
|
||||
// If provided, the maximum number of entries to return in a page
|
||||
uint32 limit = 3 [(require.range) = {min: 1, max: 10000}];
|
||||
}
|
||||
message ListMediaResponse {
|
||||
message ListEntry {
|
||||
// The backup cdn where this media object is stored
|
||||
uint32 cdn = 1;
|
||||
// The media_id of the object
|
||||
bytes media_id = 2;
|
||||
// The length of the object in bytes
|
||||
uint64 length = 3;
|
||||
}
|
||||
|
||||
message ListResult {
|
||||
|
||||
// A page of media objects stored for this backup ID
|
||||
repeated ListEntry page = 1;
|
||||
|
||||
// The base directory of the backup data on the cdn. The stored media can be
|
||||
// found at /backup_dir/media_dir/media_id, where the media_id is encoded with
|
||||
// unpadded url-safe base64.
|
||||
string backup_dir = 2;
|
||||
|
||||
// The prefix path component for the media objects. The stored media for
|
||||
// media_id can be found at /backup_dir/media_dir/media_id, where the media_id
|
||||
// is encoded with unpadded url-safe base64.
|
||||
string media_dir = 3;
|
||||
|
||||
// If set, the cursor value to pass to the next list request to continue
|
||||
// listing. If absent, all objects have been listed
|
||||
optional string cursor = 4;
|
||||
}
|
||||
|
||||
oneof response {
|
||||
ListResult list_result = 1;
|
||||
|
||||
// The provided backup auth credential presentation could not be
|
||||
// authenticated. Either, the presentation could not be verified, or
|
||||
// the public key signature was invalid, or there is no backup associated
|
||||
// with the backup-id in the presentation.
|
||||
errors.FailedZkAuthentication failed_authentication = 2 [(tag.reason) = "failed_authentication"];
|
||||
}
|
||||
}
|
||||
|
||||
message DeleteAllRequest {
|
||||
SignedPresentation signed_presentation = 1;
|
||||
}
|
||||
message DeleteAllResponse {
|
||||
oneof response {
|
||||
// The backup was successfully scheduled for deletion
|
||||
google.protobuf.Empty success = 1;
|
||||
|
||||
// The provided backup auth credential presentation could not be
|
||||
// authenticated. Either, the presentation could not be verified, or
|
||||
// the public key signature was invalid, or there is no backup associated
|
||||
// with the backup-id in the presentation.
|
||||
errors.FailedZkAuthentication failed_authentication = 2 [(tag.reason) = "failed_authentication"];
|
||||
}
|
||||
}
|
||||
|
||||
message DeleteMediaItem {
|
||||
// The backup cdn where this media object is stored
|
||||
uint32 cdn = 1;
|
||||
|
||||
// The media_id of the object to delete
|
||||
bytes media_id = 2 [(require.exactlySize) = 15];
|
||||
}
|
||||
|
||||
message DeleteMediaRequest {
|
||||
SignedPresentation signed_presentation = 1;
|
||||
|
||||
repeated DeleteMediaItem items = 2 [(require.size) = {min: 1, max: 1000}];
|
||||
}
|
||||
|
||||
message DeleteMediaResponse {
|
||||
oneof response {
|
||||
DeleteMediaItem deleted_item = 1;
|
||||
|
||||
// The provided backup auth credential presentation could not be
|
||||
// authenticated. Either, the presentation could not be verified, or
|
||||
// the public key signature was invalid, or there is no backup associated
|
||||
// with the backup-id in the presentation.
|
||||
errors.FailedZkAuthentication failed_authentication = 2 [(tag.reason) = "failed_authentication"];
|
||||
}
|
||||
}
|
||||
110
protos/server/org/signal/chat/call_quality.proto
Normal file
110
protos/server/org/signal/chat/call_quality.proto
Normal file
@ -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 {
|
||||
}
|
||||
31
protos/server/org/signal/chat/calling.proto
Normal file
31
protos/server/org/signal/chat/calling.proto
Normal file
@ -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;
|
||||
}
|
||||
115
protos/server/org/signal/chat/common.proto
Normal file
115
protos/server/org/signal/chat/common.proto
Normal file
@ -0,0 +1,115 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
syntax = "proto3";
|
||||
|
||||
option java_multiple_files = true;
|
||||
|
||||
package org.signal.chat.common;
|
||||
|
||||
import "org/signal/chat/require.proto";
|
||||
|
||||
enum IdentityType {
|
||||
IDENTITY_TYPE_UNSPECIFIED = 0;
|
||||
IDENTITY_TYPE_ACI = 1;
|
||||
IDENTITY_TYPE_PNI = 2;
|
||||
}
|
||||
|
||||
message ServiceIdentifier {
|
||||
// The type of identity represented by this service identifier.
|
||||
IdentityType identity_type = 1;
|
||||
|
||||
// The UUID of the identity represented by this service identifier.
|
||||
bytes uuid = 2 [(require.exactlySize) = 16];
|
||||
}
|
||||
|
||||
message AccountIdentifiers {
|
||||
// A list of service identifiers for the identified account.
|
||||
repeated ServiceIdentifier service_identifiers = 1;
|
||||
|
||||
// The phone number associated with the identified account.
|
||||
string e164 = 2;
|
||||
|
||||
// The username hash (if any) associated with the identified account. May be
|
||||
// empty if no username is associated with the identified account.
|
||||
bytes username_hash = 3;
|
||||
}
|
||||
|
||||
message EcPreKey {
|
||||
// A locally-unique identifier for this key, which will be provided by
|
||||
// peers using this key to encrypt messages so the private key can be looked
|
||||
// up.
|
||||
uint32 key_id = 1;
|
||||
|
||||
// The public key, serialized in libsignal's elliptic-curve public key format.
|
||||
bytes public_key = 2 [(require.nonEmpty) = true];
|
||||
}
|
||||
|
||||
message EcSignedPreKey {
|
||||
// A locally-unique identifier for this key, which will be provided by
|
||||
// peers using this key to encrypt messages so the private key can be looked
|
||||
// up.
|
||||
uint32 key_id = 1;
|
||||
|
||||
// The public key, serialized in libsignal's elliptic-curve public key format.
|
||||
bytes public_key = 2 [(require.nonEmpty) = true];
|
||||
|
||||
// A signature of the public key, verifiable with the identity key for the
|
||||
// account/identity associated with this pre-key.
|
||||
bytes signature = 3 [(require.nonEmpty) = true];
|
||||
}
|
||||
|
||||
message KemSignedPreKey {
|
||||
// An locally-unique identifier for this key, which will be provided by peers
|
||||
// using this key to encrypt messages so the private key can be looked up.
|
||||
uint32 key_id = 1;
|
||||
|
||||
// The public key, serialized in libsignal's Kyber1024 public key format.
|
||||
bytes public_key = 2 [(require.nonEmpty) = true];
|
||||
|
||||
// A signature of the public key, verifiable with the identity key for the
|
||||
// account/identity associated with this pre-key.
|
||||
bytes signature = 3 [(require.nonEmpty) = true];
|
||||
}
|
||||
|
||||
enum DeviceCapability {
|
||||
DEVICE_CAPABILITY_UNSPECIFIED = 0;
|
||||
DEVICE_CAPABILITY_STORAGE = 1;
|
||||
DEVICE_CAPABILITY_TRANSFER = 2;
|
||||
reserved 3;
|
||||
reserved 4;
|
||||
reserved 5;
|
||||
DEVICE_CAPABILITY_ATTACHMENT_BACKFILL = 6;
|
||||
DEVICE_CAPABILITY_SPARSE_POST_QUANTUM_RATCHET = 7;
|
||||
}
|
||||
|
||||
message ZkCredential {
|
||||
/*
|
||||
* Day on which this credential can be redeemed, in UTC seconds since epoch
|
||||
*/
|
||||
int64 redemption_time = 1;
|
||||
|
||||
/*
|
||||
* The ZK credential, using libsignal's serialization
|
||||
*/
|
||||
bytes credential = 2 [(require.nonEmpty) = true];
|
||||
}
|
||||
|
||||
// An upload location and credentials which may be used to upload an object
|
||||
// to an external CDN
|
||||
message UploadForm {
|
||||
// Indicates the CDN type. 3 indicates resumable uploads using TUS
|
||||
uint32 cdn = 1;
|
||||
|
||||
// The location within the specified cdn where the finished upload can be found
|
||||
string key = 2;
|
||||
|
||||
// A map of headers to include with all upload requests. Potentially contains
|
||||
// time-limited upload credentials
|
||||
map<string, string> headers = 3;
|
||||
|
||||
// The URL to upload to with the appropriate protocol
|
||||
string signed_upload_location = 4;
|
||||
}
|
||||
81
protos/server/org/signal/chat/credentials.proto
Normal file
81
protos/server/org/signal/chat/credentials.proto
Normal file
@ -0,0 +1,81 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
syntax = "proto3";
|
||||
|
||||
option java_multiple_files = true;
|
||||
|
||||
import "org/signal/chat/require.proto";
|
||||
|
||||
package org.signal.chat.credentials;
|
||||
|
||||
// Provides methods for obtaining and verifying credentials for "external" services
|
||||
// (i.e. services that are not a part of the chat server deployment).
|
||||
// All methods of this service require authentication.
|
||||
service ExternalServiceCredentials {
|
||||
|
||||
// Generates and returns an external service credentials for the caller.
|
||||
rpc GetExternalServiceCredentials(GetExternalServiceCredentialsRequest)
|
||||
returns (GetExternalServiceCredentialsResponse) {}
|
||||
}
|
||||
|
||||
service ExternalServiceCredentialsAnonymous {
|
||||
// Given a list of secure value recovery (SVR) service credentials and a phone number,
|
||||
// checks, which of the provided credentials were generated by the user with the given phone number
|
||||
// and have not yet expired.
|
||||
rpc CheckSvrCredentials(CheckSvrCredentialsRequest)
|
||||
returns (CheckSvrCredentialsResponse) {}
|
||||
}
|
||||
|
||||
enum ExternalServiceType {
|
||||
EXTERNAL_SERVICE_TYPE_UNSPECIFIED = 0;
|
||||
EXTERNAL_SERVICE_TYPE_DIRECTORY = 1;
|
||||
EXTERNAL_SERVICE_TYPE_PAYMENTS = 2;
|
||||
EXTERNAL_SERVICE_TYPE_STORAGE = 3;
|
||||
EXTERNAL_SERVICE_TYPE_SVR = 4;
|
||||
}
|
||||
|
||||
message GetExternalServiceCredentialsRequest {
|
||||
// A service to request credentials for.
|
||||
ExternalServiceType externalService = 1;
|
||||
}
|
||||
|
||||
message GetExternalServiceCredentialsResponse {
|
||||
// A username that can be presented to authenticate with the external service.
|
||||
string username = 1;
|
||||
|
||||
// A password that can be presented to authenticate with the external service.
|
||||
string password = 2;
|
||||
}
|
||||
|
||||
enum AuthCheckResult {
|
||||
AUTH_CHECK_RESULT_UNSPECIFIED = 0;
|
||||
// The credentials could be used to make a call to SVR service by the user
|
||||
// associated with the `CheckSvrCredentialsRequest.number` phone number.
|
||||
AUTH_CHECK_RESULT_MATCH = 1;
|
||||
// The credentials were generated by a different user.
|
||||
AUTH_CHECK_RESULT_NO_MATCH = 2;
|
||||
// This status indicates that the corresponding credentials token should no longer be used.
|
||||
// This may be because it has expired or invalid, but it can also mean that there is a more
|
||||
// recent token in the request which should be used instead.
|
||||
AUTH_CHECK_RESULT_INVALID = 3;
|
||||
}
|
||||
|
||||
message CheckSvrCredentialsRequest {
|
||||
// A phone number in the E164 format to check the passwords against.
|
||||
// Only passwords generated for the user associated with the given number will be marked as `AUTH_CHECK_RESULT_MATCH`.
|
||||
string number = 1;
|
||||
|
||||
// A list of credentials from previously made calls to `ExternalServiceCredentials.GetExternalServiceCredentials()`
|
||||
// for `EXTERNAL_SERVICE_TYPE_SVR`. This list may contain credentials generated by different users. Up to 10 credentials
|
||||
// can be checked.
|
||||
repeated string passwords = 2 [(require.nonEmpty) = true, (require.size) = {max: 10}];
|
||||
}
|
||||
|
||||
// For each of the credentials tokens in the `CheckSvrCredentialsRequest` contains the result of the check.
|
||||
message CheckSvrCredentialsResponse {
|
||||
|
||||
map<string, AuthCheckResult> matches = 1;
|
||||
}
|
||||
136
protos/server/org/signal/chat/device.proto
Normal file
136
protos/server/org/signal/chat/device.proto
Normal file
@ -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 {}
|
||||
34
protos/server/org/signal/chat/errors.proto
Normal file
34
protos/server/org/signal/chat/errors.proto
Normal file
@ -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;
|
||||
}
|
||||
223
protos/server/org/signal/chat/keys.proto
Normal file
223
protos/server/org/signal/chat/keys.proto
Normal file
@ -0,0 +1,223 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
syntax = "proto3";
|
||||
|
||||
option java_multiple_files = true;
|
||||
|
||||
package org.signal.chat.keys;
|
||||
|
||||
import "google/protobuf/empty.proto";
|
||||
|
||||
import "org/signal/chat/common.proto";
|
||||
import "org/signal/chat/errors.proto";
|
||||
import "org/signal/chat/require.proto";
|
||||
import "org/signal/chat/tag.proto";
|
||||
|
||||
// Provides methods for working with pre-keys.
|
||||
service Keys {
|
||||
|
||||
// Retrieves an approximate count of the number of the various kinds of
|
||||
// pre-keys stored for the authenticated device.
|
||||
rpc GetPreKeyCount (GetPreKeyCountRequest) returns (GetPreKeyCountResponse) {}
|
||||
|
||||
// Retrieves a set of pre-keys for establishing a session with the targeted
|
||||
// device or devices. Note that callers with an unidentified access key for
|
||||
// the targeted account should use the version of this method in
|
||||
// `KeysAnonymous` instead.
|
||||
rpc GetPreKeys(GetPreKeysRequest) returns (GetPreKeysResponse) {}
|
||||
|
||||
// Uploads a new set of one-time EC pre-keys for the authenticated device,
|
||||
// clearing any previously-stored pre-keys. Note that all keys submitted via
|
||||
// a single call to this method _must_ have the same identity type (i.e. if
|
||||
// the first key has an ACI identity type, then all other keys in the same
|
||||
// stream must also have an ACI identity type). The provided list of pre-keys
|
||||
// must be non-empty.
|
||||
rpc SetOneTimeEcPreKeys (SetOneTimeEcPreKeysRequest) returns (SetPreKeyResponse) {}
|
||||
|
||||
// Uploads a new set of one-time KEM pre-keys for the authenticated device,
|
||||
// clearing any previously-stored pre-keys. Note that all keys submitted via
|
||||
// a single call to this method _must_ have the same identity type (i.e. if
|
||||
// the first key has an ACI identity type, then all other keys in the same
|
||||
// stream must also have an ACI identity type). The provided list of pre-keys
|
||||
// must be non-empty.
|
||||
rpc SetOneTimeKemSignedPreKeys (SetOneTimeKemSignedPreKeysRequest) returns (SetPreKeyResponse) {}
|
||||
|
||||
// Sets the signed EC pre-key for one identity (i.e. ACI or PNI) associated
|
||||
// with the authenticated device.
|
||||
rpc SetEcSignedPreKey (SetEcSignedPreKeyRequest) returns (SetPreKeyResponse) {}
|
||||
|
||||
// Sets the last-resort KEM pre-key for one identity (i.e. ACI or PNI)
|
||||
// associated with the authenticated device.
|
||||
rpc SetKemLastResortPreKey (SetKemLastResortPreKeyRequest) returns (SetPreKeyResponse) {}
|
||||
}
|
||||
|
||||
// Provides methods for working with pre-keys using "unidentified access"
|
||||
// credentials.
|
||||
service KeysAnonymous {
|
||||
|
||||
// Retrieves a set of pre-keys for establishing a session with the targeted
|
||||
// device or devices. Callers must not submit any self-identifying credentials
|
||||
// when calling this method and must instead present the targeted account's
|
||||
// unidentified access key as an anonymous authentication mechanism. Callers
|
||||
// without an unidentified access key should use the equivalent, authenticated
|
||||
// method in `Keys` instead.
|
||||
rpc GetPreKeys(GetPreKeysAnonymousRequest) returns (GetPreKeysAnonymousResponse) {}
|
||||
|
||||
// Checks identity key fingerprints of the target accounts.
|
||||
//
|
||||
// Returns a stream of elements, each one representing an account that had a mismatched
|
||||
// identity key fingerprint with the server and the corresponding identity key stored by the server.
|
||||
rpc CheckIdentityKeys(stream CheckIdentityKeyRequest) returns (stream CheckIdentityKeyResponse) {}
|
||||
}
|
||||
|
||||
message GetPreKeyCountRequest {
|
||||
}
|
||||
|
||||
message GetPreKeyCountResponse {
|
||||
// The approximate number of one-time EC pre-keys stored for the
|
||||
// authenticated device and associated with the caller's ACI.
|
||||
uint32 aci_ec_pre_key_count = 1;
|
||||
|
||||
// The approximate number of one-time Kyber pre-keys stored for the
|
||||
// authenticated device and associated with the caller's ACI.
|
||||
uint32 aci_kem_pre_key_count = 2;
|
||||
|
||||
// The approximate number of one-time EC pre-keys stored for the
|
||||
// authenticated device and associated with the caller's PNI.
|
||||
uint32 pni_ec_pre_key_count = 3;
|
||||
|
||||
// The approximate number of one-time KEM pre-keys stored for the
|
||||
// authenticated device and associated with the caller's PNI.
|
||||
uint32 pni_kem_pre_key_count = 4;
|
||||
}
|
||||
|
||||
message GetPreKeysRequest {
|
||||
// The service identifier of the account for which to retrieve pre-keys.
|
||||
common.ServiceIdentifier target_identifier = 1;
|
||||
|
||||
// The ID of the device associated with the targeted account for which to
|
||||
// retrieve pre-keys. If not set, pre-keys are returned for all devices
|
||||
// associated with the targeted account.
|
||||
optional uint32 device_id = 2;
|
||||
}
|
||||
|
||||
message GetPreKeysAnonymousRequest {
|
||||
// The request to retrieve pre-keys for a specific account/device(s).
|
||||
GetPreKeysRequest request = 1;
|
||||
|
||||
// A means to authorize the request.
|
||||
oneof authorization {
|
||||
// The unidentified access key (UAK) for the targeted account.
|
||||
bytes unidentified_access_key = 2;
|
||||
|
||||
// A group send endorsement token for the targeted account.
|
||||
bytes group_send_token = 3;
|
||||
|
||||
// The destination account allows unrestricted unidentified access
|
||||
google.protobuf.Empty unrestricted_access = 4;
|
||||
}
|
||||
}
|
||||
|
||||
message DevicePreKeyBundle {
|
||||
// The EC signed pre-key associated with the targeted
|
||||
// account/device/identity.
|
||||
common.EcSignedPreKey ec_signed_pre_key = 1;
|
||||
|
||||
// A one-time EC pre-key for the targeted account/device/identity. May not
|
||||
// be set if no one-time EC pre-keys are available.
|
||||
common.EcPreKey ec_one_time_pre_key = 2;
|
||||
|
||||
// A one-time KEM pre-key (or a last-resort KEM pre-key) for the targeted
|
||||
// account/device/identity.
|
||||
common.KemSignedPreKey kem_one_time_pre_key = 3;
|
||||
|
||||
// The registration ID for the targeted account/device/identity.
|
||||
uint32 registration_id = 4;
|
||||
}
|
||||
|
||||
message AccountPreKeyBundles {
|
||||
// The identity key associated with the targeted account/identity.
|
||||
bytes identity_key = 1;
|
||||
|
||||
// A map of device IDs to pre-key "bundles" for the targeted account.
|
||||
map<uint32, DevicePreKeyBundle> device_pre_keys = 2;
|
||||
}
|
||||
|
||||
message GetPreKeysResponse {
|
||||
oneof response {
|
||||
// The requested pre-key bundles
|
||||
AccountPreKeyBundles pre_keys = 1;
|
||||
|
||||
// Either the target account was not found, no active device with the given
|
||||
// ID (if specified) was found on the target account.
|
||||
errors.NotFound target_not_found = 2 [(tag.reason) = "not_found"];
|
||||
}
|
||||
}
|
||||
|
||||
message GetPreKeysAnonymousResponse {
|
||||
oneof response {
|
||||
// The requested pre-key bundles
|
||||
AccountPreKeyBundles pre_keys = 1;
|
||||
|
||||
// Either the target account was not found, no active device with the given
|
||||
// ID (if specified) was found on the target account.
|
||||
errors.NotFound target_not_found = 2 [(tag.reason) = "not_found"];
|
||||
|
||||
// The provided unidentified authorization credential was invalid
|
||||
errors.FailedUnidentifiedAuthorization failed_unidentified_authorization = 3 [(tag.reason) = "failed_unidentified_authorization"];
|
||||
}
|
||||
}
|
||||
|
||||
message SetOneTimeEcPreKeysRequest {
|
||||
// The identity type (i.e. ACI/PNI) with which the keys in this request are
|
||||
// associated.
|
||||
common.IdentityType identity_type = 1;
|
||||
|
||||
// The unsigned EC pre-keys to be stored.
|
||||
repeated common.EcPreKey pre_keys = 2 [(require.size) = {min: 1, max: 100}];
|
||||
}
|
||||
|
||||
message SetOneTimeKemSignedPreKeysRequest {
|
||||
// The identity type (i.e. ACI/PNI) with which the keys in this request are
|
||||
// associated.
|
||||
common.IdentityType identity_type = 1;
|
||||
|
||||
// The KEM pre-keys to be stored.
|
||||
repeated common.KemSignedPreKey pre_keys = 2 [(require.size) = {min: 1, max: 100}];
|
||||
}
|
||||
|
||||
message SetEcSignedPreKeyRequest {
|
||||
// The identity type (i.e. ACI/PNI) with which this key is associated.
|
||||
common.IdentityType identity_type = 1;
|
||||
|
||||
// The signed EC pre-key itself.
|
||||
common.EcSignedPreKey signed_pre_key = 2 [(require.present) = true];
|
||||
}
|
||||
|
||||
message SetKemLastResortPreKeyRequest {
|
||||
// The identity type (i.e. ACI/PNI) with which this key is associated.
|
||||
common.IdentityType identity_type = 1;
|
||||
|
||||
// The signed KEM pre-key itself.
|
||||
common.KemSignedPreKey signed_pre_key = 2 [(require.present) = true];
|
||||
}
|
||||
|
||||
message SetPreKeyResponse {
|
||||
}
|
||||
|
||||
message CheckIdentityKeyRequest {
|
||||
// The service identifier of the account for which we want to check the associated identity key fingerprint.
|
||||
common.ServiceIdentifier target_identifier = 1;
|
||||
// The most significant 4 bytes of the SHA-256 hash of the identity key associated with the target account/identity type.
|
||||
bytes fingerprint = 2 [(require.exactlySize) = 4];
|
||||
}
|
||||
|
||||
message CheckIdentityKeyResponse {
|
||||
// The service identifier of the account for which there is a mismatch between the client and server identity key fingerprints.
|
||||
common.ServiceIdentifier target_identifier = 1;
|
||||
// The identity key that is stored by the server for the target account/identity type.
|
||||
bytes identity_key = 2;
|
||||
}
|
||||
376
protos/server/org/signal/chat/messages.proto
Normal file
376
protos/server/org/signal/chat/messages.proto
Normal file
@ -0,0 +1,376 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
syntax = "proto3";
|
||||
|
||||
option java_multiple_files = true;
|
||||
|
||||
package org.signal.chat.messages;
|
||||
|
||||
import "google/protobuf/empty.proto";
|
||||
|
||||
import "org/signal/chat/common.proto";
|
||||
import "org/signal/chat/require.proto";
|
||||
import "org/signal/chat/errors.proto";
|
||||
import "org/signal/chat/tag.proto";
|
||||
|
||||
// Provides methods for sending "unsealed sender" messages.
|
||||
service Messages {
|
||||
|
||||
option (require.auth) = AUTH_ONLY_AUTHENTICATED;
|
||||
|
||||
// Sends an "unsealed sender" message to all devices linked to a single
|
||||
// destination account.
|
||||
//
|
||||
// The destination account must not be the same as the authenticated caller.
|
||||
// Callers should use `SendSyncMessage` to send messages to themselves.
|
||||
rpc SendMessage(SendAuthenticatedSenderMessageRequest) returns (SendMessageAuthenticatedSenderResponse) {}
|
||||
|
||||
// Sends a "sync" message to all other devices linked to the authenticated
|
||||
// sender's account.
|
||||
rpc SendSyncMessage(SendSyncMessageRequest) returns (SendMessageAuthenticatedSenderResponse) {}
|
||||
}
|
||||
|
||||
// Provides methods for sending "sealed sender" messages.
|
||||
service MessagesAnonymous {
|
||||
|
||||
option (require.auth) = AUTH_ONLY_ANONYMOUS;
|
||||
|
||||
// Sends a "sealed sender" message to all devices linked to a single
|
||||
// destination account.
|
||||
//
|
||||
// If this RPC is authorized with an unidentified access key, it will fail
|
||||
// with an authorization failure if the credential is invalid OR if the
|
||||
// destination account was not found. If it is authorized using a group send
|
||||
// token, it will fail with an authorization failure if the credential is
|
||||
// invalid and with an destination not found error if the account does not
|
||||
// exist
|
||||
rpc SendSingleRecipientMessage(SendSealedSenderMessageRequest) returns (SendMessageResponse) {}
|
||||
|
||||
// Sends a "sealed sender" message with a common payload to all devices linked
|
||||
// to multiple destination accounts.
|
||||
rpc SendMultiRecipientMessage(SendMultiRecipientMessageRequest) returns (SendMultiRecipientMessageResponse) {}
|
||||
|
||||
// Sends a story message to devices linked to a single destination account.
|
||||
rpc SendStory(SendStoryMessageRequest) returns (SendMessageResponse) {}
|
||||
|
||||
// Sends a story message with a common payload to devices linked to devices
|
||||
// linked to multiple destination accounts.
|
||||
rpc SendMultiRecipientStory(SendMultiRecipientStoryRequest) returns (SendMultiRecipientMessageResponse) {}
|
||||
}
|
||||
|
||||
message IndividualRecipientMessageBundle {
|
||||
|
||||
// A message for an individual device linked to a destination account.
|
||||
message Message {
|
||||
|
||||
// The registration ID for the destination device.
|
||||
uint32 registration_id = 1 [(require.range).max = 0x3fff];
|
||||
|
||||
// The content of the message to deliver to the destination device.
|
||||
bytes payload = 2 [(require.size) = {min: 1, max: 262144}]; // 256 KiB
|
||||
|
||||
// The message type of the message. If this message is part of an
|
||||
// unidentified send, this must be UNIDENTIFIED_SENDER
|
||||
SendMessageType type = 3;
|
||||
}
|
||||
|
||||
// The time, in milliseconds since the epoch, at which this message was
|
||||
// originally sent from the perspective of the sender. Note that the maximum
|
||||
// allowable timestamp for JavaScript clients is less than Long.MAX_VALUE; see
|
||||
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date#the_epoch_timestamps_and_invalid_date
|
||||
// for additional details and discussion.
|
||||
uint64 timestamp = 1 [(require.range).min = 1, (require.range).max = 8640000000000000];
|
||||
|
||||
// A map of device IDs to individual messages. Generally, callers must include
|
||||
// one message for each device linked to the destination account. In cases of
|
||||
// "sync messages" where a sender is distributing information to other devices
|
||||
// linked to the sender's account, senders may omit a message for the sending
|
||||
// device.
|
||||
map<uint32, Message> messages = 2 [(require.nonEmpty) = true];
|
||||
}
|
||||
|
||||
enum SendMessageType {
|
||||
UNSPECIFIED = 0;
|
||||
|
||||
// A double-ratchet message represents a "normal," "unsealed-sender" message
|
||||
// encrypted using the Double Ratchet within an established Signal session.
|
||||
DOUBLE_RATCHET = 1;
|
||||
|
||||
// A prekey message begins a new Signal session. The `content` of a prekey
|
||||
// message is a superset of a double-ratchet message's `content` and
|
||||
// contains the sender's identity public key and information identifying the
|
||||
// pre-keys used in the message's ciphertext.
|
||||
PREKEY_MESSAGE = 2;
|
||||
|
||||
// A plaintext message is used solely to convey encryption error receipts
|
||||
// and never contains encrypted message content. Encryption error receipts
|
||||
// must be delivered in plaintext because encryption/decryption of a prior
|
||||
// message failed and there is no reason to believe that
|
||||
// encryption/decryption of subsequent messages with the same key material
|
||||
// would succeed.
|
||||
//
|
||||
// Critically, plaintext messages never have "real" message content
|
||||
// generated by users. Plaintext messages include sender information.
|
||||
PLAINTEXT_CONTENT = 3;
|
||||
|
||||
// An unidentified sender message is an encrypted message. No other
|
||||
// information about the type of the encrypted message is known to the server.
|
||||
//
|
||||
// Unidenitfied sender messages require an unidentified access token or a
|
||||
// group send endorsement token to prove the unidentified sender is authorized
|
||||
// to send messages to the destination.
|
||||
UNIDENTIFIED_SENDER = 4;
|
||||
}
|
||||
|
||||
message SendAuthenticatedSenderMessageRequest {
|
||||
|
||||
// The service identifier of the account to which to deliver the message.
|
||||
common.ServiceIdentifier destination = 1;
|
||||
|
||||
// If true, this message will only be delivered to destination devices that
|
||||
// have an active message delivery channel with a Signal server.
|
||||
bool ephemeral = 2;
|
||||
|
||||
// Indicates whether this message is urgent and should trigger a high-priority
|
||||
// notification if the destination device does not have an active message
|
||||
// delivery channel with a Signal server
|
||||
bool urgent = 3;
|
||||
|
||||
// The messages to send to the destination account.
|
||||
IndividualRecipientMessageBundle messages = 4;
|
||||
}
|
||||
|
||||
message SendMessageAuthenticatedSenderResponse {
|
||||
|
||||
// The outcome of the message delivery
|
||||
oneof response {
|
||||
|
||||
// The message was successfully delivered to all destination devices
|
||||
google.protobuf.Empty success = 1;
|
||||
|
||||
// A list of discrepancies between the destination devices identified in a
|
||||
// request to send a message and the devices that are actually linked to an
|
||||
// account.
|
||||
MismatchedDevices mismatched_devices = 2 [(tag.reason) = "mismatched_devices"];
|
||||
|
||||
// A description of a challenge callers must complete before sending
|
||||
// additional messages.
|
||||
ChallengeRequired challenge_required = 3 [(tag.reason) = "challenge_required"];
|
||||
|
||||
// The destination account did not exist
|
||||
errors.NotFound destination_not_found = 4 [(tag.reason) = "destination_not_found"];
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
message SendSyncMessageRequest {
|
||||
|
||||
// Indicates whether this message is urgent and should trigger a high-priority
|
||||
// notification if the destination device does not have an active message
|
||||
// delivery channel with a Signal server
|
||||
bool urgent = 1;
|
||||
|
||||
// The messages to send to the destination account.
|
||||
IndividualRecipientMessageBundle messages = 2;
|
||||
}
|
||||
|
||||
message SendSealedSenderMessageRequest {
|
||||
|
||||
// The service identifier of the account to which to deliver the message.
|
||||
common.ServiceIdentifier destination = 1;
|
||||
|
||||
// If true, this message will only be delivered to destination devices that
|
||||
// have an active message delivery channel with a Signal server.
|
||||
bool ephemeral = 2;
|
||||
|
||||
// Indicates whether this message is urgent and should trigger a high-priority
|
||||
// notification if the destination device does not have an active message
|
||||
// delivery channel with a Signal server
|
||||
bool urgent = 3;
|
||||
|
||||
// The messages to send to the destination account.
|
||||
IndividualRecipientMessageBundle messages = 4;
|
||||
|
||||
// A means to authorize the request.
|
||||
oneof authorization {
|
||||
|
||||
// The unidentified access key (UAK) for the destination account.
|
||||
bytes unidentified_access_key = 5 [(require.exactlySize) = 16];
|
||||
|
||||
// A group send endorsement token for the destination account.
|
||||
bytes group_send_token = 6;
|
||||
|
||||
// The destination account allows unrestricted unidentified access
|
||||
google.protobuf.Empty unrestricted_access = 7;
|
||||
}
|
||||
}
|
||||
|
||||
message SendStoryMessageRequest {
|
||||
|
||||
// The service identifier of the account to which to deliver the message.
|
||||
common.ServiceIdentifier destination = 1;
|
||||
|
||||
// Indicates whether this message is urgent and should trigger a high-priority
|
||||
// notification if the destination device does not have an active message
|
||||
// delivery channel with a Signal server
|
||||
bool urgent = 2;
|
||||
|
||||
// The messages to send to the destination account.
|
||||
IndividualRecipientMessageBundle messages = 3;
|
||||
}
|
||||
|
||||
message SendMessageResponse {
|
||||
|
||||
// The outcome of the message delivery
|
||||
oneof response {
|
||||
|
||||
// The message was successfully delivered to all destination devices
|
||||
google.protobuf.Empty success = 1;
|
||||
|
||||
// A list of discrepancies between the destination devices identified in a
|
||||
// request to send a message and the devices that are actually linked to an
|
||||
// account.
|
||||
MismatchedDevices mismatched_devices = 2 [(tag.reason) = "mismatched_devices"];
|
||||
|
||||
// The provided unidentified authorization credential was invalid
|
||||
errors.FailedUnidentifiedAuthorization failed_unidentified_authorization = 3 [(tag.reason) = "failed_unidentified_authorization"];
|
||||
|
||||
// The destination account did not exist
|
||||
errors.NotFound destination_not_found = 4 [(tag.reason) = "destination_not_found"];
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
message MultiRecipientMessage {
|
||||
|
||||
// The time, in milliseconds since the epoch, at which this message was
|
||||
// originally sent from the perspective of the sender. Note that the maximum
|
||||
// allowable timestamp for JavaScript clients is less than Long.MAX_VALUE; see
|
||||
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date#the_epoch_timestamps_and_invalid_date
|
||||
// for additional details and discussion.
|
||||
uint64 timestamp = 1 [(require.range).min = 1, (require.range).max = 8640000000000000];
|
||||
|
||||
// The serialized multi-recipient message payload.
|
||||
bytes payload = 2 [(require.size).max = 762144]; // 256 KiB payload + (5000 * 100) of overhead
|
||||
}
|
||||
|
||||
message SendMultiRecipientMessageRequest {
|
||||
|
||||
// If true, this message will only be delivered to destination devices that
|
||||
// have an active message delivery channel with a Signal server.
|
||||
bool ephemeral = 1;
|
||||
|
||||
// Indicates whether this message is urgent and should trigger a high-priority
|
||||
// notification if the destination device does not have an active message
|
||||
// delivery channel with a Signal server
|
||||
bool urgent = 2;
|
||||
|
||||
// The multi-recipient message to send to all destination accounts and
|
||||
// devices.
|
||||
MultiRecipientMessage message = 3;
|
||||
|
||||
// A group send endorsement token for the destination account.
|
||||
bytes group_send_token = 4 [(require.nonEmpty) = true];
|
||||
}
|
||||
|
||||
message SendMultiRecipientStoryRequest {
|
||||
|
||||
// Indicates whether this message is urgent and should trigger a high-priority
|
||||
// notification if the destination device does not have an active message
|
||||
// delivery channel with a Signal server
|
||||
bool urgent = 1;
|
||||
|
||||
// The multi-recipient story message to send to all destination accounts and
|
||||
// devices.
|
||||
MultiRecipientMessage message = 2;
|
||||
}
|
||||
|
||||
message MultiRecipientSuccess {
|
||||
// A list of destination service identifiers that could not be resolved to
|
||||
// registered Signal accounts. The message in the original request was sent
|
||||
// to all service identifiers/devices in the original request except for the
|
||||
// destination devices associated with the service identifiers in this list.
|
||||
repeated common.ServiceIdentifier unresolved_recipients = 1;
|
||||
}
|
||||
|
||||
message SendMultiRecipientMessageResponse {
|
||||
|
||||
// The outcome of the message delivery
|
||||
oneof response {
|
||||
// The message was sent to at least some of the destination accounts/devices
|
||||
// identified in the original request.
|
||||
MultiRecipientSuccess success = 1;
|
||||
|
||||
// A list of sets of discrepancies between the destination devices
|
||||
// identified in a request to send a message and the devices that are
|
||||
// actually linked to a destination account.
|
||||
MultiRecipientMismatchedDevices mismatched_devices = 2 [(tag.reason) = "mismatched_devices"];
|
||||
|
||||
// The provided unidentified authorization credential was invalid
|
||||
errors.FailedUnidentifiedAuthorization failed_unidentified_authorization = 3 [(tag.reason) = "failed_unidentified_authorization"];
|
||||
}
|
||||
}
|
||||
|
||||
message MismatchedDevices {
|
||||
|
||||
// The service identifier to which the devices named in this object are
|
||||
// linked.
|
||||
common.ServiceIdentifier service_identifier = 1;
|
||||
|
||||
// A list of device IDs that are linked to the destination account, but were
|
||||
// not included in the collection of messages bound for the destination
|
||||
// account.
|
||||
repeated uint32 missing_devices = 2 [(require.range).max = 0x7f];
|
||||
|
||||
// A list of device IDs that were included in the collection of messages bound
|
||||
// for the destination account, but are not currently linked to the
|
||||
// destination account.
|
||||
repeated uint32 extra_devices = 3 [(require.range).max = 0x7f];
|
||||
|
||||
// A list of device IDs that present in the collection of messages bound for
|
||||
// the destination account and are linked to the destination account, but have
|
||||
// a different registration ID than the registration ID presented by the
|
||||
// sender (indicating that the destination device has likely been replaced by
|
||||
// another device).
|
||||
repeated uint32 stale_devices = 4 [(require.range).max = 0x7f];
|
||||
}
|
||||
|
||||
message MultiRecipientMismatchedDevices {
|
||||
|
||||
// A list of sets of discrepancies between the destination devices identified
|
||||
// in a request to send a message and the devices that are actually linked to
|
||||
// a destination account.
|
||||
repeated MismatchedDevices mismatched_devices = 1;
|
||||
}
|
||||
|
||||
message ChallengeRequired {
|
||||
|
||||
enum ChallengeType {
|
||||
UNSPECIFIED = 0;
|
||||
|
||||
// A challenge that callers can fulfill by completing a captcha.
|
||||
CAPTCHA = 1;
|
||||
|
||||
// A challenge that callers can fulfill by supplying a token delivered via
|
||||
// push notification.
|
||||
PUSH_CHALLENGE = 2;
|
||||
};
|
||||
|
||||
// An opaque token identifying this challenge request. Clients must generally
|
||||
// submit this token when submitting a challenge response.
|
||||
string token = 1;
|
||||
|
||||
// A list of challenge types callers may choose to complete to resolve the
|
||||
// challenge requirement. May be empty, in which case callers cannot resolve
|
||||
// the challenge by any means other than waiting.
|
||||
repeated ChallengeType challenge_options = 2;
|
||||
|
||||
// A duration (in seconds) after which the challenge requirement may be
|
||||
// resolved by simply waiting. May not be set if the challenge cannot be
|
||||
// resolved by waiting.
|
||||
optional uint64 retry_after_seconds = 3;
|
||||
}
|
||||
33
protos/server/org/signal/chat/payments.proto
Normal file
33
protos/server/org/signal/chat/payments.proto
Normal file
@ -0,0 +1,33 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
syntax = "proto3";
|
||||
|
||||
option java_multiple_files = true;
|
||||
|
||||
package org.signal.chat.payments;
|
||||
|
||||
// Provides methods for working with payments.
|
||||
service Payments {
|
||||
|
||||
rpc GetCurrencyConversions(GetCurrencyConversionsRequest) returns (GetCurrencyConversionsResponse) {}
|
||||
}
|
||||
|
||||
message GetCurrencyConversionsRequest {
|
||||
}
|
||||
|
||||
message GetCurrencyConversionsResponse {
|
||||
|
||||
message CurrencyConversionEntity {
|
||||
|
||||
string base = 1;
|
||||
|
||||
map<string, string> conversions = 2;
|
||||
}
|
||||
|
||||
uint64 timestamp = 1;
|
||||
|
||||
repeated CurrencyConversionEntity currencies = 2;
|
||||
}
|
||||
243
protos/server/org/signal/chat/profile.proto
Normal file
243
protos/server/org/signal/chat/profile.proto
Normal file
@ -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;
|
||||
}
|
||||
184
protos/server/org/signal/chat/require.proto
Normal file
184
protos/server/org/signal/chat/require.proto
Normal file
@ -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;
|
||||
}
|
||||
|
||||
38
protos/server/org/signal/chat/tag.proto
Normal file
38
protos/server/org/signal/chat/tag.proto
Normal file
@ -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;
|
||||
}
|
||||
@ -122,6 +122,7 @@ export class Group extends GroupData {
|
||||
description: null,
|
||||
announcementsOnly: null,
|
||||
membersBanned: null,
|
||||
terminated: null,
|
||||
};
|
||||
|
||||
return new Group({
|
||||
|
||||
@ -310,6 +310,7 @@ const EMPTY_GROUP_ACTIONS: Proto.GroupChange.Actions.Params = {
|
||||
promoteMembersPendingPniAciProfileKey: null,
|
||||
modifyMemberLabels: null,
|
||||
modifyMemberLabelAccess: null,
|
||||
terminateGroup: null,
|
||||
};
|
||||
|
||||
export const EMPTY_DATA_MESSAGE: Proto.DataMessage.Params = {
|
||||
@ -2144,6 +2145,7 @@ export class PrimaryDevice {
|
||||
const ciphertext = await SignalClient.signalEncrypt(
|
||||
paddedMessage,
|
||||
target.getAddressByKind(serviceIdKind),
|
||||
this.device.getAddressByKind(ServiceIdKind.ACI),
|
||||
this.sessions,
|
||||
identity,
|
||||
);
|
||||
@ -2227,7 +2229,7 @@ export class PrimaryDevice {
|
||||
|
||||
decrypted = await SignalClient.signalDecrypt(
|
||||
SignalMessage.deserialize(encrypted),
|
||||
source.getAddressByKind(serviceIdKind),
|
||||
source.getAddressByKind(ServiceIdKind.ACI),
|
||||
this.sessions,
|
||||
identity,
|
||||
);
|
||||
@ -2236,7 +2238,8 @@ export class PrimaryDevice {
|
||||
|
||||
decrypted = await SignalClient.signalDecryptPreKey(
|
||||
PreKeySignalMessage.deserialize(encrypted),
|
||||
source.getAddressByKind(serviceIdKind),
|
||||
source.getAddressByKind(ServiceIdKind.ACI),
|
||||
this.device.getAddressByKind(serviceIdKind),
|
||||
this.sessions,
|
||||
identity,
|
||||
preKeys,
|
||||
|
||||
@ -6,16 +6,26 @@ import fs from 'fs';
|
||||
import fsPromises from 'fs/promises';
|
||||
import { type Readable } from 'stream';
|
||||
import path from 'path';
|
||||
import https, { ServerOptions } from 'https';
|
||||
import type { IncomingMessage, ServerResponse } from 'http';
|
||||
import http2, {
|
||||
SecureServerOptions,
|
||||
Http2ServerRequest,
|
||||
Http2ServerResponse,
|
||||
} from 'http2';
|
||||
import { parse as parseURL } from 'url';
|
||||
import { PrivateKey, PublicKey } from '@signalapp/libsignal-client';
|
||||
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 } from 'micro';
|
||||
import { run, type RequestHandler } from 'micro';
|
||||
|
||||
import { attachmentToPointer } from '../data/attachment';
|
||||
import { BackupMediaBatch } from '../data/schemas';
|
||||
@ -56,6 +66,7 @@ import {
|
||||
} 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';
|
||||
@ -77,7 +88,7 @@ type ZKParams = Readonly<{
|
||||
type StrictConfig = Readonly<{
|
||||
trustRoot: TrustRoot;
|
||||
zkParams: ZKParams;
|
||||
https: ServerOptions;
|
||||
https: SecureServerOptions;
|
||||
timeout: number;
|
||||
maxStorageReadKeys?: number;
|
||||
cdn3Path?: string;
|
||||
@ -87,7 +98,7 @@ type StrictConfig = Readonly<{
|
||||
export type Config = Readonly<{
|
||||
trustRoot?: TrustRoot;
|
||||
zkParams?: ZKParams;
|
||||
https?: ServerOptions;
|
||||
https?: SecureServerOptions;
|
||||
timeout?: number;
|
||||
maxStorageReadKeys?: number;
|
||||
cdn3Path?: string;
|
||||
@ -121,6 +132,7 @@ type ProvisionResultQueue = Readonly<{
|
||||
}>;
|
||||
|
||||
const debug = createDebug('mock:server:mock');
|
||||
const libsignalDebug = createDebug('mock:server:libsignal');
|
||||
|
||||
const CERTS_DIR = path.join(__dirname, '..', '..', 'certs');
|
||||
|
||||
@ -137,6 +149,27 @@ const ZK_PARAMS: ZKParams = JSON.parse(
|
||||
|
||||
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;
|
||||
|
||||
@ -178,7 +211,12 @@ export class Server extends BaseServer {
|
||||
https: {
|
||||
key: KEY,
|
||||
cert: CERT,
|
||||
allowHTTP1: true,
|
||||
...(config.https ?? {}),
|
||||
settings: {
|
||||
...(config.https?.settings ?? {}),
|
||||
enableConnectProtocol: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@ -223,63 +261,41 @@ export class Server extends BaseServer {
|
||||
updates2Path: this.config.updates2Path,
|
||||
});
|
||||
|
||||
const server = https.createServer(this.config.https, (req, res) => {
|
||||
void run(req, res, httpHandler);
|
||||
});
|
||||
const grpcHandler = createGRPCHandler(this);
|
||||
|
||||
const wss = new WebSocket.Server({
|
||||
server,
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises
|
||||
verifyClient: async (info, callback) => {
|
||||
const { url } = info.req;
|
||||
assert(url, 'verifyClient: expected a URL on incoming request');
|
||||
const { query } = parseURL(url, true);
|
||||
|
||||
if (query.login == null && query.password == null) {
|
||||
debug('verifyClient: Allowing connection with no credentials');
|
||||
callback(true);
|
||||
return;
|
||||
const server = http2
|
||||
.createSecureServer(this.config.https, (req, res) => {
|
||||
let handler: RequestHandler;
|
||||
if (req.headers['content-type'] === 'application/grpc') {
|
||||
handler = grpcHandler;
|
||||
} else {
|
||||
handler = httpHandler;
|
||||
}
|
||||
|
||||
// Note: when a device has been unlinked, it will use '' as its password
|
||||
if (
|
||||
query.login == null ||
|
||||
Array.isArray(query.login) ||
|
||||
typeof query.password !== 'string' ||
|
||||
Array.isArray(query.password)
|
||||
) {
|
||||
debug('verifyClient: Malformed credentials @ %s: %j', url, query);
|
||||
callback(false, 403);
|
||||
// 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, undefined, {});
|
||||
websocket.setSocket(req.stream, Buffer.alloc(0), {});
|
||||
const conn = new WSConnection(req, websocket, this);
|
||||
|
||||
conn.start().catch((error: unknown) => {
|
||||
websocket.close();
|
||||
debug('Websocket handling error', error);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const device = await this.auth(query.login, query.password);
|
||||
if (!device) {
|
||||
debug('verifyClient: Invalid credentials @ %s: %j', url, query);
|
||||
callback(false, 403);
|
||||
return;
|
||||
}
|
||||
|
||||
callback(true);
|
||||
},
|
||||
});
|
||||
|
||||
wss.on('connection', (ws, request) => {
|
||||
const conn = new WSConnection(request, ws, this);
|
||||
|
||||
conn.start().catch((error: unknown) => {
|
||||
ws.close();
|
||||
debug('Websocket handling error', error);
|
||||
});
|
||||
});
|
||||
|
||||
wss.on('headers', (headers) => {
|
||||
Object.entries(this.wsUpgradeResponseHeaders).forEach(
|
||||
([header, value]) => {
|
||||
headers.push(`${header}: ${value}`);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
this.https = server;
|
||||
|
||||
|
||||
@ -26,7 +26,7 @@ import {
|
||||
UuidCiphertext,
|
||||
} from '@signalapp/libsignal-client/zkgroup';
|
||||
import assert from 'assert';
|
||||
import https from 'https';
|
||||
import http2 from 'http2';
|
||||
import crypto from 'crypto';
|
||||
import createDebug from 'debug';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
@ -205,6 +205,7 @@ export { type ModifyGroupResult };
|
||||
|
||||
interface WebSocket {
|
||||
sendMessage: (message: Buffer<ArrayBuffer> | 'empty') => Promise<void>;
|
||||
close: (code: number) => void;
|
||||
}
|
||||
|
||||
interface SerializableCredential {
|
||||
@ -222,11 +223,7 @@ type StorageAuthEntry = Readonly<{
|
||||
device: Device;
|
||||
}>;
|
||||
|
||||
type MessageQueueEntry = {
|
||||
readonly message: Buffer<ArrayBuffer>;
|
||||
resolve: () => void;
|
||||
reject: (error: Error) => void;
|
||||
};
|
||||
type MessageQueueEntry = (socket: WebSocket) => Promise<void>;
|
||||
|
||||
export type CallLinkEntry = Readonly<{
|
||||
adminPasskey: Buffer<ArrayBuffer>;
|
||||
@ -325,7 +322,7 @@ export abstract class Server {
|
||||
>();
|
||||
private readonly attachments = new Map<AttachmentId, Buffer<ArrayBuffer>>();
|
||||
private readonly stickerPacks = new Map<string, EncryptedStickerPack>();
|
||||
private readonly webSockets = new Map<Device, Set<WebSocket>>();
|
||||
private readonly webSockets = new Map<Device, WebSocket>();
|
||||
private readonly messageQueue = new WeakMap<
|
||||
Device,
|
||||
Array<MessageQueueEntry>
|
||||
@ -360,7 +357,7 @@ export abstract class Server {
|
||||
protected privZKSecret: ServerSecretParams | undefined;
|
||||
protected privGenericServerSecret: GenericServerSecretParams | undefined;
|
||||
protected privBackupServerSecret: GenericServerSecretParams | undefined;
|
||||
protected https: https.Server | undefined;
|
||||
protected https: http2.Http2SecureServer | undefined;
|
||||
|
||||
public address(): AddressInfo {
|
||||
if (!this.https) {
|
||||
@ -800,26 +797,25 @@ export abstract class Server {
|
||||
|
||||
public async addWebSocket(device: Device, socket: WebSocket): Promise<void> {
|
||||
debug('adding websocket for device=%s', device.debugId);
|
||||
let sockets = this.webSockets.get(device);
|
||||
if (!sockets) {
|
||||
sockets = new Set();
|
||||
this.webSockets.set(device, sockets);
|
||||
const existing = this.webSockets.get(device);
|
||||
if (existing !== undefined) {
|
||||
debug('closing stale socket for devices=%s', device.debugId);
|
||||
existing.close(4409);
|
||||
}
|
||||
sockets.add(socket);
|
||||
this.webSockets.set(device, socket);
|
||||
|
||||
await this.sendQueue(device, socket);
|
||||
// Don't wait for send to be over
|
||||
void this.sendQueue(device, socket);
|
||||
}
|
||||
|
||||
public removeWebSocket(device: Device, socket: WebSocket): void {
|
||||
debug('removing websocket for device=%s', device.debugId);
|
||||
const sockets = this.webSockets.get(device);
|
||||
if (!sockets) {
|
||||
const existing = this.webSockets.get(device);
|
||||
if (existing !== socket) {
|
||||
return;
|
||||
}
|
||||
sockets.delete(socket);
|
||||
if (sockets.size === 0) {
|
||||
this.webSockets.delete(device);
|
||||
}
|
||||
|
||||
debug('removing websocket for device=%s', device.debugId);
|
||||
this.webSockets.delete(device);
|
||||
}
|
||||
|
||||
// TODO(indutny): timeout
|
||||
@ -827,57 +823,43 @@ export abstract class Server {
|
||||
target: Device,
|
||||
message: Buffer<ArrayBuffer>,
|
||||
): Promise<void> {
|
||||
const sockets = this.webSockets.get(target);
|
||||
if (sockets) {
|
||||
debug(
|
||||
'sending message to %d sockets of %s',
|
||||
sockets.size,
|
||||
target.debugId,
|
||||
);
|
||||
let success = false;
|
||||
await Promise.all<void>(
|
||||
Array.from(sockets).map(async (socket) => {
|
||||
try {
|
||||
await socket.sendMessage(message);
|
||||
success = true;
|
||||
} catch (error) {
|
||||
assert(error instanceof Error);
|
||||
debug(
|
||||
'failed to send message to socket of %s, error %s',
|
||||
target.debugId,
|
||||
error.message,
|
||||
);
|
||||
}
|
||||
}),
|
||||
);
|
||||
const socket = this.webSockets.get(target);
|
||||
if (socket) {
|
||||
debug('sending message to %d socket', target.debugId);
|
||||
try {
|
||||
await socket.sendMessage(message);
|
||||
|
||||
// At least one send should succeed, if not - queue
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (success) {
|
||||
return;
|
||||
} catch (error) {
|
||||
assert(error instanceof Error);
|
||||
debug(
|
||||
'failed to send message to socket of %s, error %s',
|
||||
target.debugId,
|
||||
error.message,
|
||||
);
|
||||
}
|
||||
|
||||
debug("message couldn't be sent to %s", sockets.size, target.debugId);
|
||||
}
|
||||
|
||||
debug('queueing message for device=%s', target.debugId);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
// NOTE: set and push have to happen in the same tick, otherwise a race
|
||||
// condition is possible in `removeWebSocket`.
|
||||
let queue = this.messageQueue.get(target);
|
||||
if (!queue) {
|
||||
queue = [];
|
||||
this.messageQueue.set(target, queue);
|
||||
}
|
||||
let queue = this.messageQueue.get(target);
|
||||
if (!queue) {
|
||||
queue = [];
|
||||
this.messageQueue.set(target, queue);
|
||||
}
|
||||
|
||||
queue.push({
|
||||
message,
|
||||
resolve,
|
||||
reject,
|
||||
});
|
||||
const { promise, resolve, reject } = Promise.withResolvers<void>();
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@ -1022,7 +1004,7 @@ export abstract class Server {
|
||||
await Promise.all(deletes);
|
||||
|
||||
debug(
|
||||
'updating storage manifest to version=%j for=%j',
|
||||
'updating storage manifest to version=%d for=%j',
|
||||
manifest.version,
|
||||
device.debugId,
|
||||
);
|
||||
@ -1756,24 +1738,14 @@ export abstract class Server {
|
||||
}
|
||||
|
||||
debug('sending queued %d messages to %s', queue.length, device.debugId);
|
||||
await Promise.all(
|
||||
queue.map(async (entry) => {
|
||||
const { message, resolve, reject } = entry;
|
||||
|
||||
try {
|
||||
await socket.sendMessage(message);
|
||||
} catch (error) {
|
||||
assert(error instanceof Error);
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
|
||||
resolve();
|
||||
}),
|
||||
);
|
||||
|
||||
debug('queue for %s is empty', device.debugId);
|
||||
await socket.sendMessage('empty');
|
||||
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(
|
||||
|
||||
37
src/server/common.ts
Normal file
37
src/server/common.ts
Normal file
@ -0,0 +1,37 @@
|
||||
// Copyright 2026 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import createDebug from 'debug';
|
||||
import type { ServerRequest, ServerResponse } from 'microrouter';
|
||||
import { send } from 'micro';
|
||||
import { ParseAuthHeaderResult, parseAuthHeader } from '../util';
|
||||
import type { Server } from './base';
|
||||
import type { Device } from '../data/device';
|
||||
|
||||
const debug = createDebug('mock:server:base');
|
||||
|
||||
export function parsePassword(req: ServerRequest): ParseAuthHeaderResult {
|
||||
return parseAuthHeader(req.headers.authorization);
|
||||
}
|
||||
|
||||
export async function auth(
|
||||
server: Server,
|
||||
req: ServerRequest,
|
||||
res: ServerResponse,
|
||||
): Promise<Device | undefined> {
|
||||
const { username, password, error } = parsePassword(req);
|
||||
if (error) {
|
||||
debug('%s %s auth failed, error %j', req.method, req.url, error);
|
||||
void send(res, 401, { error });
|
||||
return;
|
||||
}
|
||||
|
||||
const device = await server.auth(username ?? '', password ?? '');
|
||||
if (!device) {
|
||||
debug('%s %s auth failed, need re-provisioning', req.method, req.url);
|
||||
void send(res, 401, { error: 'Need re-provisioning' });
|
||||
return;
|
||||
}
|
||||
|
||||
return device;
|
||||
}
|
||||
@ -133,6 +133,7 @@ export class ServerGroup extends Group {
|
||||
promoteMembersPendingPniAciProfileKey: null,
|
||||
modifyMemberLabels: null,
|
||||
modifyMemberLabelAccess: null,
|
||||
terminateGroup: null,
|
||||
};
|
||||
|
||||
assert.ok(actions.version, 'Actions should have a new version');
|
||||
@ -376,6 +377,13 @@ export class ServerGroup extends Group {
|
||||
];
|
||||
}
|
||||
|
||||
if (actions.terminateGroup !== null) {
|
||||
this.verifyAccess('terminated', authMember, AccessRequired.ADMINISTRATOR);
|
||||
|
||||
appliedActions.terminateGroup = {};
|
||||
newState.terminated = true;
|
||||
}
|
||||
|
||||
const { version: oldVersion } = this.state;
|
||||
assert.ok(
|
||||
typeof oldVersion === 'number',
|
||||
|
||||
328
src/server/grpc.ts
Normal file
328
src/server/grpc.ts
Normal file
@ -0,0 +1,328 @@
|
||||
// Copyright 2026 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import assert from 'assert';
|
||||
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,
|
||||
} from 'microrouter';
|
||||
import { ServiceId, Aci, Pni } from '@signalapp/libsignal-client';
|
||||
import SealedSenderMultiRecipientMessage from '@signalapp/libsignal-client/dist/SealedSenderMultiRecipientMessage';
|
||||
import { Message } from '../data/schemas';
|
||||
|
||||
import { DeviceId, RegistrationId, ServiceIdString } from '../types';
|
||||
import { $services, org, signalservice as Proto } from '../../protos/compiled';
|
||||
import { Server } from './base';
|
||||
import { auth } from './common';
|
||||
|
||||
const debug = createDebug('mock:grpc');
|
||||
|
||||
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 grpcRoute<Endpoint extends keyof typeof $services>(
|
||||
endpoint: Endpoint,
|
||||
handler: (
|
||||
request: ReturnType<(typeof $services)[Endpoint]['Request']['decode']>,
|
||||
) => Promise<
|
||||
Parameters<(typeof $services)[Endpoint]['Response']['encode']>[0]
|
||||
>,
|
||||
) {
|
||||
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 (req, res) => {
|
||||
const raw = await buffer(req);
|
||||
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 request = definition.Request.decode(
|
||||
raw.subarray(5, 5 + len) as Uint8Array<ArrayBuffer>,
|
||||
);
|
||||
|
||||
const response = await handler(request as Parameters<typeof handler>[0]);
|
||||
|
||||
const data = (
|
||||
definition.Response.encode as (params: unknown) => Uint8Array<ArrayBuffer>
|
||||
)(response);
|
||||
const header = Buffer.alloc(5);
|
||||
header.writeUint32BE(data.length, 1);
|
||||
return sendRaw(res, 200, Buffer.concat([header, data]));
|
||||
});
|
||||
}
|
||||
|
||||
export const createHandler = (server: Server): RequestHandler => {
|
||||
// gRPC
|
||||
|
||||
async function onMultiRecipientMessage(
|
||||
request:
|
||||
| org.signal.chat.messages.SendMultiRecipientMessageRequest
|
||||
| org.signal.chat.messages.SendMultiRecipientStoryRequest,
|
||||
): Promise<org.signal.chat.messages.SendMultiRecipientMessageResponse.Params> {
|
||||
const {
|
||||
message: givenMessage,
|
||||
// TODO(indutny): check it at all?
|
||||
// groupSendToken,
|
||||
} = request;
|
||||
|
||||
if (givenMessage == null) {
|
||||
throw new Error('Missing message');
|
||||
}
|
||||
|
||||
const { timestamp, payload } = givenMessage;
|
||||
|
||||
const message = new SealedSenderMultiRecipientMessage(Buffer.from(payload));
|
||||
|
||||
const listByServiceId = new Map<ServiceIdString, Array<Message>>();
|
||||
|
||||
const recipients = message.recipientsByServiceIdString();
|
||||
for (const [serviceId, recipient] of Object.entries(recipients)) {
|
||||
let list: Array<Message> | undefined = listByServiceId.get(
|
||||
serviceId as ServiceIdString,
|
||||
);
|
||||
if (!list) {
|
||||
list = [];
|
||||
listByServiceId.set(serviceId as ServiceIdString, list);
|
||||
}
|
||||
|
||||
for (const [i, deviceId] of recipient.deviceIds.entries()) {
|
||||
const registrationId = recipient.registrationIds.at(i);
|
||||
|
||||
list.push({
|
||||
type: Proto.Envelope.Type.UNIDENTIFIED_SENDER,
|
||||
destinationDeviceId: deviceId as DeviceId,
|
||||
destinationRegistrationId: registrationId as RegistrationId,
|
||||
content: Buffer.from(message.messageForRecipient(recipient)).toString(
|
||||
'base64',
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const results = await Promise.all(
|
||||
Array.from(listByServiceId.entries()).map(
|
||||
async ([serviceId, messages]) => {
|
||||
return {
|
||||
uuid: serviceId,
|
||||
prepared: await server.prepareMultiDeviceMessage(
|
||||
undefined,
|
||||
serviceId,
|
||||
messages,
|
||||
timestamp,
|
||||
),
|
||||
};
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
const mismatchedDevices = results.filter(({ prepared }) => {
|
||||
return prepared.status === 'incomplete' || prepared.status === 'stale';
|
||||
});
|
||||
|
||||
if (mismatchedDevices.length > 0) {
|
||||
return {
|
||||
response: {
|
||||
mismatchedDevices: {
|
||||
mismatchedDevices: mismatchedDevices.map(({ uuid, prepared }) => {
|
||||
if (prepared.status === 'incomplete') {
|
||||
return {
|
||||
serviceIdentifier: toServiceIdentifier(uuid),
|
||||
missingDevices: prepared.missingDevices.slice(),
|
||||
extraDevices: prepared.extraDevices.slice(),
|
||||
staleDevices: null,
|
||||
};
|
||||
}
|
||||
|
||||
assert.ok(prepared.status === 'stale');
|
||||
return {
|
||||
serviceIdentifier: toServiceIdentifier(uuid),
|
||||
missingDevices: null,
|
||||
extraDevices: null,
|
||||
staleDevices: prepared.staleDevices.slice(),
|
||||
};
|
||||
}),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const uuids404 = results
|
||||
.filter(({ prepared }) => prepared.status === 'unknown')
|
||||
.map(({ uuid }) => uuid);
|
||||
|
||||
const ok = results.filter(({ prepared }) => prepared.status === 'ok');
|
||||
|
||||
await Promise.all(
|
||||
ok.map(({ prepared }) => {
|
||||
assert.ok(prepared.status === 'ok');
|
||||
return server.handlePreparedMultiDeviceMessage(
|
||||
undefined,
|
||||
prepared.targetServiceId,
|
||||
prepared.result,
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
response: {
|
||||
success: {
|
||||
unresolvedRecipients: uuids404.map(toServiceIdentifier),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const onSendMultiRecipientMessage = grpcRoute(
|
||||
'org.signal.chat.messages.MessagesAnonymous/SendMultiRecipientMessage',
|
||||
onMultiRecipientMessage,
|
||||
);
|
||||
|
||||
const onSendMultiRecipientStory = grpcRoute(
|
||||
'org.signal.chat.messages.MessagesAnonymous/SendMultiRecipientStory',
|
||||
onMultiRecipientMessage,
|
||||
);
|
||||
|
||||
const onLookupUsernameHash = grpcRoute(
|
||||
'org.signal.chat.account.AccountsAnonymous/LookupUsernameHash',
|
||||
async ({ usernameHash }) => {
|
||||
const uuid = await server.lookupByUsernameHash(Buffer.from(usernameHash));
|
||||
|
||||
if (!uuid) {
|
||||
return {
|
||||
response: {
|
||||
notFound: {},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
response: {
|
||||
serviceIdentifier: toServiceIdentifier(uuid),
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
const onLookupUsernameLink = grpcRoute(
|
||||
'org.signal.chat.account.AccountsAnonymous/LookupUsernameLink',
|
||||
async ({ usernameLinkHandle }) => {
|
||||
const usernameCiphertext = await server.lookupByUsernameLink(
|
||||
stringifyUuid(usernameLinkHandle),
|
||||
);
|
||||
|
||||
if (!usernameCiphertext) {
|
||||
return {
|
||||
response: {
|
||||
notFound: {},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
response: {
|
||||
usernameCiphertext,
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
const onGetUploadForm = grpcRoute(
|
||||
'org.signal.chat.attachments.Attachments/GetUploadForm',
|
||||
async () => {
|
||||
const { cdn, key, headers, signedUploadLocation } =
|
||||
await server.getAttachmentUploadForm('attachments', uuidv4());
|
||||
return {
|
||||
outcome: {
|
||||
uploadForm: {
|
||||
cdn,
|
||||
key,
|
||||
headers: new Map(Object.entries(headers)),
|
||||
signedUploadLocation,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
const 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 sendRaw(res, 404, { error: 'Not supported yet' });
|
||||
};
|
||||
|
||||
const routes = router(
|
||||
// gRPC
|
||||
onSendMultiRecipientMessage,
|
||||
onSendMultiRecipientStory,
|
||||
onLookupUsernameHash,
|
||||
onLookupUsernameLink,
|
||||
onGetUploadForm,
|
||||
|
||||
...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);
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
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);
|
||||
}
|
||||
};
|
||||
};
|
||||
@ -34,9 +34,9 @@ import {
|
||||
UpdateCallLinkSchema,
|
||||
} from '../data/schemas';
|
||||
import { AttachmentId } from '../types';
|
||||
import { ParseAuthHeaderResult, parseAuthHeader } from '../util';
|
||||
import { CallLinkEntry, Server } from './base';
|
||||
import { ServerGroup } from './group';
|
||||
import { parsePassword, auth } from './common';
|
||||
import { join } from 'path';
|
||||
import { createHash } from 'crypto';
|
||||
|
||||
@ -44,10 +44,6 @@ const debug = createDebug('mock:http');
|
||||
|
||||
const ALL_METHODS = [get, post, put, patch, del, head, options] as const;
|
||||
|
||||
const parsePassword = (req: ServerRequest): ParseAuthHeaderResult => {
|
||||
return parseAuthHeader(req.headers.authorization);
|
||||
};
|
||||
|
||||
function getContentType(filePath: string): string {
|
||||
const ext = filePath.toLowerCase().split('.').pop();
|
||||
switch (ext) {
|
||||
@ -302,27 +298,6 @@ export const createHandler = (
|
||||
// Authorized requests
|
||||
//
|
||||
|
||||
async function auth(
|
||||
req: ServerRequest,
|
||||
res: ServerResponse,
|
||||
): Promise<Device | undefined> {
|
||||
const { username, password, error } = parsePassword(req);
|
||||
if (error) {
|
||||
debug('%s %s auth failed, error %j', req.method, req.url, error);
|
||||
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;
|
||||
}
|
||||
|
||||
type GroupAuthResult = Readonly<{
|
||||
publicParams: Buffer<ArrayBuffer>;
|
||||
aciCiphertext: UuidCiphertext;
|
||||
@ -762,7 +737,7 @@ export const createHandler = (
|
||||
});
|
||||
|
||||
const notFoundAfterAuth: RouteHandler = async (req, res) => {
|
||||
const device = await auth(req, res);
|
||||
const device = await auth(server, req, res);
|
||||
if (!device) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -3,17 +3,15 @@
|
||||
|
||||
import assert from 'assert';
|
||||
import { Buffer } from 'buffer';
|
||||
import { IncomingMessage } from 'http';
|
||||
import { Http2ServerRequest } from 'http2';
|
||||
import { timingSafeEqual } from 'crypto';
|
||||
import createDebug from 'debug';
|
||||
import {
|
||||
CreateCallLinkCredentialRequest,
|
||||
ProfileKeyCredentialRequest,
|
||||
} from '@signalapp/libsignal-client/zkgroup';
|
||||
import SealedSenderMultiRecipientMessage from '@signalapp/libsignal-client/dist/SealedSenderMultiRecipientMessage';
|
||||
|
||||
import WebSocket from 'ws';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { signalservice as Proto } from '../../../protos/compiled';
|
||||
import { Device } from '../../data/device';
|
||||
@ -23,7 +21,6 @@ import {
|
||||
BackupMediaBatchSchema,
|
||||
CreateCallLinkAuthSchema,
|
||||
DeviceKeysSchema,
|
||||
Message,
|
||||
MessageListSchema,
|
||||
PutUsernameLinkSchema,
|
||||
SetBackupIdSchema,
|
||||
@ -35,7 +32,6 @@ import {
|
||||
DeviceId,
|
||||
ProvisionIdString,
|
||||
ProvisioningCode,
|
||||
RegistrationId,
|
||||
ServiceIdKind,
|
||||
ServiceIdString,
|
||||
untagPni,
|
||||
@ -49,7 +45,6 @@ import {
|
||||
} from '../../crypto';
|
||||
import { Server } from '../base';
|
||||
import {
|
||||
fromURLSafeBase64,
|
||||
getDevicesKeysResult,
|
||||
parseAuthHeader,
|
||||
serviceIdKindFromQuery,
|
||||
@ -64,10 +59,14 @@ const debug = createDebug('mock:ws:connection');
|
||||
|
||||
export class Connection extends Service {
|
||||
private device: Device | undefined;
|
||||
private readonly router = new Router();
|
||||
private readonly router = new Router({
|
||||
beforeRequest: (verb, path, headers) => {
|
||||
return this.handleAuth(verb, path, headers);
|
||||
},
|
||||
});
|
||||
|
||||
constructor(
|
||||
private readonly request: IncomingMessage,
|
||||
private readonly request: Http2ServerRequest,
|
||||
ws: WebSocket,
|
||||
private readonly server: Server,
|
||||
) {
|
||||
@ -189,121 +188,6 @@ export class Connection extends Service {
|
||||
}),
|
||||
);
|
||||
|
||||
this.router.put(
|
||||
'/v1/messages/multi_recipient',
|
||||
async (_params, body, _headers, query = {}) => {
|
||||
if (!body) {
|
||||
return [400, { error: 'Missing body' }];
|
||||
}
|
||||
if (query.ts == null) {
|
||||
return [400, { error: 'Missing ts' }];
|
||||
}
|
||||
|
||||
const timestamp = BigInt(query.ts as string);
|
||||
|
||||
const message = new SealedSenderMultiRecipientMessage(
|
||||
Buffer.from(body),
|
||||
);
|
||||
|
||||
const listByServiceId = new Map<ServiceIdString, Array<Message>>();
|
||||
|
||||
const recipients = message.recipientsByServiceIdString();
|
||||
for (const [serviceId, recipient] of Object.entries(recipients)) {
|
||||
let list: Array<Message> | undefined = listByServiceId.get(
|
||||
serviceId as ServiceIdString,
|
||||
);
|
||||
if (!list) {
|
||||
list = [];
|
||||
listByServiceId.set(serviceId as ServiceIdString, list);
|
||||
}
|
||||
|
||||
for (const [i, deviceId] of recipient.deviceIds.entries()) {
|
||||
const registrationId = recipient.registrationIds.at(i);
|
||||
|
||||
list.push({
|
||||
type: Proto.Envelope.Type.UNIDENTIFIED_SENDER,
|
||||
destinationDeviceId: deviceId as DeviceId,
|
||||
destinationRegistrationId: registrationId as RegistrationId,
|
||||
content: Buffer.from(
|
||||
message.messageForRecipient(recipient),
|
||||
).toString('base64'),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// TODO(indutny): verify access key xor
|
||||
|
||||
const results = await Promise.all(
|
||||
Array.from(listByServiceId.entries()).map(
|
||||
async ([serviceId, messages]) => {
|
||||
return {
|
||||
uuid: serviceId,
|
||||
prepared: await this.server.prepareMultiDeviceMessage(
|
||||
undefined,
|
||||
serviceId,
|
||||
messages,
|
||||
timestamp,
|
||||
),
|
||||
};
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
const incomplete = results.filter(
|
||||
({ prepared }) => prepared.status === 'incomplete',
|
||||
);
|
||||
|
||||
if (incomplete.length !== 0) {
|
||||
return [
|
||||
409,
|
||||
incomplete.map(({ uuid, prepared }) => {
|
||||
assert.ok(prepared.status === 'incomplete');
|
||||
return {
|
||||
uuid,
|
||||
devices: {
|
||||
missingDevices: prepared.missingDevices,
|
||||
extraDevices: prepared.extraDevices,
|
||||
},
|
||||
};
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
const stale = results.filter(
|
||||
({ prepared }) => prepared.status === 'stale',
|
||||
);
|
||||
|
||||
if (stale.length !== 0) {
|
||||
return [
|
||||
410,
|
||||
stale.map(({ uuid, prepared }) => {
|
||||
assert.ok(prepared.status === 'stale');
|
||||
return { uuid, devices: { staleDevices: prepared.staleDevices } };
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
const uuids404 = results
|
||||
.filter(({ prepared }) => prepared.status === 'unknown')
|
||||
.map(({ uuid }) => uuid);
|
||||
|
||||
const ok = results.filter(({ prepared }) => prepared.status === 'ok');
|
||||
|
||||
await Promise.all(
|
||||
ok.map(({ prepared }) => {
|
||||
assert.ok(prepared.status === 'ok');
|
||||
return this.server.handlePreparedMultiDeviceMessage(
|
||||
undefined,
|
||||
prepared.targetServiceId,
|
||||
prepared.result,
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
return [200, { uuids404 }];
|
||||
},
|
||||
);
|
||||
|
||||
this.router.put(
|
||||
'/v1/messages/:serviceId',
|
||||
async (params, body, headers, query = {}) => {
|
||||
@ -788,18 +672,6 @@ export class Connection extends Service {
|
||||
return [200, { ok: true }];
|
||||
});
|
||||
|
||||
//
|
||||
// Attachment upload forms
|
||||
//
|
||||
|
||||
this.router.get('/v4/attachments/form/upload', async () => {
|
||||
const key = uuidv4();
|
||||
return [
|
||||
200,
|
||||
await this.server.getAttachmentUploadForm('attachments', key),
|
||||
];
|
||||
});
|
||||
|
||||
//
|
||||
// Accounts
|
||||
//
|
||||
@ -810,7 +682,11 @@ export class Connection extends Service {
|
||||
const device = this.getDevice();
|
||||
return [
|
||||
200,
|
||||
{ uuid: device.aci, pni: device.pni, number: device.number },
|
||||
{
|
||||
uuid: device.aci,
|
||||
pni: untagPni(device.pni),
|
||||
number: device.number,
|
||||
},
|
||||
];
|
||||
}),
|
||||
);
|
||||
@ -875,33 +751,6 @@ export class Connection extends Service {
|
||||
}),
|
||||
);
|
||||
|
||||
this.router.get('/v1/accounts/username_hash/:hash', async (params) => {
|
||||
const { hash = '' } = params;
|
||||
|
||||
const uuid = await server.lookupByUsernameHash(fromURLSafeBase64(hash));
|
||||
|
||||
if (!uuid) {
|
||||
return [404, { error: 'Not found' }];
|
||||
}
|
||||
|
||||
return [200, { uuid }];
|
||||
});
|
||||
|
||||
this.router.get('/v1/accounts/username_link/:uuid', async (params) => {
|
||||
const { uuid: linkUuid = '' } = params;
|
||||
|
||||
const encryptedValue = await server.lookupByUsernameLink(linkUuid);
|
||||
|
||||
if (!encryptedValue) {
|
||||
return [404, { error: 'Not found' }];
|
||||
}
|
||||
|
||||
return [
|
||||
200,
|
||||
{ usernameLinkEncryptedValue: toURLSafeBase64(encryptedValue) },
|
||||
];
|
||||
});
|
||||
|
||||
this.router.put(
|
||||
'/v1/accounts/username_link',
|
||||
requireAuth(async (_params, rawBody) => {
|
||||
@ -1011,10 +860,11 @@ export class Connection extends Service {
|
||||
}
|
||||
|
||||
if (path === '/v1/websocket/') {
|
||||
return this.handleNormal(this.request);
|
||||
} else {
|
||||
debug('websocket connection has unexpected URL %s', url);
|
||||
await this.handleAuthHeaders(this.request.headers);
|
||||
return;
|
||||
}
|
||||
|
||||
debug('websocket connection has unexpected URL %s', url);
|
||||
}
|
||||
|
||||
public async sendMessage(
|
||||
@ -1036,6 +886,10 @@ export class Connection extends Service {
|
||||
);
|
||||
}
|
||||
|
||||
public close(code: number): void {
|
||||
this.ws.close(code);
|
||||
}
|
||||
|
||||
//
|
||||
// Service implementation
|
||||
//
|
||||
@ -1067,12 +921,31 @@ export class Connection extends Service {
|
||||
}
|
||||
}
|
||||
|
||||
private async handleNormal(incomingMessage: IncomingMessage) {
|
||||
const authHeaders = incomingMessage.headers.authorization;
|
||||
if (!authHeaders) {
|
||||
private async handleAuth(
|
||||
verb: string,
|
||||
path: string,
|
||||
headers: Record<string, string>,
|
||||
) {
|
||||
// We are actively registering device
|
||||
if (verb === 'PUT' && path === '/v1/devices/link') {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.handleAuthHeaders(headers);
|
||||
}
|
||||
|
||||
private async handleAuthHeaders(
|
||||
headers: Record<string, string | Array<string> | 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,
|
||||
});
|
||||
@ -1088,7 +961,7 @@ export class Connection extends Service {
|
||||
|
||||
const device = await this.server.auth(username, password);
|
||||
if (!device) {
|
||||
debug('Invalid WebSocket credentials @ %s: %j', incomingMessage.url, {
|
||||
debug('Invalid WebSocket credentials @ %j', {
|
||||
username,
|
||||
password,
|
||||
});
|
||||
@ -1096,6 +969,11 @@ export class Connection extends Service {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.device !== undefined) {
|
||||
assert.strictEqual(this.device, device, 'Cannot change active device');
|
||||
return;
|
||||
}
|
||||
|
||||
this.device = device;
|
||||
this.router.setIsAuthenticated(true);
|
||||
|
||||
|
||||
@ -32,11 +32,21 @@ type Route = Readonly<{
|
||||
handler: Handler;
|
||||
}>;
|
||||
|
||||
export type RouterOptions = Readonly<{
|
||||
beforeRequest: (
|
||||
verb: string,
|
||||
path: string,
|
||||
headers: Record<string, string>,
|
||||
) => Promise<void>;
|
||||
}>;
|
||||
|
||||
export class Router {
|
||||
private readonly routes: Array<Route> = [];
|
||||
|
||||
private isAuthenticated = false;
|
||||
|
||||
constructor(private options: RouterOptions) {}
|
||||
|
||||
public register(method: string, pattern: string, handler: Handler): void {
|
||||
this.routes.push({
|
||||
method,
|
||||
@ -83,6 +93,12 @@ export class Router {
|
||||
// eslint-disable-next-line @typescript-eslint/no-deprecated
|
||||
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;
|
||||
|
||||
@ -35,7 +35,9 @@ export abstract class Service {
|
||||
debug('onMessage error', error.stack);
|
||||
}
|
||||
});
|
||||
this.ws.once('close', () => this.onClose());
|
||||
this.ws.once('close', () => {
|
||||
this.onClose();
|
||||
});
|
||||
}
|
||||
|
||||
public async send(
|
||||
|
||||
Loading…
Reference in New Issue
Block a user