Compare commits

...

23 Commits
18-x-y ... main

Author SHA1 Message Date
Fedor Indutny
3e556f913f 22.0.0
Some checks failed
Test / test (push) Has been cancelled
Publish / Publish (push) Has been cancelled
2026-04-16 12:48:41 -07:00
Fedor Indutny
fb9770f781
breaking: move upload form to grpc 2026-04-16 12:48:25 -07:00
Fedor Indutny
6588848faa 21.1.2
Some checks failed
Publish / Publish (push) Has been cancelled
2026-04-14 11:37:58 -07:00
Fedor Indutny
3ee74eac12
fix: use correct local address for decryption 2026-04-14 14:36:32 -04:00
trevor-signal
3f82acc35a 21.1.1
Some checks failed
Publish / Publish (push) Has been cancelled
2026-04-10 12:38:39 -04:00
trevor-signal
f12ff0b3de fix: allow publishing from tag 2026-04-10 12:38:02 -04:00
trevor-signal
5055037e59 21.1.0
Some checks failed
Publish / Publish (push) Has been cancelled
2026-04-10 12:31:09 -04:00
trevor-signal
d87d2d5dd9 fix: pnpm install instead of CI during publish 2026-04-10 12:30:55 -04:00
andrew-signal
805c07ca1b
Bump to libsignal v0.92.1 2026-04-10 12:19:49 -04:00
Fedor Indutny
dfeafcb058 21.0.1
Some checks failed
Publish / Publish (push) Has been cancelled
2026-04-07 10:27:19 -07:00
Fedor Indutny
0631853445 fix: untag pni in whoami response 2026-04-07 10:27:07 -07:00
Fedor Indutny
dee38e55be 21.0.0
Some checks failed
Publish / Publish (push) Has been cancelled
2026-04-03 17:05:05 -07:00
Fedor Indutny
e5c366be15 breaking: move username lookup to grpc 2026-04-03 17:04:33 -07:00
Fedor Indutny
534206223d 20.0.1
Some checks failed
Publish / Publish (push) Has been cancelled
2026-04-03 16:04:38 -07:00
Fedor Indutny
68bcf5d8ef Add endpoint for multirecipient story 2026-04-03 16:04:21 -07:00
Fedor Indutny
a254dafa1c 20.0.0
Some checks failed
Publish / Publish (push) Has been cancelled
2026-04-03 14:42:42 -07:00
Fedor Indutny
60903b3f0d
Add MultiRecipientMessage gRPC endpoint 2026-04-03 14:42:10 -07:00
Fedor Indutny
7b8104d873 19.1.0
Some checks failed
Publish / Publish (push) Has been cancelled
2026-04-02 15:04:54 -07:00
ayumi-signal
eec4d66f68
Add support for group terminate 2026-04-02 14:49:16 -07:00
Fedor Indutny
226c3c4f04 19.0.0
Some checks failed
Publish / Publish (push) Has been cancelled
2026-04-02 11:02:26 -07:00
Fedor Indutny
46e64649f9 feat: 4409 support 2026-04-02 11:02:15 -07:00
Fedor Indutny
5548fe2bb7 fix: take auth header from initial request too 2026-04-02 10:01:43 -07:00
Fedor Indutny
d7a1b5852c
major: switch to http2 2026-03-31 17:47:19 -07:00
44 changed files with 6487 additions and 3583 deletions

View File

@ -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

View File

@ -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
View File

@ -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

File diff suppressed because it is too large Load Diff

View File

@ -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
View 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

File diff suppressed because it is too large Load Diff

View File

@ -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;

View File

@ -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

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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 {
}

View 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 {}

View 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"];
}
}

View 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"];
}
}

View 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"];
}
}

View 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 {
}

View 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;
}

View 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;
}

View 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;
}

View 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 {}

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View File

@ -122,6 +122,7 @@ export class Group extends GroupData {
description: null,
announcementsOnly: null,
membersBanned: null,
terminated: null,
};
return new Group({

View File

@ -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,

View File

@ -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;

View File

@ -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
View File

@ -0,0 +1,37 @@
// Copyright 2026 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import createDebug from 'debug';
import type { ServerRequest, ServerResponse } from 'microrouter';
import { send } from 'micro';
import { ParseAuthHeaderResult, parseAuthHeader } from '../util';
import type { Server } from './base';
import type { Device } from '../data/device';
const debug = createDebug('mock:server:base');
export function parsePassword(req: ServerRequest): ParseAuthHeaderResult {
return parseAuthHeader(req.headers.authorization);
}
export async function auth(
server: Server,
req: ServerRequest,
res: ServerResponse,
): Promise<Device | undefined> {
const { username, password, error } = parsePassword(req);
if (error) {
debug('%s %s auth failed, error %j', req.method, req.url, error);
void send(res, 401, { error });
return;
}
const device = await server.auth(username ?? '', password ?? '');
if (!device) {
debug('%s %s auth failed, need re-provisioning', req.method, req.url);
void send(res, 401, { error: 'Need re-provisioning' });
return;
}
return device;
}

View File

@ -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
View 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);
}
};
};

View File

@ -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;
}

View File

@ -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);

View File

@ -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;

View File

@ -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(