Source added

This commit is contained in:
Fr4nz D13trich 2025-11-20 09:26:33 +01:00
parent b2864b500e
commit ba28ca859e
8352 changed files with 1487182 additions and 1 deletions

1
libsignal-service/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

View file

@ -0,0 +1,115 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
plugins {
id("java-library")
id("org.jetbrains.kotlin.jvm")
id("java-test-fixtures")
id("maven-publish")
id("signing")
id("idea")
id("org.jlleitschuh.gradle.ktlint")
id("com.squareup.wire")
}
val signalBuildToolsVersion: String by rootProject.extra
val signalCompileSdkVersion: String by rootProject.extra
val signalTargetSdkVersion: Int by rootProject.extra
val signalMinSdkVersion: Int by rootProject.extra
val signalJavaVersion: JavaVersion by rootProject.extra
val signalKotlinJvmTarget: String by rootProject.extra
java {
withJavadocJar()
withSourcesJar()
sourceCompatibility = signalJavaVersion
targetCompatibility = signalJavaVersion
}
tasks.withType<KotlinCompile>().configureEach {
kotlin {
compilerOptions {
jvmTarget = JvmTarget.fromTarget(signalKotlinJvmTarget)
freeCompilerArgs = listOf("-Xjvm-default=all")
suppressWarnings = true
}
}
}
afterEvaluate {
listOf(
"runKtlintCheckOverMainSourceSet",
"runKtlintFormatOverMainSourceSet",
"sourcesJar"
).forEach { taskName ->
tasks.named(taskName) {
mustRunAfter(tasks.named("generateMainProtos"))
}
}
}
ktlint {
version.set("1.2.1")
filter {
exclude { entry ->
entry.file.toString().contains("build/generated/source/wire")
}
}
}
wire {
protoLibrary = true
kotlin {
javaInterop = true
}
sourcePath {
srcDir("src/main/protowire")
}
custom {
// Comes from wire-handler jar project
schemaHandlerFactoryClass = "org.signal.wire.Factory"
}
}
tasks.whenTaskAdded {
if (name == "lint") {
enabled = false
}
}
dependencies {
api(libs.google.libphonenumber)
api(libs.jackson.core)
api(libs.jackson.module.kotlin)
implementation(libs.libsignal.client)
api(libs.square.okhttp3)
api(libs.square.okio)
implementation(libs.google.jsr305)
api(libs.rxjava3.rxjava)
implementation(libs.rxjava3.rxkotlin)
implementation(libs.kotlin.stdlib.jdk8)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.coroutines.core.jvm)
implementation(project(":core-util-jvm"))
testImplementation(testLibs.junit.junit)
testImplementation(testLibs.assertk)
testImplementation(testLibs.conscrypt.openjdk.uber)
testImplementation(testLibs.mockk)
testFixturesImplementation(libs.libsignal.client)
testFixturesImplementation(testLibs.junit.junit)
}

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<lint>
<!-- It's a bummer, but Android Studio doesn't seem to be smart enough to recognize Optional and stream() are available to us in this lib -->
<issue id="NewApi" severity="ignore" />
<issue id="OptionalUsedAsFieldOrParameterType" severity="ignore" />
<issue id="NewClassNamingConvention" severity="ignore" />
</lint>

View file

@ -0,0 +1,57 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package com.squareup.wire.internal
import okio.ByteString
/**
* File inspired by countNonNull implementations in com.squareup.wire.internal.Internal.kt
*
* Do not change the name without also updating the name used in wire-handler jar project. Our custom
* handler tweaks the generated proto code to call this less restrictive oneOf validators. Wire requires
* at most one non-null but iOS can't handle that currently, so we use at most one non-null and non-default.
*
* For example, a oneOf property that is an int but set to 0 is valid.
*/
/** Do not change the name. Returns the number of non-null values in `a, b`. */
fun countNonDefa(a: Any?, b: Any?): Int {
return a.isNonDefault() + b.isNonDefault()
}
/** Do not change the name. Returns the number of non-null values in `a, b, c`. */
fun countNonDefa(a: Any?, b: Any?, c: Any?): Int {
return a.isNonDefault() + b.isNonDefault() + c.isNonDefault()
}
/** Do not change the name. Returns the number of non-null values in `a, b, c, d, rest`. */
fun countNonDefa(a: Any?, b: Any?, c: Any?, d: Any?, vararg rest: Any?): Int {
var result = 0
result += a.isNonDefault()
result += b.isNonDefault()
result += c.isNonDefault()
result += d.isNonDefault()
for (o in rest) {
result += o.isNonDefault()
}
return result
}
private fun Any?.isNonDefault(): Int {
return when {
this == null -> 0
this is Boolean && this == false -> 0
this is ByteString && this.size == 0 -> 0
this is Byte && this == 0.toByte() -> 0
this is Short && this == 0.toShort() -> 0
this is Int && this == 0 -> 0
this is Long && this == 0L -> 0
this is String && this == "" -> 0
this is Double && this == 0.0 -> 0
this is Float && this == 0f -> 0
else -> 1
}
}

View file

@ -0,0 +1,54 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.signalservice.api
import org.whispersystems.signalservice.api.backup.MessageBackupKey
import org.whispersystems.signalservice.api.kbs.MasterKey
import org.signal.libsignal.messagebackup.AccountEntropyPool as LibSignalAccountEntropyPool
/**
* The Root of All Entropy. You can use this to derive the [MasterKey] or [MessageBackupKey].
*/
class AccountEntropyPool(value: String) {
val value = value.lowercase()
val displayValue = value.uppercase()
companion object {
private val INVALID_CHARACTERS = Regex("[^0-9a-zA-Z]")
const val LENGTH = 64
fun generate(): AccountEntropyPool {
return AccountEntropyPool(LibSignalAccountEntropyPool.generate())
}
fun parseOrNull(input: String): AccountEntropyPool? {
val stripped = removeIllegalCharacters(input)
if (stripped.length != LENGTH) {
return null
}
return AccountEntropyPool(stripped)
}
fun isFullyValid(input: String): Boolean {
return LibSignalAccountEntropyPool.isValid(input)
}
fun removeIllegalCharacters(input: String): String {
return input.replace(INVALID_CHARACTERS, "")
}
}
fun deriveMasterKey(): MasterKey {
return MasterKey(LibSignalAccountEntropyPool.deriveSvrKey(value))
}
fun deriveMessageBackupKey(): MessageBackupKey {
val libSignalBackupKey = LibSignalAccountEntropyPool.deriveBackupKey(value)
return MessageBackupKey(libSignalBackupKey.serialize())
}
}

View file

@ -0,0 +1,7 @@
package org.whispersystems.signalservice.api;
public class ContentTooLargeException extends IllegalStateException {
public ContentTooLargeException(long size) {
super("Too large! Size: " + size + " bytes");
}
}

View file

@ -0,0 +1,45 @@
package org.whispersystems.signalservice.api;
import java.util.Optional;
/**
* An exception thrown when something about the proto is malformed. e.g. one of the fields has an invalid value.
*/
public final class InvalidMessageStructureException extends Exception {
private final Optional<String> sender;
private final Optional<Integer> device;
public InvalidMessageStructureException(String message) {
super(message);
this.sender = Optional.empty();
this.device = Optional.empty();
}
public InvalidMessageStructureException(String message, String sender, int device) {
super(message);
this.sender = Optional.ofNullable(sender);
this.device = Optional.of(device);
}
public InvalidMessageStructureException(Exception e, String sender, int device) {
super(e);
this.sender = Optional.ofNullable(sender);
this.device = Optional.of(device);
}
public InvalidMessageStructureException(Exception e) {
super(e);
this.sender = Optional.empty();
this.device = Optional.empty();
}
public Optional<String> getSender() {
return sender;
}
public Optional<Integer> getDevice() {
return device;
}
}

View file

@ -0,0 +1,18 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.signalservice.api
import org.signal.libsignal.protocol.InvalidKeyException
import org.signal.libsignal.protocol.SignalProtocolAddress
import java.io.IOException
/**
* Wraps an [InvalidKeyException] in an [IOException] with a nicer message.
*/
class InvalidPreKeyException(
address: SignalProtocolAddress,
invalidKeyException: InvalidKeyException
) : IOException("Invalid prekey for $address", invalidKeyException)

View file

@ -0,0 +1,482 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.signalservice.api
import io.reactivex.rxjava3.core.Single
import org.signal.core.util.concurrent.safeBlockingGet
import org.whispersystems.signalservice.api.NetworkResult.ApplicationError
import org.whispersystems.signalservice.api.NetworkResult.StatusCodeError
import org.whispersystems.signalservice.api.push.exceptions.MalformedRequestException
import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException
import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException
import org.whispersystems.signalservice.api.websocket.SignalWebSocket
import org.whispersystems.signalservice.internal.util.JsonUtil
import org.whispersystems.signalservice.internal.websocket.WebSocketConnection
import org.whispersystems.signalservice.internal.websocket.WebSocketRequestMessage
import org.whispersystems.signalservice.internal.websocket.WebsocketResponse
import java.io.IOException
import java.util.concurrent.TimeoutException
import kotlin.reflect.KClass
import kotlin.reflect.cast
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
typealias StatusCodeErrorAction = (StatusCodeError<*>) -> Unit
typealias ApplicationErrorAction = (ApplicationError<*>) -> Unit
/**
* A helper class that wraps the result of a network request, turning common exceptions
* into sealed classes, with optional request chaining.
*
* This was designed to be a middle ground between the heavy reliance on specific exceptions
* in old network code (which doesn't translate well to kotlin not having checked exceptions)
* and plain rx, which still doesn't free you from having to catch exceptions and translate
* things to sealed classes yourself.
*
* If you have a very complicated network request with lots of different possible response types
* based on specific errors, this isn't for you. You're likely better off writing your own
* sealed class. However, for the majority of requests which just require getting a model from
* the success case and the status code of the error, this can be quite convenient.
*/
sealed class NetworkResult<T>(
private val statusCodeErrorActions: MutableSet<StatusCodeErrorAction> = mutableSetOf(),
private val applicationErrorActions: MutableSet<ApplicationErrorAction> = mutableSetOf()
) {
companion object {
/**
* A convenience method to capture the common case of making a request.
* Perform the network action in the [fetcher], returning your result.
* Common exceptions will be caught and translated to errors.
*/
@JvmStatic
fun <T> fromFetch(fetcher: Fetcher<T>): NetworkResult<T> = try {
Success(fetcher.fetch())
} catch (e: NonSuccessfulResponseCodeException) {
StatusCodeError(e)
} catch (e: IOException) {
NetworkError(e)
} catch (e: Throwable) {
ApplicationError(e)
}
/**
* A convenience method to convert a websocket request into a network result, parsing the body into type [T].
*
* Common HTTP errors will be translated to [StatusCodeError]s.
*/
inline fun <reified T : Any> fromWebSocket(fetcher: Fetcher<Single<WebsocketResponse>>): NetworkResult<T> {
return fromWebSocket(DefaultWebSocketConverter(T::class), fetcher)
}
/**
* A convenience method to convert a websocket request into a network result, using the provided
* [webSocketResponseConverter] to parse the response into type [T].
*
* Common HTTP errors will be translated to [StatusCodeError]s.
*/
fun <T> fromWebSocket(
webSocketResponseConverter: WebSocketResponseConverter<T>,
fetcher: Fetcher<Single<WebsocketResponse>>
): NetworkResult<T> {
return try {
val result: Result<NetworkResult<T>> = fetcher.fetch()
.map { response: WebsocketResponse -> Result.success(webSocketResponseConverter.convert(response)) }
.onErrorReturn { Result.failure(it) }
.safeBlockingGet()
result.getOrThrow()
} catch (e: NonSuccessfulResponseCodeException) {
StatusCodeError(e)
} catch (e: IOException) {
NetworkError(e)
} catch (e: TimeoutException) {
NetworkError(PushNetworkException(e))
} catch (e: InterruptedException) {
NetworkError(PushNetworkException(e))
} catch (e: Throwable) {
ApplicationError(e)
}
}
/**
* A convenience method to convert a websocket request into a network result.
* Common HTTP errors will be translated to [StatusCodeError]s.
*/
@JvmStatic
fun fromWebSocketRequest(
signalWebSocket: SignalWebSocket,
request: WebSocketRequestMessage,
timeout: Duration = WebSocketConnection.DEFAULT_SEND_TIMEOUT
): NetworkResult<Unit> = fromWebSocketRequest(
signalWebSocket = signalWebSocket,
request = request,
timeout = timeout,
clazz = Unit::class
)
/**
* A convenience method to convert a websocket request into a network result with simple conversion of the response body to the desired class.
* Common HTTP errors will be translated to [StatusCodeError]s.
*/
@JvmStatic
fun <T : Any> fromWebSocketRequest(
signalWebSocket: SignalWebSocket,
request: WebSocketRequestMessage,
clazz: KClass<T>,
timeout: Duration = WebSocketConnection.DEFAULT_SEND_TIMEOUT
): NetworkResult<T> {
return fromWebSocketRequest(
signalWebSocket = signalWebSocket,
request = request,
timeout = timeout,
webSocketResponseConverter = DefaultWebSocketConverter(clazz)
)
}
/**
* A convenience method to convert a websocket request into a network result with the ability to fully customize the conversion of the response.
* Common HTTP errors will be translated to [StatusCodeError]s.
*/
@JvmStatic
fun <T : Any> fromWebSocketRequest(
signalWebSocket: SignalWebSocket,
request: WebSocketRequestMessage,
timeout: Duration = WebSocketConnection.DEFAULT_SEND_TIMEOUT,
webSocketResponseConverter: WebSocketResponseConverter<T>
): NetworkResult<T> {
return fromWebSocket(webSocketResponseConverter) { signalWebSocket.request(request, timeout) }
}
/**
* Wraps a local operation, [block], that may throw an exception that should be wrapped in an [ApplicationError]
* and abort downstream network requests that directly depend on the output of the local operation. Should
* be used almost exclusively prior to a [then].
*/
fun <T : Any> fromLocal(block: () -> T): NetworkResult<T> {
return try {
Success(block())
} catch (e: Throwable) {
ApplicationError(e)
}
}
/**
* Runs [operation] to perform a network call. If [shouldRetry] returns false for the result, then returns it. Otherwise will call [operation] repeatedly
* until [shouldRetry] returns false or is called [maxAttempts] number of times.
*
* @param maxAttempts Max attempts to try the network operation, must be 1 or more, default is 5
* @param shouldRetry Predicate to determine if network operation should be retried, default is any [NetworkError] result is retried
* @param logAttempt Log each attempt before [operation] is called, default is noop
* @param operation Network operation that can be called repeatedly for each attempt
*/
fun <T : Any?> withRetry(
maxAttempts: Int = 5,
shouldRetry: (NetworkResult<T>) -> Boolean = { it is NetworkError },
logAttempt: (attempt: Int, maxAttempts: Int) -> Unit = { _, _ -> },
operation: () -> NetworkResult<T>
): NetworkResult<T> {
require(maxAttempts > 0)
lateinit var result: NetworkResult<T>
for (attempt in 0 until maxAttempts) {
logAttempt(attempt, maxAttempts)
result = operation()
if (!shouldRetry(result)) {
return result
}
}
return result
}
}
/** Indicates the request was successful */
data class Success<T>(val result: T) : NetworkResult<T>()
/** Indicates a generic network error occurred before we were able to process a response. */
data class NetworkError<T>(val exception: IOException) : NetworkResult<T>()
/** Indicates we got a response, but it was a non-2xx response. */
data class StatusCodeError<T>(val code: Int, val stringBody: String?, val binaryBody: ByteArray?, private val headers: Map<String, String>, val exception: NonSuccessfulResponseCodeException) : NetworkResult<T>() {
constructor(e: NonSuccessfulResponseCodeException) : this(e.code, e.stringBody, e.binaryBody, e.headers, e)
constructor(result: StatusCodeError<*>) : this(result.code, result.stringBody, result.binaryBody, result.headers, result.exception)
inline fun <reified T> parseJsonBody(): T? {
return try {
if (stringBody != null) {
JsonUtil.fromJsonResponse(stringBody, T::class.java)
} else if (binaryBody != null) {
JsonUtil.fromJsonResponse(binaryBody, T::class.java)
} else {
null
}
} catch (_: MalformedRequestException) {
null
}
}
fun header(key: String): String? {
return headers[key.lowercase()]
}
fun retryAfter(): Duration? {
return header("retry-after")?.toLongOrNull()?.seconds
}
}
/** Indicates that the application somehow failed in a way unrelated to network activity. Usually a runtime crash. */
data class ApplicationError<T>(val throwable: Throwable) : NetworkResult<T>()
/**
* Returns the result if successful, otherwise turns the result back into an exception and throws it.
*
* Useful for bridging to Java, where you may want to use try-catch.
*/
@Throws(NonSuccessfulResponseCodeException::class, IOException::class, Throwable::class)
fun successOrThrow(): T {
when (this) {
is Success -> return result
is NetworkError -> throw exception
is StatusCodeError -> throw exception
is ApplicationError -> throw throwable
}
}
/**
* Returns the result if successful, otherwise null.
*/
fun successOrNull(): T? {
return when (this) {
is Success -> result
else -> null
}
}
/**
* Returns the [Throwable] associated with the result, or null if the result is successful.
*/
fun getCause(): Throwable? {
return when (this) {
is Success -> null
is NetworkError -> exception
is StatusCodeError -> exception
is ApplicationError -> throwable
}
}
/**
* Takes the output of one [NetworkResult] and transforms it into another if the operation is successful.
* If it's non-successful, [transform] lambda is not run, and instead the original failure will be propagated.
* Useful for changing the type of a result.
*
* If an exception is thrown during [transform], this is mapped to an [ApplicationError].
*
* ```kotlin
* val user: NetworkResult<LocalUserModel> = NetworkResult
* .fromFetch { fetchRemoteUserModel() }
* .map { it.toLocalUserModel() }
* ```
*/
fun <R> map(transform: (T) -> R): NetworkResult<R> {
val map = when (this) {
is Success -> {
try {
Success(transform(this.result))
} catch (e: Throwable) {
ApplicationError<R>(e)
}
}
is NetworkError -> NetworkError<R>(exception)
is ApplicationError -> ApplicationError<R>(throwable)
is StatusCodeError -> StatusCodeError<R>(this)
}
return map.runOnStatusCodeError(statusCodeErrorActions).runOnApplicationError(applicationErrorActions)
}
/**
* Provides the ability to fallback to [fallback] if the current [NetworkResult] is non-successful.
*
* The [fallback] will only be triggered on non-[Success] results. You can provide a [predicate] to limit what kinds of errors you fallback on
* (the default is to fallback on every error).
*
* This primary usecase of this is to make an unauth websocket request and fallback to auth websocket upon failure.
*
* ```kotlin
* val user: NetworkResult<LocalUserModel> = NetworkResult
* .fromWebSocket { unauthWebSocket.request(request, sealedSenderAccess) }
* .fallback { NetworkResult.fromWebSocket { authWebSocket.request(request) } }
* ```
*
* @param predicate If this lambda returns true, the fallback will be triggered.
*/
fun fallback(predicate: (NetworkResult<T>) -> Boolean = { true }, fallback: () -> NetworkResult<T>): NetworkResult<T> {
if (this is Success) {
return this
}
return if (predicate(this)) {
fallback()
} else {
this
}
}
/**
* Takes the output of one [NetworkResult] and passes it as the input to another if the operation is successful.
* If it's non-successful, the [result] lambda is not run, and instead the original failure will be propagated.
* Useful for chaining operations together.
*
* ```kotlin
* val networkResult: NetworkResult<MyData> = NetworkResult
* .fromFetch { fetchAuthCredential() }
* .then {
* NetworkResult.fromFetch { credential -> fetchData(credential) }
* }
* ```
*/
fun <R> then(result: (T) -> NetworkResult<R>): NetworkResult<R> {
val then = when (this) {
is Success -> result(this.result)
is NetworkError -> NetworkError<R>(exception)
is ApplicationError -> ApplicationError<R>(throwable)
is StatusCodeError -> StatusCodeError<R>(this)
}
return then.runOnStatusCodeError(statusCodeErrorActions).runOnApplicationError(applicationErrorActions)
}
/**
* Will perform an operation if the result at this point in the chain is successful. Note that it runs if the chain is _currently_ successful. It does not
* depend on anything further down the chain.
*
* ```kotlin
* val networkResult: NetworkResult<MyData> = NetworkResult
* .fromFetch { fetchAuthCredential() }
* .runIfSuccessful { storeMyCredential(it) }
* ```
*/
fun runIfSuccessful(result: (T) -> Unit): NetworkResult<T> {
if (this is Success) {
result(this.result)
}
return this
}
/**
* Specify an action to be run when a status code error occurs. When a result is a [StatusCodeError] or is transformed into one further down the chain via
* a future [map] or [then], this code will be run. There can only ever be a single status code error in a chain, and therefore this lambda will only ever
* be run a single time.
*
* This is a low-visibility way of doing things, so use sparingly.
*
* ```kotlin
* val result = NetworkResult
* .fromFetch { getAuth() }
* .runOnStatusCodeError { error -> logError(error) }
* .then { credential ->
* NetworkResult.fromFetch { fetchUserDetails(credential) }
* }
* ```
*/
fun runOnStatusCodeError(action: StatusCodeErrorAction): NetworkResult<T> {
return runOnStatusCodeError(setOf(action))
}
private fun runOnStatusCodeError(actions: Collection<StatusCodeErrorAction>): NetworkResult<T> {
if (actions.isEmpty()) {
return this
}
statusCodeErrorActions += actions
if (this is StatusCodeError) {
statusCodeErrorActions.forEach { it.invoke(this) }
statusCodeErrorActions.clear()
}
return this
}
/**
* Specify an action to be run when a application error occurs. When a result is a [ApplicationErrorAction] or is transformed into one further down the chain via
* a future [map] or [then], this code will be run. There can only ever be a single application error in a chain, and therefore this lambda will only ever
* be run a single time.
*
* This is a low-visibility way of doing things, so use sparingly.
*
* ```kotlin
* val result = NetworkResult
* .fromFetch { getAuth() }
* .runOnApplicationError { error -> logError(error) }
* .then { credential ->
* NetworkResult.fromFetch { fetchUserDetails(credential) }
* }
* ```
*/
fun runOnApplicationError(action: ApplicationErrorAction): NetworkResult<T> {
return runOnApplicationError(setOf(action))
}
private fun runOnApplicationError(actions: Collection<ApplicationErrorAction>): NetworkResult<T> {
if (actions.isEmpty()) {
return this
}
applicationErrorActions += actions
if (this is ApplicationError) {
applicationErrorActions.forEach { it.invoke(this) }
applicationErrorActions.clear()
}
return this
}
fun interface Fetcher<T> {
@Throws(Exception::class)
fun fetch(): T
}
fun interface WebSocketResponseConverter<T> {
@Throws(Exception::class)
fun convert(response: WebsocketResponse): NetworkResult<T>
fun <T : Any> WebsocketResponse.toStatusCodeError(): NetworkResult<T> {
return StatusCodeError(NonSuccessfulResponseCodeException(this.status, "", this.body, this.headers))
}
fun <T : Any> WebsocketResponse.toSuccess(responseJsonClass: KClass<T>): NetworkResult<T> {
return when (responseJsonClass) {
Unit::class -> Success(responseJsonClass.cast(Unit))
String::class -> Success(responseJsonClass.cast(this.body))
else -> Success(JsonUtil.fromJson(this.body, responseJsonClass.java))
}
}
}
class DefaultWebSocketConverter<T : Any>(private val responseJsonClass: KClass<T>) : WebSocketResponseConverter<T> {
override fun convert(response: WebsocketResponse): NetworkResult<T> {
return if (response.status < 200 || response.status > 299) {
response.toStatusCodeError()
} else {
response.toSuccess(responseJsonClass)
}
}
}
class LongPollingWebSocketConverter<T : Any>(private val responseJsonClass: KClass<T>) : WebSocketResponseConverter<T> {
override fun convert(response: WebsocketResponse): NetworkResult<T> {
return if (response.status == 204 || response.status < 200 || response.status > 299) {
response.toStatusCodeError()
} else {
response.toSuccess(responseJsonClass)
}
}
}
}

View file

@ -0,0 +1,234 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.signalservice.api
import org.whispersystems.signalservice.api.push.exceptions.AuthorizationFailedException
import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException
import org.whispersystems.signalservice.api.push.exceptions.NotFoundException
import org.whispersystems.signalservice.api.push.exceptions.ProofRequiredException
import org.whispersystems.signalservice.api.push.exceptions.RateLimitException
import org.whispersystems.signalservice.api.push.exceptions.ServerRejectedException
import org.whispersystems.signalservice.api.push.exceptions.UnregisteredUserException
import org.whispersystems.signalservice.api.websocket.WebSocketUnavailableException
import org.whispersystems.signalservice.internal.push.SendGroupMessageResponse
import org.whispersystems.signalservice.internal.push.SendMessageResponse
import org.whispersystems.signalservice.internal.push.exceptions.GroupMismatchedDevicesException
import org.whispersystems.signalservice.internal.push.exceptions.GroupStaleDevicesException
import org.whispersystems.signalservice.internal.push.exceptions.InAppPaymentProcessorError
import org.whispersystems.signalservice.internal.push.exceptions.InAppPaymentReceiptCredentialError
import org.whispersystems.signalservice.internal.push.exceptions.InvalidUnidentifiedAccessHeaderException
import org.whispersystems.signalservice.internal.push.exceptions.MismatchedDevicesException
import org.whispersystems.signalservice.internal.push.exceptions.PaymentsRegionException
import org.whispersystems.signalservice.internal.push.exceptions.StaleDevicesException
import java.io.IOException
import java.util.Optional
import kotlin.time.Duration.Companion.seconds
/**
* Bridge layer to convert [NetworkResult]s into the response data or thrown exceptions.
*/
object NetworkResultUtil {
/**
* Unwraps [NetworkResult] to a basic [IOException] or [NonSuccessfulResponseCodeException]. Should only
* be used when you don't need a specific flavor of IOException for a specific response of any kind.
*/
@JvmStatic
@Throws(IOException::class)
fun <T> successOrThrow(result: NetworkResult<T>): T {
return when (result) {
is NetworkResult.Success -> result.result
is NetworkResult.ApplicationError -> {
throw when (val error = result.throwable) {
is IOException, is RuntimeException -> error
else -> RuntimeException(error)
}
}
is NetworkResult.NetworkError -> throw result.exception
is NetworkResult.StatusCodeError -> throw result.exception
}
}
/**
* Convert to a basic [IOException] or [NonSuccessfulResponseCodeException]. Should only be used when you don't
* need a specific flavor of IOException for a specific response code.
*/
@JvmStatic
@Throws(IOException::class)
fun <T> toBasicLegacy(result: NetworkResult<T>): T {
return when (result) {
is NetworkResult.Success -> result.result
is NetworkResult.ApplicationError -> {
throw when (val error = result.throwable) {
is IOException, is RuntimeException -> error
else -> RuntimeException(error)
}
}
is NetworkResult.NetworkError -> throw result.exception
is NetworkResult.StatusCodeError -> {
when (result.code) {
401, 403 -> throw AuthorizationFailedException(result.code, "Authorization failed!")
413, 429 -> throw RateLimitException(result.code, "Rate Limited", Optional.ofNullable(result.header("retry-after")?.toLongOrNull()?.seconds?.inWholeMilliseconds))
else -> throw result.exception
}
}
}
}
/**
* Convert [NetworkResult] into expected type exceptions for an individual message send.
*/
@JvmStatic
@Throws(
AuthorizationFailedException::class,
UnregisteredUserException::class,
MismatchedDevicesException::class,
StaleDevicesException::class,
ProofRequiredException::class,
WebSocketUnavailableException::class,
ServerRejectedException::class,
IOException::class
)
fun toMessageSendLegacy(destination: String, result: NetworkResult<SendMessageResponse>): SendMessageResponse {
return when (result) {
is NetworkResult.Success -> result.result
is NetworkResult.ApplicationError -> {
throw when (val error = result.throwable) {
is IOException, is RuntimeException -> error
else -> RuntimeException(error)
}
}
is NetworkResult.NetworkError -> throw result.exception
is NetworkResult.StatusCodeError -> {
throw when (result.code) {
401 -> AuthorizationFailedException(result.code, "Authorization failed!")
404 -> UnregisteredUserException(destination, result.exception)
409 -> MismatchedDevicesException(result.parseJsonBody())
410 -> StaleDevicesException(result.parseJsonBody())
413, 429 -> RateLimitException(result.code, "Rate Limited", Optional.ofNullable(result.header("retry-after")?.toLongOrNull()?.seconds?.inWholeMilliseconds))
428 -> ProofRequiredException(result.parseJsonBody(), result.header("retry-after")?.toLongOrNull() ?: -1)
508 -> ServerRejectedException()
else -> result.exception
}
}
}
}
/**
* Convert [NetworkResult] into expected type exceptions for a multi-recipient message send.
*/
@JvmStatic
@Throws(
InvalidUnidentifiedAccessHeaderException::class,
NotFoundException::class,
GroupMismatchedDevicesException::class,
GroupStaleDevicesException::class,
RateLimitException::class,
ServerRejectedException::class,
WebSocketUnavailableException::class,
IOException::class
)
fun toGroupMessageSendLegacy(result: NetworkResult<SendGroupMessageResponse>): SendGroupMessageResponse {
return when (result) {
is NetworkResult.Success -> result.result
is NetworkResult.ApplicationError -> {
throw when (val error = result.throwable) {
is IOException, is RuntimeException -> error
else -> RuntimeException(error)
}
}
is NetworkResult.NetworkError -> throw result.exception
is NetworkResult.StatusCodeError -> {
throw when (result.code) {
401 -> InvalidUnidentifiedAccessHeaderException()
404 -> NotFoundException("At least one unregistered user is message send.")
409 -> GroupMismatchedDevicesException(result.parseJsonBody())
410 -> GroupStaleDevicesException(result.parseJsonBody())
413, 429 -> throw RateLimitException(result.code, "Rate Limited", Optional.ofNullable(result.header("retry-after")?.toLongOrNull()?.seconds?.inWholeMilliseconds))
508 -> ServerRejectedException()
else -> result.exception
}
}
}
}
@JvmStatic
@Throws(IOException::class)
fun <T> toPreKeysLegacy(result: NetworkResult<T>): T {
return when (result) {
is NetworkResult.Success -> result.result
is NetworkResult.StatusCodeError -> {
throw when (result.code) {
400, 401 -> AuthorizationFailedException(result.code, "Authorization failed!")
404 -> NotFoundException("Not found")
429 -> RateLimitException(result.code, "Rate limit exceeded: ${result.code}", Optional.empty())
508 -> ServerRejectedException()
else -> result.exception
}
}
is NetworkResult.NetworkError -> throw result.exception
is NetworkResult.ApplicationError -> {
throw when (val error = result.throwable) {
is IOException, is RuntimeException -> error
else -> RuntimeException(error)
}
}
}
}
/**
* Convert a [NetworkResult] into typed exceptions expected during setting the user's profile.
*/
@JvmStatic
@Throws(AuthorizationFailedException::class, PaymentsRegionException::class, RateLimitException::class, IOException::class)
fun toSetProfileLegacy(result: NetworkResult<String?>): String? {
return when (result) {
is NetworkResult.Success -> result.result
is NetworkResult.ApplicationError -> {
throw when (val error = result.throwable) {
is IOException, is RuntimeException -> error
else -> RuntimeException(error)
}
}
is NetworkResult.NetworkError -> throw result.exception
is NetworkResult.StatusCodeError -> {
throw when (result.code) {
401 -> AuthorizationFailedException(result.code, "Authorization failed!")
403 -> PaymentsRegionException(result.code)
413, 429 -> RateLimitException(result.code, "Rate Limited", Optional.ofNullable(result.header("retry-after")?.toLongOrNull()))
else -> result.exception
}
}
}
}
/**
* Convert a [NetworkResult] into typed exceptions expected during calls with IAP endpoints. Not all endpoints require
* specific error parsing but if those errors do happen for them they'll fail to parse and get the normal status code
* exception.
*/
@JvmStatic
@Throws(IOException::class)
fun <T> toIAPBasicLegacy(result: NetworkResult<T>): T {
return when (result) {
is NetworkResult.Success -> result.result
is NetworkResult.ApplicationError -> {
throw when (val error = result.throwable) {
is IOException, is RuntimeException -> error
else -> RuntimeException(error)
}
}
is NetworkResult.NetworkError -> throw result.exception
is NetworkResult.StatusCodeError -> {
throw when (result.code) {
402 -> result.parseJsonBody<InAppPaymentReceiptCredentialError>() ?: result.exception
440 -> result.parseJsonBody<InAppPaymentProcessorError>() ?: result.exception
else -> result.exception
}
}
}
}
}

View file

@ -0,0 +1,18 @@
package org.whispersystems.signalservice.api;
import org.signal.libsignal.protocol.state.SignalProtocolStore;
/**
* And extension of the normal protocol store interface that has additional methods that are needed
* in the service layer, but not the protocol layer.
*/
public interface SignalServiceAccountDataStore extends SignalProtocolStore,
SignalServicePreKeyStore,
SignalServiceSessionStore,
SignalServiceSenderKeyStore,
SignalServiceKyberPreKeyStore {
/**
* @return True if the user has linked devices, otherwise false.
*/
boolean isMultiDevice();
}

View file

@ -0,0 +1,126 @@
/**
* Copyright (C) 2014-2016 Open Whisper Systems
*
* Licensed according to the LICENSE file in this repository.
*/
package org.whispersystems.signalservice.api;
import org.signal.libsignal.net.Network;
import org.whispersystems.signalservice.api.account.AccountApi;
import org.whispersystems.signalservice.api.groupsv2.ClientZkOperations;
import org.whispersystems.signalservice.api.groupsv2.GroupsV2Api;
import org.whispersystems.signalservice.api.groupsv2.GroupsV2Operations;
import org.whispersystems.signalservice.api.push.ServiceId.ACI;
import org.whispersystems.signalservice.api.push.ServiceId.PNI;
import org.whispersystems.signalservice.api.registration.RegistrationApi;
import org.whispersystems.signalservice.api.svr.SecureValueRecoveryV2;
import org.whispersystems.signalservice.api.svr.SecureValueRecoveryV3;
import org.whispersystems.signalservice.api.websocket.SignalWebSocket;
import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration;
import org.whispersystems.signalservice.internal.push.PushServiceSocket;
import org.whispersystems.signalservice.internal.push.WhoAmIResponse;
import org.whispersystems.signalservice.internal.util.StaticCredentialsProvider;
import java.io.IOException;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
/**
* The main interface for creating, registering, and
* managing a Signal Service account.
*
* @author Moxie Marlinspike
*/
public class SignalServiceAccountManager {
private static final String TAG = SignalServiceAccountManager.class.getSimpleName();
private final PushServiceSocket pushServiceSocket;
private final GroupsV2Operations groupsV2Operations;
private final SignalServiceConfiguration configuration;
private final SignalWebSocket.AuthenticatedWebSocket authWebSocket;
private final AccountApi accountApi;
/**
* Construct a SignalServiceAccountManager.
* @param configuration The URL for the Signal Service.
* @param aci The Signal Service ACI.
* @param pni The Signal Service PNI.
* @param e164 The Signal Service phone number.
* @param password A Signal Service password.
* @param signalAgent A string which identifies the client software.
*/
public static SignalServiceAccountManager createWithStaticCredentials(SignalServiceConfiguration configuration,
ACI aci,
PNI pni,
String e164,
int deviceId,
String password,
String signalAgent,
boolean automaticNetworkRetry,
int maxGroupSize)
{
StaticCredentialsProvider credentialProvider = new StaticCredentialsProvider(aci, pni, e164, deviceId, password);
GroupsV2Operations gv2Operations = new GroupsV2Operations(ClientZkOperations.create(configuration), maxGroupSize);
return new SignalServiceAccountManager(
null,
null,
new PushServiceSocket(configuration, credentialProvider, signalAgent, automaticNetworkRetry),
gv2Operations
);
}
public SignalServiceAccountManager(@Nullable SignalWebSocket.AuthenticatedWebSocket authWebSocket,
@Nullable AccountApi accountApi,
@Nonnull PushServiceSocket pushServiceSocket,
@Nonnull GroupsV2Operations groupsV2Operations) {
this.authWebSocket = authWebSocket;
this.accountApi = accountApi;
this.groupsV2Operations = groupsV2Operations;
this.pushServiceSocket = pushServiceSocket;
this.configuration = pushServiceSocket.getConfiguration();
}
public SecureValueRecoveryV2 getSecureValueRecoveryV2(String mrEnclave) {
return new SecureValueRecoveryV2(configuration, mrEnclave, authWebSocket);
}
public SecureValueRecoveryV3 getSecureValueRecoveryV3(Network network) {
return new SecureValueRecoveryV3(network, authWebSocket);
}
public WhoAmIResponse getWhoAmI() throws IOException {
return NetworkResultUtil.toBasicLegacy(accountApi.whoAmI());
}
/**
* Request a push challenge. A number will be pushed to the GCM (FCM) id. This can then be used
* during SMS/call requests to bypass the CAPTCHA.
*
* @param gcmRegistrationId The GCM (FCM) id to use.
* @param sessionId The session to request a push for.
* @throws IOException
*/
public void requestRegistrationPushChallenge(String sessionId, String gcmRegistrationId) throws IOException {
pushServiceSocket.requestPushChallenge(sessionId, gcmRegistrationId);
}
public void checkNetworkConnection() throws IOException {
this.pushServiceSocket.pingStorageService();
}
public void cancelInFlightRequests() {
this.pushServiceSocket.cancelInFlightRequests();
}
public GroupsV2Api getGroupsV2Api() {
return new GroupsV2Api(authWebSocket, pushServiceSocket, groupsV2Operations);
}
public RegistrationApi getRegistrationApi() {
return new RegistrationApi(pushServiceSocket);
}
}

View file

@ -0,0 +1,30 @@
package org.whispersystems.signalservice.api;
import org.whispersystems.signalservice.api.push.ServiceId;
/**
* And extension of the normal protocol store interface that has additional methods that are needed
* in the service layer, but not the protocol layer.
*/
public interface SignalServiceDataStore {
/**
* @return A {@link SignalServiceAccountDataStore} for the specified account.
*/
SignalServiceAccountDataStore get(ServiceId accountIdentifier);
/**
* @return A {@link SignalServiceAccountDataStore} for the ACI account.
*/
SignalServiceAccountDataStore aci();
/**
* @return A {@link SignalServiceAccountDataStore} for the PNI account.
*/
SignalServiceAccountDataStore pni();
/**
* @return True if the user has linked devices, otherwise false.
*/
boolean isMultiDevice();
}

View file

@ -0,0 +1,36 @@
package org.whispersystems.signalservice.api
import org.signal.libsignal.protocol.state.KyberPreKeyRecord
import org.signal.libsignal.protocol.state.KyberPreKeyStore
/**
* And extension of the normal protocol sender key store interface that has additional methods that are
* needed in the service layer, but not the protocol layer.
*/
interface SignalServiceKyberPreKeyStore : KyberPreKeyStore {
/**
* Identical to [storeKyberPreKey] but indicates that this is a last-resort key rather than a one-time key.
*/
fun storeLastResortKyberPreKey(kyberPreKeyId: Int, kyberPreKeyRecord: KyberPreKeyRecord)
/**
* Retrieves all last-resort kyber prekeys.
*/
fun loadLastResortKyberPreKeys(): List<KyberPreKeyRecord>
/**
* Unconditionally remove the specified key from the store.
*/
fun removeKyberPreKey(kyberPreKeyId: Int)
/**
* Marks all prekeys stale if they haven't been marked already. "Stale" means the time that the keys have been replaced.
*/
fun markAllOneTimeKyberPreKeysStaleIfNecessary(staleTime: Long)
/**
* Deletes all prekeys that have been stale since before the threshold. "Stale" means the time that the keys have been replaced.
*/
fun deleteAllStaleOneTimeKyberPreKeys(threshold: Long, minCount: Int)
}

View file

@ -0,0 +1,257 @@
/*
* Copyright (C) 2014-2016 Open Whisper Systems
*
* Licensed according to the LICENSE file in this repository.
*/
package org.whispersystems.signalservice.api;
import org.signal.core.util.StreamUtil;
import org.signal.libsignal.protocol.InvalidMessageException;
import org.signal.libsignal.zkgroup.profiles.ProfileKey;
import org.whispersystems.signalservice.api.backup.MediaRootBackupKey;
import org.whispersystems.signalservice.api.crypto.AttachmentCipherInputStream;
import org.whispersystems.signalservice.api.crypto.AttachmentCipherInputStream.IntegrityCheck;
import org.whispersystems.signalservice.api.crypto.AttachmentCipherStreamUtil;
import org.whispersystems.signalservice.api.crypto.ProfileCipherInputStream;
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment.ProgressListener;
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentPointer;
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentRemoteId;
import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage;
import org.whispersystems.signalservice.api.messages.SignalServiceStickerManifest;
import org.whispersystems.signalservice.api.push.exceptions.MissingConfigurationException;
import org.whispersystems.signalservice.internal.crypto.PaddingInputStream;
import org.whispersystems.signalservice.internal.push.PushServiceSocket;
import org.whispersystems.signalservice.internal.sticker.Pack;
import org.whispersystems.signalservice.internal.util.Util;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.time.ZonedDateTime;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import kotlin.Unit;
/**
* The primary interface for receiving Signal Service messages.
*
* @author Moxie Marlinspike
*/
public class SignalServiceMessageReceiver {
private final PushServiceSocket socket;
/**
* Construct a SignalServiceMessageReceiver.
*/
public SignalServiceMessageReceiver(PushServiceSocket socket) {
this.socket = socket;
}
/**
* Retrieves a SignalServiceAttachment.
*
* @param pointer The {@link SignalServiceAttachmentPointer}
* received in a {@link SignalServiceDataMessage}.
* @param destination The download destination for this attachment.
*
* @return An InputStream that streams the plaintext attachment contents.
* @throws IOException
* @throws InvalidMessageException
*/
public InputStream retrieveAttachment(SignalServiceAttachmentPointer pointer, File destination, long maxSizeBytes, IntegrityCheck integrityCheck)
throws IOException, InvalidMessageException, MissingConfigurationException {
return retrieveAttachment(pointer, destination, maxSizeBytes, integrityCheck, null);
}
public InputStream retrieveProfileAvatar(String path, File destination, ProfileKey profileKey, long maxSizeBytes)
throws IOException
{
socket.retrieveProfileAvatar(path, destination, maxSizeBytes);
return new ProfileCipherInputStream(new FileInputStream(destination), profileKey);
}
public FileInputStream retrieveGroupsV2ProfileAvatar(String path, File destination, long maxSizeBytes)
throws IOException
{
socket.retrieveProfileAvatar(path, destination, maxSizeBytes);
return new FileInputStream(destination);
}
/**
* Retrieves a SignalServiceAttachment. The encrypted data is written to @{code destination}, and then an {@link InputStream} is returned that decrypts the
* contents of the destination file, giving you access to the plaintext content.
*
* @param pointer The {@link SignalServiceAttachmentPointer}
* received in a {@link SignalServiceDataMessage}.
* @param destination The download destination for this attachment. If this file exists, it is
* assumed that this is previously-downloaded content that can be resumed.
* @param listener An optional listener (may be null) to receive callbacks on download progress.
*
* @return An InputStream that streams the plaintext attachment contents.
* @throws IOException
* @throws InvalidMessageException
*/
public InputStream retrieveAttachment(SignalServiceAttachmentPointer pointer, File destination, long maxSizeBytes, IntegrityCheck integrityCheck, ProgressListener listener)
throws IOException, InvalidMessageException, MissingConfigurationException {
if (integrityCheck == null) throw new InvalidMessageException("No integrity check!");
if (pointer.getKey() == null) throw new InvalidMessageException("No key!");
socket.retrieveAttachment(pointer.getCdnNumber(), Collections.emptyMap(), pointer.getRemoteId(), destination, maxSizeBytes, listener);
byte[] iv = new byte[16];
try (InputStream tempStream = new FileInputStream(destination)) {
StreamUtil.readFully(tempStream, iv);
}
return AttachmentCipherInputStream.createForAttachment(
destination,
pointer.getSize().orElse(0),
pointer.getKey(),
integrityCheck,
null,
0
);
}
/**
* Retrieves an archived media attachment.
*
* @param archivedMediaKeyMaterial Decryption key material for decrypting outer layer of archived media.
* @param plaintextHash The plaintext hash of the attachment, used to verify the integrity of the downloaded content.
* @param readCredentialHeaders Headers to pass to the backup CDN to authorize the download
* @param archiveDestination The download destination for archived attachment. If this file exists, download will resume.
* @param pointer The {@link SignalServiceAttachmentPointer} received in a {@link SignalServiceDataMessage}.
* @param listener An optional listener (may be null) to receive callbacks on download progress.
*
* @return An InputStream that streams the plaintext attachment contents.
*/
public InputStream retrieveArchivedAttachment(@Nonnull MediaRootBackupKey.MediaKeyMaterial archivedMediaKeyMaterial,
@Nonnull byte[] plaintextHash,
@Nonnull Map<String, String> readCredentialHeaders,
@Nonnull File archiveDestination,
@Nonnull SignalServiceAttachmentPointer pointer,
long maxSizeBytes,
@Nullable ProgressListener listener)
throws IOException, InvalidMessageException, MissingConfigurationException
{
if (pointer.getKey() == null) {
throw new InvalidMessageException("No key!");
}
socket.retrieveAttachment(pointer.getCdnNumber(), readCredentialHeaders, pointer.getRemoteId(), archiveDestination, maxSizeBytes, listener);
long originalCipherLength = pointer.getSize()
.filter(s -> s > 0)
.map(s -> AttachmentCipherStreamUtil.getCiphertextLength(PaddingInputStream.getPaddedSize(s)))
.orElse(0L);
return AttachmentCipherInputStream.createForArchivedMedia(
archivedMediaKeyMaterial,
archiveDestination,
originalCipherLength,
pointer.getSize().orElse(0),
pointer.getKey(),
plaintextHash,
null,
0
);
}
/**
* Retrieves an archived media attachment.
*
* @param archivedMediaKeyMaterial Decryption key material for decrypting outer layer of archived media.
* @param readCredentialHeaders Headers to pass to the backup CDN to authorize the download
* @param archiveDestination The download destination for archived attachment. If this file exists, download will resume.
* @param pointer The {@link SignalServiceAttachmentPointer} received in a {@link SignalServiceDataMessage}.
* @param listener An optional listener (may be null) to receive callbacks on download progress.
*
* @return An InputStream that streams the plaintext attachment contents.
*/
public InputStream retrieveArchivedThumbnail(@Nonnull MediaRootBackupKey.MediaKeyMaterial archivedMediaKeyMaterial,
@Nonnull Map<String, String> readCredentialHeaders,
@Nonnull File archiveDestination,
@Nonnull SignalServiceAttachmentPointer pointer,
long maxSizeBytes,
@Nullable ProgressListener listener)
throws IOException, InvalidMessageException, MissingConfigurationException
{
if (pointer.getKey() == null) {
throw new InvalidMessageException("No key!");
}
socket.retrieveAttachment(pointer.getCdnNumber(), readCredentialHeaders, pointer.getRemoteId(), archiveDestination, maxSizeBytes, listener);
return AttachmentCipherInputStream.createForArchivedThumbnail(
archivedMediaKeyMaterial,
archiveDestination,
pointer.getKey()
);
}
public void retrieveBackup(int cdnNumber, Map<String, String> headers, String cdnPath, File destination, ProgressListener listener) throws MissingConfigurationException, IOException {
socket.retrieveBackup(cdnNumber, headers, cdnPath, destination, 1_000_000_000L, listener);
}
public NetworkResult<byte[]> retrieveBackupForwardSecretMetadataBytes(int cdnNumber, Map<String, String> headers, String cdnPath, int maxSizeBytes) {
return NetworkResult.fromFetch(() -> socket.retrieveBackupForwardSecrecyMetadataBytes(cdnNumber, headers, cdnPath, maxSizeBytes));
}
/**
* Retrieves a link+sync backup file. The data is written to @{code destination}.
*/
public @Nonnull NetworkResult<Unit> retrieveLinkAndSyncBackup(int cdn, @Nonnull String key, @Nonnull File destination, @Nullable ProgressListener listener) {
return NetworkResult.fromFetch(() -> {
socket.retrieveAttachment(cdn, Collections.emptyMap(), new SignalServiceAttachmentRemoteId.V4(key), destination, 1_000_000_000L, listener);
return Unit.INSTANCE;
});
}
public @Nonnull ZonedDateTime getCdnLastModifiedTime(int cdnNumber, Map<String, String> headers, String cdnPath) throws MissingConfigurationException, IOException {
return socket.getCdnLastModifiedTime(cdnNumber, headers, cdnPath);
}
public InputStream retrieveSticker(byte[] packId, byte[] packKey, int stickerId)
throws IOException, InvalidMessageException
{
byte[] data = socket.retrieveSticker(packId, stickerId);
return AttachmentCipherInputStream.createForStickerData(data, packKey);
}
/**
* Retrieves a {@link SignalServiceStickerManifest}.
*
* @param packId The 16-byte packId that identifies the sticker pack.
* @param packKey The 32-byte packKey that decrypts the sticker pack.
* @return The {@link SignalServiceStickerManifest} representing the sticker pack.
* @throws IOException
* @throws InvalidMessageException
*/
public SignalServiceStickerManifest retrieveStickerManifest(byte[] packId, byte[] packKey)
throws IOException, InvalidMessageException
{
byte[] manifestBytes = socket.retrieveStickerManifest(packId);
InputStream cipherStream = AttachmentCipherInputStream.createForStickerData(manifestBytes, packKey);
Pack pack = Pack.ADAPTER.decode(Util.readFullyAsBytes(cipherStream));
List<SignalServiceStickerManifest.StickerInfo> stickers = new ArrayList<>(pack.stickers.size());
SignalServiceStickerManifest.StickerInfo cover = pack.cover != null ? new SignalServiceStickerManifest.StickerInfo(pack.cover.id, pack.cover.emoji, pack.cover.contentType)
: null;
for (Pack.Sticker sticker : pack.stickers) {
stickers.add(new SignalServiceStickerManifest.StickerInfo(sticker.id, sticker.emoji, sticker.contentType));
}
return new SignalServiceStickerManifest(pack.title, pack.author, cover, stickers);
}
}

View file

@ -0,0 +1,19 @@
package org.whispersystems.signalservice.api
import org.signal.libsignal.protocol.state.PreKeyStore
/**
* And extension of the normal protocol prekey store interface that has additional methods that are
* needed in the service layer, but not the protocol layer.
*/
interface SignalServicePreKeyStore : PreKeyStore {
/**
* Marks all prekeys stale if they haven't been marked already. "Stale" means the time that the keys have been replaced.
*/
fun markAllOneTimeEcPreKeysStaleIfNecessary(staleTime: Long)
/**
* Deletes all prekeys that have been stale since before the threshold. "Stale" means the time that the keys have been replaced.
*/
fun deleteAllStaleOneTimeEcPreKeys(threshold: Long, minCount: Int)
}

View file

@ -0,0 +1,29 @@
package org.whispersystems.signalservice.api;
import org.signal.libsignal.protocol.SignalProtocolAddress;
import org.signal.libsignal.protocol.groups.state.SenderKeyStore;
import org.whispersystems.signalservice.api.push.DistributionId;
import java.util.Collection;
import java.util.Set;
/**
* And extension of the normal protocol sender key store interface that has additional methods that are
* needed in the service layer, but not the protocol layer.
*/
public interface SignalServiceSenderKeyStore extends SenderKeyStore {
/**
* @return A set of protocol addresses that have previously been sent the sender key data for the provided distributionId.
*/
Set<SignalProtocolAddress> getSenderKeySharedWith(DistributionId distributionId);
/**
* Marks the provided addresses as having been sent the sender key data for the provided distributionId.
*/
void markSenderKeySharedWith(DistributionId distributionId, Collection<SignalProtocolAddress> addresses);
/**
* Marks the provided addresses as not knowing about any distributionIds.
*/
void clearSenderKeySharedWith(Collection<SignalProtocolAddress> addresses);
}

View file

@ -0,0 +1,18 @@
package org.whispersystems.signalservice.api;
import org.signal.libsignal.protocol.SignalProtocolAddress;
import org.signal.libsignal.protocol.state.SessionRecord;
import org.signal.libsignal.protocol.state.SessionStore;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* And extension of the normal protocol session store interface that has additional methods that are
* needed in the service layer, but not the protocol layer.
*/
public interface SignalServiceSessionStore extends SessionStore {
void archiveSession(SignalProtocolAddress address);
Map<SignalProtocolAddress, SessionRecord> getAllAddressesWithActiveSessions(List<String> addressNames);
}

View file

@ -0,0 +1,17 @@
package org.whispersystems.signalservice.api;
import java.io.Closeable;
/**
* An interface to allow the injection of a lock that will be used to keep interactions with
* ecryptions/decryptions thread-safe.
*/
public interface SignalSessionLock {
Lock acquire();
interface Lock extends Closeable {
@Override
void close();
}
}

View file

@ -0,0 +1,67 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.signalservice.api
import okhttp3.ConnectionSpec
import okhttp3.OkHttpClient
import org.whispersystems.signalservice.api.push.TrustStore
import org.whispersystems.signalservice.api.util.Tls12SocketFactory
import org.whispersystems.signalservice.api.util.TlsProxySocketFactory
import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration
import org.whispersystems.signalservice.internal.configuration.SignalUrl
import org.whispersystems.signalservice.internal.util.BlacklistingTrustManager
import org.whispersystems.signalservice.internal.util.Util
import java.security.KeyManagementException
import java.security.NoSuchAlgorithmException
import java.util.concurrent.TimeUnit
import javax.net.ssl.SSLContext
import javax.net.ssl.SSLSocketFactory
import javax.net.ssl.X509TrustManager
/**
* Select a a URL at random to use.
*/
fun <T : SignalUrl> Array<T>.chooseUrl(): T {
return this[(Math.random() * size).toInt()]
}
/**
* Build and configure an [OkHttpClient] as defined by the target [SignalUrl] and provided [configuration].
*/
fun <T : SignalUrl> T.buildOkHttpClient(configuration: SignalServiceConfiguration): OkHttpClient {
val (socketFactory, trustManager) = createTlsSocketFactory(this.trustStore)
val builder = OkHttpClient.Builder()
.sslSocketFactory(socketFactory, trustManager)
.connectionSpecs(this.connectionSpecs.orElse(Util.immutableList(ConnectionSpec.RESTRICTED_TLS)))
.retryOnConnectionFailure(false)
.readTimeout(30, TimeUnit.SECONDS)
.connectTimeout(30, TimeUnit.SECONDS)
for (interceptor in configuration.networkInterceptors) {
builder.addInterceptor(interceptor)
}
if (configuration.signalProxy.isPresent) {
val proxy = configuration.signalProxy.get()
builder.socketFactory(TlsProxySocketFactory(proxy.host, proxy.port, configuration.dns))
}
return builder.build()
}
private fun createTlsSocketFactory(trustStore: TrustStore): Pair<SSLSocketFactory, X509TrustManager> {
return try {
val context = SSLContext.getInstance("TLS")
val trustManagers = BlacklistingTrustManager.createFor(trustStore)
context.init(null, trustManagers, null)
Tls12SocketFactory(context.socketFactory) to trustManagers[0] as X509TrustManager
} catch (e: NoSuchAlgorithmException) {
throw AssertionError(e)
} catch (e: KeyManagementException) {
throw AssertionError(e)
}
}

View file

@ -0,0 +1,7 @@
package org.whispersystems.signalservice.api;
public final class SvrNoDataException extends Exception {
public SvrNoDataException() {
}
}

View file

@ -0,0 +1,228 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.signalservice.api.account
import org.signal.core.util.Base64
import org.signal.core.util.Base64.encodeUrlSafeWithoutPadding
import org.signal.libsignal.usernames.BaseUsernameException
import org.signal.libsignal.usernames.Username
import org.whispersystems.signalservice.api.NetworkResult
import org.whispersystems.signalservice.api.push.UsernameLinkComponents
import org.whispersystems.signalservice.api.websocket.SignalWebSocket
import org.whispersystems.signalservice.internal.delete
import org.whispersystems.signalservice.internal.get
import org.whispersystems.signalservice.internal.push.ConfirmUsernameRequest
import org.whispersystems.signalservice.internal.push.ConfirmUsernameResponse
import org.whispersystems.signalservice.internal.push.GcmRegistrationId
import org.whispersystems.signalservice.internal.push.PushServiceSocket
import org.whispersystems.signalservice.internal.push.ReserveUsernameRequest
import org.whispersystems.signalservice.internal.push.ReserveUsernameResponse
import org.whispersystems.signalservice.internal.push.SetUsernameLinkRequestBody
import org.whispersystems.signalservice.internal.push.SetUsernameLinkResponseBody
import org.whispersystems.signalservice.internal.push.VerifyAccountResponse
import org.whispersystems.signalservice.internal.push.WhoAmIResponse
import org.whispersystems.signalservice.internal.put
import org.whispersystems.signalservice.internal.websocket.WebSocketRequestMessage
import java.security.SecureRandom
import java.util.UUID
/**
* Various user account specific APIs to get, update, and delete account data.
*/
class AccountApi(private val authWebSocket: SignalWebSocket.AuthenticatedWebSocket) {
private val random = SecureRandom()
/**
* Fetch information about yourself.
*
* GET /v1/accounts/whoami
* - 200: Success
*/
fun whoAmI(): NetworkResult<WhoAmIResponse> {
val request = WebSocketRequestMessage.get("/v1/accounts/whoami")
return NetworkResult.fromWebSocketRequest(authWebSocket, request, WhoAmIResponse::class)
}
/**
* PUT /v1/accounts/gcm
* - 200: Success
*/
fun setFcmToken(fcmToken: String): NetworkResult<Unit> {
val request = WebSocketRequestMessage.put("/v1/accounts/gcm", GcmRegistrationId(fcmToken, true))
return NetworkResult.fromWebSocketRequest(authWebSocket, request)
}
/**
* DELETE /v1/account/gcm
* - 204: Success
*/
fun clearFcmToken(): NetworkResult<Unit> {
val request = WebSocketRequestMessage.delete("/v1/accounts/gcm")
return NetworkResult.fromWebSocketRequest(authWebSocket, request)
}
/**
* Set account attributes.
*
* PUT /v1/accounts/attributes
* - 200: Success
*/
fun setAccountAttributes(accountAttributes: AccountAttributes): NetworkResult<Unit> {
val request = WebSocketRequestMessage.put("/v1/accounts/attributes", accountAttributes)
return NetworkResult.fromWebSocketRequest(authWebSocket, request)
}
/**
* PUT /v1/accounts/registration_lock
* - 204: Success
*/
fun enableRegistrationLock(registrationLock: String): NetworkResult<Unit> {
val request = WebSocketRequestMessage.put("/v1/accounts/registration_lock", PushServiceSocket.RegistrationLockV2(registrationLock))
return NetworkResult.fromWebSocketRequest(authWebSocket, request)
}
/**
* DELETE /v1/accounts/registration_lock
* - 204: Success
*/
fun disableRegistrationLock(): NetworkResult<Unit> {
val request = WebSocketRequestMessage.delete("/v1/accounts/registration_lock")
return NetworkResult.fromWebSocketRequest(authWebSocket, request)
}
/**
* DELETE /v1/accounts/me
* - 204: Success
* - 4401: Success
*/
fun deleteAccount(): NetworkResult<Unit> {
val request = WebSocketRequestMessage.delete("/v1/accounts/me")
return NetworkResult.fromWebSocketRequest(authWebSocket, request)
}
/**
* Generate and get an account data report.
*
* GET /v2/accounts/data_report
* - 200: Success
*/
fun accountDataReport(): NetworkResult<String> {
val request = WebSocketRequestMessage.get("/v2/accounts/data_report")
return NetworkResult.fromWebSocketRequest(authWebSocket, request, String::class)
}
/**
* Changes the phone number that an account is associated with.
*
* PUT /v2/accounts/number
* - 200: Success
* - 403: No recovery password provided
* - 409: Mismatched device ids to notify
* - 410: Mismatched device registration ids to notify
* - 422: Unable to parse [ChangePhoneNumberRequest]
* - 423: Account reglock enabled for new phone number
* - 429: Rate limited
*/
fun changeNumber(changePhoneNumberRequest: ChangePhoneNumberRequest): NetworkResult<VerifyAccountResponse> {
val request = WebSocketRequestMessage.put("/v2/accounts/number", changePhoneNumberRequest)
return NetworkResult.fromWebSocketRequest(authWebSocket, request, VerifyAccountResponse::class)
}
/**
* Reserve a username for the account. This replaces an existing reservation if one exists. The username is guaranteed to be available for 5 minutes and can
* be confirmed with confirmUsername.
*
* PUT /v1/accounts/username_hash/reserve
* - 200: Success
* - 409: Username taken
* - 422: Username malformed
* - 429: Rate limited
*
* @param usernameHashes A list of hashed usernames encoded as web-safe base64 strings without padding. The list will have a max length of 20, and each hash will be 32 bytes.
* @return The reserved username. It is available for confirmation for 5 minutes.
*/
fun reserveUsername(usernameHashes: List<String>): NetworkResult<ReserveUsernameResponse> {
val request = WebSocketRequestMessage.put("/v1/accounts/username_hash/reserve", ReserveUsernameRequest(usernameHashes))
return NetworkResult.fromWebSocketRequest(authWebSocket, request, ReserveUsernameResponse::class)
}
/**
* Set a previously reserved username for the account.
*
* PUT /v1/accounts/username_hash/confirm
* - 200: Success
* - 409: Username is not reserved
* - 410: Username unavailable
* - 422: Unable to parse [ConfirmUsernameRequest]
* - 429: Rate limited
*
* @param username The username the user wishes to confirm.
*/
fun confirmUsername(username: Username, link: Username.UsernameLink): NetworkResult<UUID> {
val randomness = ByteArray(32)
random.nextBytes(randomness)
val proof: ByteArray = try {
username.generateProofWithRandomness(randomness)
} catch (e: BaseUsernameException) {
return NetworkResult.ApplicationError(e)
}
val confirmUsernameRequest = ConfirmUsernameRequest(
encodeUrlSafeWithoutPadding(username.hash),
encodeUrlSafeWithoutPadding(proof),
encodeUrlSafeWithoutPadding(link.encryptedUsername)
)
val request = WebSocketRequestMessage.put("/v1/accounts/username_hash/confirm", confirmUsernameRequest)
return NetworkResult.fromWebSocketRequest(authWebSocket, request, ConfirmUsernameResponse::class)
.map { it.usernameLinkHandle }
}
/**
* DELETE /v1/accounts/username_hash
* - 204: Success
*/
fun deleteUsername(): NetworkResult<Unit> {
val request = WebSocketRequestMessage.delete("/v1/accounts/username_hash")
return NetworkResult.fromWebSocketRequest(authWebSocket, request)
}
/**
* Creates a new username link for the given [usernameLink].
*
* PUT /v1/accounts/username_link
* - 200: Success
* - 409: Username is not set
* - 422: Invalid [SetUsernameLinkRequestBody] format
* - 429: Rate limited
*/
fun createUsernameLink(usernameLink: Username.UsernameLink): NetworkResult<UsernameLinkComponents> {
return modifyUsernameLink(usernameLink, false)
}
/**
* Update account username link for the given [usernameLink].
*
* PUT /v1/accounts/username_link
* - 200: Success
* - 409: Username is not set
* - 422: Invalid [SetUsernameLinkRequestBody] format
* - 429: Rate limited
*/
fun updateUsernameLink(usernameLink: Username.UsernameLink): NetworkResult<UsernameLinkComponents> {
return modifyUsernameLink(usernameLink, true)
}
private fun modifyUsernameLink(usernameLink: Username.UsernameLink, keepLinkHandle: Boolean): NetworkResult<UsernameLinkComponents> {
val encryptedUsername = Base64.encodeUrlSafeWithPadding(usernameLink.encryptedUsername)
val request = WebSocketRequestMessage.put("/v1/accounts/username_link", SetUsernameLinkRequestBody(encryptedUsername, keepLinkHandle))
return NetworkResult.fromWebSocketRequest(authWebSocket, request, SetUsernameLinkResponseBody::class)
.map { UsernameLinkComponents(usernameLink.entropy, it.usernameLinkHandle) }
}
}

View file

@ -0,0 +1,62 @@
/*
* Copyright (C) 2014-2016 Open Whisper Systems
*
* Licensed according to the LICENSE file in this repository.
*/
package org.whispersystems.signalservice.api.account
import com.fasterxml.jackson.annotation.JsonCreator
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import com.fasterxml.jackson.annotation.JsonProperty
@JsonIgnoreProperties(ignoreUnknown = true)
class AccountAttributes @JsonCreator constructor(
@JsonProperty val signalingKey: String?,
@JsonProperty val registrationId: Int,
@JsonProperty val voice: Boolean,
@JsonProperty val video: Boolean,
@JsonProperty val fetchesMessages: Boolean,
@JsonProperty val registrationLock: String?,
@JsonProperty val unidentifiedAccessKey: ByteArray?,
@JsonProperty val unrestrictedUnidentifiedAccess: Boolean,
@JsonProperty val discoverableByPhoneNumber: Boolean,
@JsonProperty val capabilities: Capabilities?,
@JsonProperty val name: String?,
@JsonProperty val pniRegistrationId: Int,
@JsonProperty val recoveryPassword: String?
) {
constructor(
signalingKey: String?,
registrationId: Int,
fetchesMessages: Boolean,
registrationLock: String?,
unidentifiedAccessKey: ByteArray?,
unrestrictedUnidentifiedAccess: Boolean,
capabilities: Capabilities?,
discoverableByPhoneNumber: Boolean,
name: String?,
pniRegistrationId: Int,
recoveryPassword: String?
) : this(
signalingKey = signalingKey,
registrationId = registrationId,
voice = true,
video = true,
fetchesMessages = fetchesMessages,
registrationLock = registrationLock,
unidentifiedAccessKey = unidentifiedAccessKey,
unrestrictedUnidentifiedAccess = unrestrictedUnidentifiedAccess,
discoverableByPhoneNumber = discoverableByPhoneNumber,
capabilities = capabilities,
name = name,
pniRegistrationId = pniRegistrationId,
recoveryPassword = recoveryPassword
)
data class Capabilities @JsonCreator constructor(
@JsonProperty val storage: Boolean,
@JsonProperty val versionedExpirationTimer: Boolean,
@JsonProperty val attachmentBackfill: Boolean,
@JsonProperty val spqr: Boolean
)
}

View file

@ -0,0 +1,93 @@
package org.whispersystems.signalservice.api.account;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import org.signal.libsignal.protocol.IdentityKey;
import org.whispersystems.signalservice.api.push.SignedPreKeyEntity;
import org.whispersystems.signalservice.internal.push.KyberPreKeyEntity;
import org.whispersystems.signalservice.internal.push.OutgoingPushMessage;
import org.whispersystems.signalservice.internal.util.JsonUtil;
import java.util.List;
import java.util.Map;
public final class ChangePhoneNumberRequest {
@JsonProperty
private String sessionId;
@JsonProperty
private String recoveryPassword;
@JsonProperty
private String number;
@JsonProperty("reglock")
private String registrationLock;
@JsonProperty
@JsonSerialize(using = JsonUtil.IdentityKeySerializer.class)
@JsonDeserialize(using = JsonUtil.IdentityKeyDeserializer.class)
private IdentityKey pniIdentityKey;
@JsonProperty
private List<OutgoingPushMessage> deviceMessages;
@JsonProperty
private Map<String, SignedPreKeyEntity> devicePniSignedPrekeys;
@JsonProperty("devicePniPqLastResortPrekeys")
private Map<String, KyberPreKeyEntity> devicePniLastResortKyberPrekeys;
@JsonProperty
private Map<String, Integer> pniRegistrationIds;
@SuppressWarnings("unused")
public ChangePhoneNumberRequest() {}
public ChangePhoneNumberRequest(String sessionId,
String recoveryPassword,
String number,
String registrationLock,
IdentityKey pniIdentityKey,
List<OutgoingPushMessage> deviceMessages,
Map<String, SignedPreKeyEntity> devicePniSignedPrekeys,
Map<String, KyberPreKeyEntity> devicePniLastResortKyberPrekeys,
Map<String, Integer> pniRegistrationIds)
{
this.sessionId = sessionId;
this.recoveryPassword = recoveryPassword;
this.number = number;
this.registrationLock = registrationLock;
this.pniIdentityKey = pniIdentityKey;
this.deviceMessages = deviceMessages;
this.devicePniSignedPrekeys = devicePniSignedPrekeys;
this.devicePniLastResortKyberPrekeys = devicePniLastResortKyberPrekeys;
this.pniRegistrationIds = pniRegistrationIds;
}
public String getNumber() {
return number;
}
public String getRegistrationLock() {
return registrationLock;
}
public IdentityKey getPniIdentityKey() {
return pniIdentityKey;
}
public List<OutgoingPushMessage> getDeviceMessages() {
return deviceMessages;
}
public Map<String, SignedPreKeyEntity> getDevicePniSignedPrekeys() {
return devicePniSignedPrekeys;
}
public Map<String, Integer> getPniRegistrationIds() {
return pniRegistrationIds;
}
}

View file

@ -0,0 +1,70 @@
package org.whispersystems.signalservice.api.account;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import org.signal.libsignal.protocol.IdentityKey;
import org.whispersystems.signalservice.api.push.SignedPreKeyEntity;
import org.whispersystems.signalservice.internal.push.KyberPreKeyEntity;
import org.whispersystems.signalservice.internal.push.OutgoingPushMessage;
import org.whispersystems.signalservice.internal.util.JsonUtil;
import java.util.List;
import java.util.Map;
public final class PniKeyDistributionRequest {
@JsonProperty
@JsonSerialize(using = JsonUtil.IdentityKeySerializer.class)
@JsonDeserialize(using = JsonUtil.IdentityKeyDeserializer.class)
private IdentityKey pniIdentityKey;
@JsonProperty
private List<OutgoingPushMessage> deviceMessages;
@JsonProperty
private Map<String, SignedPreKeyEntity> devicePniSignedPrekeys;
@JsonProperty("devicePniPqLastResortPrekeys")
private Map<String, KyberPreKeyEntity> devicePniLastResortKyberPrekeys;
@JsonProperty
private Map<String, Integer> pniRegistrationIds;
@JsonProperty
private boolean signatureValidOnEachSignedPreKey;
@SuppressWarnings("unused")
public PniKeyDistributionRequest() {}
public PniKeyDistributionRequest(IdentityKey pniIdentityKey,
List<OutgoingPushMessage> deviceMessages,
Map<String, SignedPreKeyEntity> devicePniSignedPrekeys,
Map<String, KyberPreKeyEntity> devicePniLastResortKyberPrekeys,
Map<String, Integer> pniRegistrationIds,
boolean signatureValidOnEachSignedPreKey)
{
this.pniIdentityKey = pniIdentityKey;
this.deviceMessages = deviceMessages;
this.devicePniSignedPrekeys = devicePniSignedPrekeys;
this.devicePniLastResortKyberPrekeys = devicePniLastResortKyberPrekeys;
this.pniRegistrationIds = pniRegistrationIds;
this.signatureValidOnEachSignedPreKey = signatureValidOnEachSignedPreKey;
}
public IdentityKey getPniIdentityKey() {
return pniIdentityKey;
}
public List<OutgoingPushMessage> getDeviceMessages() {
return deviceMessages;
}
public Map<String, SignedPreKeyEntity> getDevicePniSignedPrekeys() {
return devicePniSignedPrekeys;
}
public Map<String, Integer> getPniRegistrationIds() {
return pniRegistrationIds;
}
}

View file

@ -0,0 +1,21 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.signalservice.api.account
import org.signal.libsignal.protocol.IdentityKey
import org.signal.libsignal.protocol.state.KyberPreKeyRecord
import org.signal.libsignal.protocol.state.SignedPreKeyRecord
/**
* Holder class to pass around a bunch of prekeys that we send off to the service during registration.
* As the service does not return the submitted prekeys, we need to hold them in memory so that when
* the service approves the keys we have a local copy to persist.
*/
data class PreKeyCollection(
val identityKey: IdentityKey,
val signedPreKey: SignedPreKeyRecord,
val lastResortKyberPreKey: KyberPreKeyRecord
)

View file

@ -0,0 +1,24 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.signalservice.api.account
import org.signal.libsignal.protocol.state.KyberPreKeyRecord
import org.signal.libsignal.protocol.state.PreKeyRecord
import org.signal.libsignal.protocol.state.SignedPreKeyRecord
import org.whispersystems.signalservice.api.push.ServiceIdType
/**
* Represents a bundle of prekeys you want to upload.
*
* If a field is nullable, not setting it will simply leave that field alone on the service.
*/
data class PreKeyUpload(
val serviceIdType: ServiceIdType,
val signedPreKey: SignedPreKeyRecord?,
val oneTimeEcPreKeys: List<PreKeyRecord>?,
val lastResortKyberPreKey: KyberPreKeyRecord?,
val oneTimeKyberPreKeys: List<KyberPreKeyRecord>?
)

View file

@ -0,0 +1,449 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.signalservice.api.archive
import org.signal.core.util.isNotNullOrBlank
import org.signal.libsignal.protocol.ecc.ECPrivateKey
import org.signal.libsignal.protocol.ecc.ECPublicKey
import org.signal.libsignal.zkgroup.GenericServerPublicParams
import org.signal.libsignal.zkgroup.backups.BackupAuthCredential
import org.signal.libsignal.zkgroup.backups.BackupAuthCredentialRequestContext
import org.signal.libsignal.zkgroup.backups.BackupAuthCredentialResponse
import org.whispersystems.signalservice.api.NetworkResult
import org.whispersystems.signalservice.api.archive.ArchiveGetMediaItemsResponse.StoredMediaObject
import org.whispersystems.signalservice.api.backup.BackupKey
import org.whispersystems.signalservice.api.backup.MediaRootBackupKey
import org.whispersystems.signalservice.api.backup.MessageBackupKey
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment
import org.whispersystems.signalservice.api.push.ServiceId.ACI
import org.whispersystems.signalservice.api.websocket.SignalWebSocket
import org.whispersystems.signalservice.internal.delete
import org.whispersystems.signalservice.internal.get
import org.whispersystems.signalservice.internal.post
import org.whispersystems.signalservice.internal.push.AttachmentUploadForm
import org.whispersystems.signalservice.internal.push.AuthCredentials
import org.whispersystems.signalservice.internal.push.PushServiceSocket
import org.whispersystems.signalservice.internal.put
import org.whispersystems.signalservice.internal.websocket.WebSocketRequestMessage
import java.io.InputStream
import java.time.Instant
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
/**
* Class to interact with various archive-related endpoints.
* Why is it called archive instead of backup? Because SVR took the "backup" endpoint namespace first :)
*/
class ArchiveApi(
private val authWebSocket: SignalWebSocket.AuthenticatedWebSocket,
private val unauthWebSocket: SignalWebSocket.UnauthenticatedWebSocket,
private val pushServiceSocket: PushServiceSocket
) {
private val backupServerPublicParams: GenericServerPublicParams = GenericServerPublicParams(pushServiceSocket.configuration.backupServerPublicParams)
/**
* Retrieves a set of credentials one can use to authorize other requests.
*
* You'll receive a set of credentials spanning 7 days. Cache them and store them for later use.
* It's important that (at least in the common case) you do not request credentials on-the-fly.
* Instead, request them in advance on a regular schedule. This is because the purpose of these
* credentials is to keep the caller anonymous, but that doesn't help if this authenticated request
* happens right before all of the unauthenticated ones, as that would make it easier to correlate
* traffic.
*
* GET /v1/archives/auth
*
* - 200: Success
* - 400: Bad start/end times
* - 404: BackupId could not be found
* - 429: Rate-limited
*/
fun getServiceCredentials(currentTime: Long): NetworkResult<ArchiveServiceCredentialsResponse> {
val roundedToNearestDay = currentTime.milliseconds.inWholeDays.days
val endTime = roundedToNearestDay + 7.days
val request = WebSocketRequestMessage.get("/v1/archives/auth?redemptionStartSeconds=${roundedToNearestDay.inWholeSeconds}&redemptionEndSeconds=${endTime.inWholeSeconds}")
return NetworkResult.fromWebSocketRequest(authWebSocket, request, ArchiveServiceCredentialsResponse::class)
}
/**
* Gets credentials needed to read from the CDN. Make sure you use the right [backupKey] depending on whether you're doing a message or media operation.
*
* GET /v1/archives/auth/read
*
* - 200: Success
* - 400: Bad arguments, or made on an authenticated channel
* - 401: Bad presentation, invalid public key signature, no matching backupId on teh server, or the credential was of the wrong type (messages/media)
* - 403: Forbidden
* - 429: Rate-limited
*/
fun getCdnReadCredentials(cdnNumber: Int, aci: ACI, archiveServiceAccess: ArchiveServiceAccess<*>): NetworkResult<GetArchiveCdnCredentialsResponse> {
return getCredentialPresentation(aci, archiveServiceAccess)
.map { it.toArchiveCredentialPresentation().toHeaders() }
.then { headers ->
val request = WebSocketRequestMessage.get("/v1/archives/auth/read?cdn=$cdnNumber", headers)
NetworkResult.fromWebSocketRequest(unauthWebSocket, request, GetArchiveCdnCredentialsResponse::class)
}
}
/**
* Ensures that you reserve backupIds for both messages and media on the service. This must be done before any other
* backup-related calls. You only need to do it once, but repeated calls are safe.
*
* Passing null for either key will skip reserving for that backup and not cost a rate limit permit.
*
* PUT /v1/archives/backupid
*
* - 204: Success
* - 400: Invalid credential
* - 429: Rate-limited
*/
fun triggerBackupIdReservation(messageBackupKey: MessageBackupKey?, mediaRootBackupKey: MediaRootBackupKey?, aci: ACI): NetworkResult<Unit> {
val messageBackupRequestContext = messageBackupKey?.let { BackupAuthCredentialRequestContext.create(messageBackupKey.value, aci.rawUuid) }
val mediaBackupRequestContext = mediaRootBackupKey?.let { BackupAuthCredentialRequestContext.create(mediaRootBackupKey.value, aci.rawUuid) }
val request = WebSocketRequestMessage.put(
"/v1/archives/backupid",
ArchiveSetBackupIdRequest(messageBackupRequestContext?.request, mediaBackupRequestContext?.request)
)
return NetworkResult.fromWebSocketRequest(authWebSocket, request)
}
/**
* Sets a public key on the service derived from your [MessageBackupKey]. This key is used to prevent
* unauthorized users from changing your backup data. You only need to do it once, but repeated
* calls are safe.
*
* PUT /v1/archives/keys
*
* - 204: Success
* - 400: Bad arguments, or request was made on an authenticated channel
* - 401: Bad presentation, invalid public key signature, no matching backupId on teh server, or the credential was of the wrong type (messages/media)
* - 403: Forbidden
* - 429: Rate-limited
*/
fun setPublicKey(aci: ACI, archiveServiceAccess: ArchiveServiceAccess<*>): NetworkResult<Unit> {
return getCredentialPresentation(aci, archiveServiceAccess)
.then { presentation ->
val headers = presentation.toArchiveCredentialPresentation().toHeaders()
val publicKey = presentation.publicKey
val request = WebSocketRequestMessage.put("/v1/archives/keys", ArchiveSetPublicKeyRequest(publicKey), headers)
NetworkResult.fromWebSocketRequest(unauthWebSocket, request)
}
}
/**
* Fetches an upload form you can use to upload your main message backup file to cloud storage.
*
* GET /v1/archives/upload/form
* - 200: Success
* - 400: Bad args, or made on an authenticated channel
* - 403: Insufficient permissions
* - 413: The backup is too large
* - 429: Rate-limited
*/
fun getMessageBackupUploadForm(aci: ACI, archiveServiceAccess: ArchiveServiceAccess<MessageBackupKey>, backupFileSize: Long): NetworkResult<AttachmentUploadForm> {
return getCredentialPresentation(aci, archiveServiceAccess)
.map { it.toArchiveCredentialPresentation().toHeaders() }
.then { headers ->
val request = WebSocketRequestMessage.get("/v1/archives/upload/form?uploadLength=$backupFileSize", headers)
NetworkResult.fromWebSocketRequest(unauthWebSocket, request, AttachmentUploadForm::class)
}
}
/**
* Fetches metadata about your current backup. This will be different for different key/credential pairs. For example, message credentials will always
* return 0 for used space since that is stored under the media key/credential.
*
* Will return a [NetworkResult.StatusCodeError] with status code 404 if you haven't uploaded a backup yet.
*
* GET /v1/archives
* - 200: Success
* - 400: Bad arguments. The request may have been made on an authenticated channel.
* - 401: The provided backup auth credential 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 or the credential was of the wrong type (messages/media)
* - 403: Forbidden
* - 404: No backup
* - 429: Rate limited
*/
fun getBackupInfo(aci: ACI, archiveServiceAccess: ArchiveServiceAccess<*>): NetworkResult<ArchiveGetBackupInfoResponse> {
return getCredentialPresentation(aci, archiveServiceAccess)
.map { it.toArchiveCredentialPresentation().toHeaders() }
.then { headers ->
val request = WebSocketRequestMessage.get("/v1/archives", headers)
NetworkResult.fromWebSocketRequest(unauthWebSocket, request, ArchiveGetBackupInfoResponse::class)
}
}
/**
* Indicate that this backup is still active. Clients must periodically upload new backups or perform a refresh via a POST request. If a backup is not
* refreshed, after 30 days it may be deleted.
*
* POST /v1/archives
*
* - 204: The backup was successfully refreshed.
* - 400: Bad arguments. The request may have been made on an authenticated channel.
* - 401: The provided backup auth credential 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 or The credential was of the wrong type (messages/media)
* - 403: Forbidden. The request had insufficient permissions to perform the requested action.
* - 429: Rate limited.
*/
fun refreshBackup(aci: ACI, archiveServiceAccess: ArchiveServiceAccess<*>): NetworkResult<Unit> {
return getCredentialPresentation(aci, archiveServiceAccess)
.map { it.toArchiveCredentialPresentation().toHeaders() }
.then { headers ->
val request = WebSocketRequestMessage.post(path = "/v1/archives", body = null, headers = headers)
NetworkResult.fromWebSocketRequest(unauthWebSocket, request)
}
}
/**
* Delete all backup metadata, objects, and stored public key. To use backups again, a public key must be resupplied.
*
* DELETE /v1/archives
*
* - 204: The backup has been successfully deleted
* - 400: Bad arguments. The request may have been made on an authenticated channel.
* - 401: The provided backup auth credential 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 or The credential was of the wrong type (messages/media)
* - 403: Forbidden. The request had insufficient permissions to perform the requested action.
* - 429: Rate limited.
*
*/
fun deleteBackup(aci: ACI, archiveServiceAccess: ArchiveServiceAccess<*>): NetworkResult<Unit> {
return getCredentialPresentation(aci, archiveServiceAccess)
.map { it.toArchiveCredentialPresentation().toHeaders() }
.then { headers ->
val request = WebSocketRequestMessage.delete("/v1/archives", headers)
NetworkResult.fromWebSocketRequest(unauthWebSocket, request)
}
}
/**
* Retrieves a resumable upload URL you can use to upload your main message backup file or an arbitrary media file to cloud storage.
*/
fun getBackupResumableUploadUrl(uploadForm: AttachmentUploadForm): NetworkResult<String> {
return NetworkResult.fromFetch {
pushServiceSocket.getResumableUploadUrl(uploadForm)
}
}
/**
* Uploads your main backup file to cloud storage.
*/
fun uploadBackupFile(uploadForm: AttachmentUploadForm, resumableUploadUrl: String, data: InputStream, dataLength: Long, progressListener: SignalServiceAttachment.ProgressListener? = null): NetworkResult<Unit> {
return NetworkResult.fromFetch {
pushServiceSocket.uploadBackupFile(uploadForm, resumableUploadUrl, data, dataLength, progressListener)
}
}
/**
* Retrieves an [AttachmentUploadForm] that can be used to upload pre-existing media to the archive.
*
* This is basically the same as [org.whispersystems.signalservice.api.attachment.AttachmentApi.getAttachmentV4UploadForm], but with a relaxed rate limit
* so we can request them more often (which is required for backfilling).
*
* After uploading, the media still needs to be copied via [copyAttachmentToArchive].
*
* GET /v1/archives/media/upload/form
*
* - 200: Success
* - 400: Bad request, or made on authenticated channel
* - 403: Forbidden
* - 429: Rate-limited
*/
fun getMediaUploadForm(aci: ACI, archiveServiceAccess: ArchiveServiceAccess<MediaRootBackupKey>): NetworkResult<AttachmentUploadForm> {
return getCredentialPresentation(aci, archiveServiceAccess)
.map { it.toArchiveCredentialPresentation().toHeaders() }
.then { headers ->
val request = WebSocketRequestMessage.get("/v1/archives/media/upload/form", headers)
NetworkResult.fromWebSocketRequest(unauthWebSocket, request, AttachmentUploadForm::class)
}
}
/**
* Retrieves all media items in the user's archive. Note that this could be a very large number of items, making this only suitable for debugging.
* Use [getArchiveMediaItemsPage] in production.
*/
fun debugGetUploadedMediaItemMetadata(aci: ACI, archiveServiceAccess: ArchiveServiceAccess<MediaRootBackupKey>): NetworkResult<List<StoredMediaObject>> {
return NetworkResult.fromFetch {
val mediaObjects: MutableList<StoredMediaObject> = ArrayList()
var cursor: String? = null
do {
val response: ArchiveGetMediaItemsResponse = getArchiveMediaItemsPage(aci, archiveServiceAccess, 10_000, cursor).successOrThrow()
mediaObjects += response.storedMediaObjects
cursor = response.cursor
} while (cursor != null)
mediaObjects
}
}
/**
* Retrieves a page of media items in the user's archive.
*
* GET /v1/archives/media?limit={limit}&cursor={cursor}
*
* - 200: Success
* - 400: Bad request, or made on authenticated channel
* - 403: Forbidden
* - 429: Rate-limited
*
* @param limit The maximum number of items to return.
* @param cursor A token that can be read from your previous response, telling the server where to start the next page.
*/
fun getArchiveMediaItemsPage(aci: ACI, archiveServiceAccess: ArchiveServiceAccess<MediaRootBackupKey>, limit: Int, cursor: String?): NetworkResult<ArchiveGetMediaItemsResponse> {
return getCredentialPresentation(aci, archiveServiceAccess)
.map { it.toArchiveCredentialPresentation().toHeaders() }
.then { headers ->
val request = WebSocketRequestMessage.get("/v1/archives/media?limit=$limit${if (cursor.isNotNullOrBlank()) "&cursor=$cursor" else ""}", headers)
NetworkResult.fromWebSocketRequest(unauthWebSocket, request, ArchiveGetMediaItemsResponse::class)
}
}
/**
* Copy and re-encrypt media from the attachments cdn into the backup cdn.
*
* PUT /v1/archives/media
*
* - 200: Success
* - 400: Bad arguments, or made on an authenticated channel
* - 401: Invalid presentation or signature
* - 403: Insufficient permissions
* - 410: The source object was not found
* - 413: No media space remaining
* - 429: Rate-limited
*/
fun copyAttachmentToArchive(
aci: ACI,
archiveServiceAccess: ArchiveServiceAccess<MediaRootBackupKey>,
item: ArchiveMediaRequest
): NetworkResult<ArchiveMediaResponse> {
return getCredentialPresentation(aci, archiveServiceAccess)
.map { it.toArchiveCredentialPresentation().toHeaders() }
.then { headers ->
val request = WebSocketRequestMessage.put("/v1/archives/media", item, headers)
NetworkResult.fromWebSocketRequest(unauthWebSocket, request, ArchiveMediaResponse::class)
}
}
/**
* Copy and re-encrypt media from the attachments cdn into the backup cdn.
*/
fun copyAttachmentToArchive(
aci: ACI,
archiveServiceAccess: ArchiveServiceAccess<MediaRootBackupKey>,
items: List<ArchiveMediaRequest>
): NetworkResult<BatchArchiveMediaResponse> {
return getCredentialPresentation(aci, archiveServiceAccess)
.map { it.toArchiveCredentialPresentation().toHeaders() }
.then { headers ->
val request = WebSocketRequestMessage.put("/v1/archives/media/batch", BatchArchiveMediaRequest(items = items), headers)
NetworkResult.fromWebSocketRequest(unauthWebSocket, request, BatchArchiveMediaResponse::class)
}
}
/**
* Delete media from the backup cdn.
*
* POST /v1/archives/media/delete
*
* - 400: Bad args or made on an authenticated channel
* - 401: Bad presentation, invalid public key signature, no matching backupId on the server, or the credential was of the wrong type (messages/media)
* - 403: Forbidden
* - 429: Rate-limited
*/
fun deleteArchivedMedia(
aci: ACI,
archiveServiceAccess: ArchiveServiceAccess<MediaRootBackupKey>,
mediaToDelete: List<DeleteArchivedMediaRequest.ArchivedMediaObject>
): NetworkResult<Unit> {
return getCredentialPresentation(aci, archiveServiceAccess)
.map { it.toArchiveCredentialPresentation().toHeaders() }
.then { headers ->
val request = WebSocketRequestMessage.post("/v1/archives/media/delete", DeleteArchivedMediaRequest(mediaToDelete = mediaToDelete), headers)
NetworkResult.fromWebSocketRequest(unauthWebSocket, request, timeout = 30.seconds)
}
}
/**
* Retrieves auth credentials that can be used to perform SVRB operations.
*
* GET /v1/archives/auth/svrb
* - 200: Success
* - 400: Bad arguments, or made on an authenticated channel
* - 401: Bad presentation, invalid public key signature, no matching backupId on the server, or the credential was of the wrong type (messages/media)
* - 403: Forbidden
*/
fun getSvrBAuthorization(aci: ACI, archiveServiceAccess: ArchiveServiceAccess<MessageBackupKey>): NetworkResult<AuthCredentials> {
return getCredentialPresentation(aci, archiveServiceAccess)
.map { it.toArchiveCredentialPresentation().toHeaders() }
.then { headers ->
val request = WebSocketRequestMessage.get("/v1/archives/auth/svrb", headers)
NetworkResult.fromWebSocketRequest(unauthWebSocket, request, AuthCredentials::class)
}
}
/**
* Determine whether the backup-id can currently be rotated
*
* GET /v1/archives/backupid/limits
* - 200: Successfully retrieved backup-id rotation limits
* - 403: Invalid account authentication
*/
fun getKeyRotationLimit(): NetworkResult<ArchiveKeyRotationLimitResponse> {
val request = WebSocketRequestMessage.get("/v1/archives/backupid/limits")
return NetworkResult.fromWebSocketRequest(authWebSocket, request, ArchiveKeyRotationLimitResponse::class)
}
private fun getCredentialPresentation(aci: ACI, archiveServiceAccess: ArchiveServiceAccess<*>): NetworkResult<CredentialPresentationData> {
return NetworkResult.fromLocal {
val zkCredential = getZkCredential(aci, archiveServiceAccess)
CredentialPresentationData.from(archiveServiceAccess.backupKey, aci, zkCredential, backupServerPublicParams)
}
}
fun getZkCredential(aci: ACI, archiveServiceAccess: ArchiveServiceAccess<*>): BackupAuthCredential {
val backupAuthResponse = BackupAuthCredentialResponse(archiveServiceAccess.credential.credential)
val backupRequestContext = BackupAuthCredentialRequestContext.create(archiveServiceAccess.backupKey.value, aci.rawUuid)
return backupRequestContext.receiveResponse(
backupAuthResponse,
Instant.ofEpochSecond(archiveServiceAccess.credential.redemptionTime),
backupServerPublicParams
)
}
private class CredentialPresentationData(
val privateKey: ECPrivateKey,
val presentation: ByteArray,
val signedPresentation: ByteArray
) {
val publicKey: ECPublicKey = privateKey.getPublicKey()
companion object {
fun from(backupKey: BackupKey, aci: ACI, credential: BackupAuthCredential, backupServerPublicParams: GenericServerPublicParams): CredentialPresentationData {
val privateKey: ECPrivateKey = backupKey.deriveAnonymousCredentialPrivateKey(aci)
val presentation: ByteArray = credential.present(backupServerPublicParams).serialize()
val signedPresentation: ByteArray = privateKey.calculateSignature(presentation)
return CredentialPresentationData(privateKey, presentation, signedPresentation)
}
}
fun toArchiveCredentialPresentation(): ArchiveCredentialPresentation {
return ArchiveCredentialPresentation(
presentation = presentation,
signedPresentation = signedPresentation
)
}
}
}

View file

@ -0,0 +1,23 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.signalservice.api.archive
import org.signal.core.util.Base64
/**
* Acts as credentials for various archive operations.
*/
class ArchiveCredentialPresentation(
val presentation: ByteArray,
val signedPresentation: ByteArray
) {
fun toHeaders(): MutableMap<String, String> {
return mutableMapOf(
"X-Signal-ZK-Auth" to Base64.encodeWithPadding(presentation),
"X-Signal-ZK-Auth-Signature" to Base64.encodeWithPadding(signedPresentation)
)
}
}

View file

@ -0,0 +1,24 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.signalservice.api.archive
import com.fasterxml.jackson.annotation.JsonProperty
/**
* Represents the response when fetching the archive backup info.
*/
data class ArchiveGetBackupInfoResponse(
@JsonProperty
val cdn: Int?,
@JsonProperty
val backupDir: String?,
@JsonProperty
val mediaDir: String?,
@JsonProperty
val backupName: String?,
@JsonProperty
val usedSpace: Long?
)

View file

@ -0,0 +1,24 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.signalservice.api.archive
import com.fasterxml.jackson.annotation.JsonProperty
/**
* Response body for getting the media items stored in the user's archive.
*/
class ArchiveGetMediaItemsResponse(
@JsonProperty val storedMediaObjects: List<StoredMediaObject>,
@JsonProperty val backupDir: String?,
@JsonProperty val mediaDir: String?,
@JsonProperty val cursor: String?
) {
data class StoredMediaObject(
@JsonProperty val cdn: Int,
@JsonProperty val mediaId: String,
@JsonProperty val objectLength: Long
)
}

View file

@ -0,0 +1,11 @@
package org.whispersystems.signalservice.api.archive
import com.fasterxml.jackson.annotation.JsonProperty
/**
* Represents the response when fetching the archive backup key rotation limits
*/
data class ArchiveKeyRotationLimitResponse(
@JsonProperty val hasPermitsRemaining: Boolean?,
@JsonProperty val retryAfterSeconds: Long?
)

View file

@ -0,0 +1,24 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.signalservice.api.archive
import com.fasterxml.jackson.annotation.JsonProperty
/**
* Request to copy and re-encrypt media from the attachments cdn into the backup cdn.
*/
class ArchiveMediaRequest(
@JsonProperty val sourceAttachment: SourceAttachment,
@JsonProperty val objectLength: Int,
@JsonProperty val mediaId: String,
@JsonProperty val hmacKey: String,
@JsonProperty val encryptionKey: String
) {
class SourceAttachment(
@JsonProperty val cdn: Int,
@JsonProperty val key: String
)
}

View file

@ -0,0 +1,30 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.signalservice.api.archive
import com.fasterxml.jackson.annotation.JsonProperty
/**
* Response to archiving media, backup CDN number where media is located.
*/
class ArchiveMediaResponse(
@JsonProperty val cdn: Int
) {
enum class StatusCodes(val code: Int) {
BadArguments(400),
InvalidPresentationOrSignature(401),
InsufficientPermissions(403),
NoMediaSpaceRemaining(413),
RateLimited(429),
Unknown(-1);
companion object {
fun from(code: Int): StatusCodes {
return entries.firstOrNull { it.code == code } ?: Unknown
}
}
}
}

View file

@ -0,0 +1,25 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.signalservice.api.archive
/**
* Status codes for the ArchiveMediaUploadForm endpoint.
*
* Kept in a separate class because [AttachmentUploadForm] (the model the request returns) is used for multiple endpoints with different status codes.
*/
enum class ArchiveMediaUploadFormStatusCodes(val code: Int) {
BadArguments(400),
InvalidPresentationOrSignature(401),
InsufficientPermissions(403),
RateLimited(429),
Unknown(-1);
companion object {
fun from(code: Int): ArchiveMediaUploadFormStatusCodes {
return entries.firstOrNull { it.code == code } ?: Unknown
}
}
}

View file

@ -0,0 +1,16 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.signalservice.api.archive
import org.whispersystems.signalservice.api.backup.BackupKey
/**
* Key and credential combo needed to perform backup operations on the server.
*/
class ArchiveServiceAccess<T : BackupKey>(
val credential: ArchiveServiceCredential,
val backupKey: T
)

View file

@ -0,0 +1,17 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.signalservice.api.archive
import org.whispersystems.signalservice.api.backup.MediaRootBackupKey
import org.whispersystems.signalservice.api.backup.MessageBackupKey
/**
* A convenient container for passing around both a message and media archive service credential.
*/
data class ArchiveServiceAccessPair(
val messageBackupAccess: ArchiveServiceAccess<MessageBackupKey>,
val mediaBackupAccess: ArchiveServiceAccess<MediaRootBackupKey>
)

View file

@ -0,0 +1,15 @@
package org.whispersystems.signalservice.api.archive
import com.fasterxml.jackson.annotation.JsonProperty
/**
* Represents an individual credential for an archive operation. Note that is isn't the final
* credential you will actually use -- that's [org.signal.libsignal.zkgroup.backups.BackupAuthCredential].
* But you use these to make those.
*/
class ArchiveServiceCredential(
@JsonProperty
val credential: ByteArray,
@JsonProperty
val redemptionTime: Long
)

View file

@ -0,0 +1,39 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.signalservice.api.archive
import com.fasterxml.jackson.annotation.JsonProperty
import okio.IOException
/**
* Represents the result of fetching archive credentials.
* See [ArchiveServiceCredential].
*/
class ArchiveServiceCredentialsResponse(
@JsonProperty
val credentials: Map<String, List<ArchiveServiceCredential>>
) {
companion object {
private const val KEY_MESSAGES = "messages"
private const val KEY_MEDIA = "media"
}
init {
if (!credentials.containsKey(KEY_MESSAGES)) {
throw IOException("Missing key '$KEY_MESSAGES'")
}
if (!credentials.containsKey(KEY_MEDIA)) {
throw IOException("Missing key '$KEY_MEDIA'")
}
}
val messageCredentials: List<ArchiveServiceCredential>
get() = credentials[KEY_MESSAGES]!!
val mediaCredentials: List<ArchiveServiceCredential>
get() = credentials[KEY_MEDIA]!!
}

View file

@ -0,0 +1,32 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.signalservice.api.archive
import com.fasterxml.jackson.annotation.JsonProperty
import com.fasterxml.jackson.core.JsonGenerator
import com.fasterxml.jackson.databind.JsonSerializer
import com.fasterxml.jackson.databind.SerializerProvider
import com.fasterxml.jackson.databind.annotation.JsonSerialize
import org.signal.core.util.Base64
import org.signal.libsignal.zkgroup.backups.BackupAuthCredentialRequest
/**
* Represents the request body when setting the archive backupId.
*/
class ArchiveSetBackupIdRequest(
@JsonProperty
@JsonSerialize(using = BackupAuthCredentialRequestSerializer::class)
val messagesBackupAuthCredentialRequest: BackupAuthCredentialRequest?,
@JsonProperty
@JsonSerialize(using = BackupAuthCredentialRequestSerializer::class)
val mediaBackupAuthCredentialRequest: BackupAuthCredentialRequest?
) {
class BackupAuthCredentialRequestSerializer : JsonSerializer<BackupAuthCredentialRequest>() {
override fun serialize(value: BackupAuthCredentialRequest, gen: JsonGenerator, serializers: SerializerProvider) {
gen.writeString(Base64.encodeWithPadding(value.serialize()))
}
}
}

View file

@ -0,0 +1,29 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.signalservice.api.archive
import com.fasterxml.jackson.annotation.JsonProperty
import com.fasterxml.jackson.core.JsonGenerator
import com.fasterxml.jackson.databind.JsonSerializer
import com.fasterxml.jackson.databind.SerializerProvider
import com.fasterxml.jackson.databind.annotation.JsonSerialize
import org.signal.core.util.Base64
import org.signal.libsignal.protocol.ecc.ECPublicKey
/**
* Represents the request body when setting the archive public key.
*/
class ArchiveSetPublicKeyRequest(
@JsonProperty
@JsonSerialize(using = PublicKeySerializer::class)
val backupIdPublicKey: ECPublicKey
) {
class PublicKeySerializer : JsonSerializer<ECPublicKey>() {
override fun serialize(value: ECPublicKey, gen: JsonGenerator, serializers: SerializerProvider) {
gen.writeString(Base64.encodeWithPadding(value.serialize()))
}
}
}

View file

@ -0,0 +1,15 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.signalservice.api.archive
import com.fasterxml.jackson.annotation.JsonProperty
/**
* Request to copy and re-encrypt media from the attachments cdn into the backup cdn.
*/
class BatchArchiveMediaRequest(
@JsonProperty val items: List<ArchiveMediaRequest>
)

View file

@ -0,0 +1,22 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.signalservice.api.archive
import com.fasterxml.jackson.annotation.JsonProperty
/**
* Multi-response data for a batch archive media operation.
*/
class BatchArchiveMediaResponse(
@JsonProperty val responses: List<BatchArchiveMediaItemResponse>
) {
class BatchArchiveMediaItemResponse(
@JsonProperty val status: Int?,
@JsonProperty val failureReason: String?,
@JsonProperty val cdn: Int?,
@JsonProperty val mediaId: String
)
}

View file

@ -0,0 +1,20 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.signalservice.api.archive
import com.fasterxml.jackson.annotation.JsonProperty
/**
* Delete media from the backup cdn.
*/
class DeleteArchivedMediaRequest(
@JsonProperty val mediaToDelete: List<ArchivedMediaObject>
) {
data class ArchivedMediaObject(
@JsonProperty val cdn: Int,
@JsonProperty val mediaId: String
)
}

View file

@ -0,0 +1,15 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.signalservice.api.archive
import com.fasterxml.jackson.annotation.JsonProperty
/**
* Get response with headers to use to read from archive cdn.
*/
class GetArchiveCdnCredentialsResponse(
@JsonProperty val headers: Map<String, String>
)

View file

@ -0,0 +1,119 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.signalservice.api.attachment
import org.whispersystems.signalservice.api.NetworkResult
import org.whispersystems.signalservice.api.crypto.AttachmentCipherStreamUtil
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentRemoteId
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentStream
import org.whispersystems.signalservice.api.websocket.SignalWebSocket
import org.whispersystems.signalservice.internal.crypto.PaddingInputStream
import org.whispersystems.signalservice.internal.get
import org.whispersystems.signalservice.internal.push.AttachmentUploadForm
import org.whispersystems.signalservice.internal.push.PushAttachmentData
import org.whispersystems.signalservice.internal.push.PushServiceSocket
import org.whispersystems.signalservice.internal.push.http.AttachmentCipherOutputStreamFactory
import org.whispersystems.signalservice.internal.push.http.ResumableUploadSpec
import org.whispersystems.signalservice.internal.websocket.WebSocketRequestMessage
import java.io.InputStream
import kotlin.jvm.optionals.getOrNull
/**
* Class to interact with various attachment-related endpoints.
*/
class AttachmentApi(
private val authWebSocket: SignalWebSocket.AuthenticatedWebSocket,
private val pushServiceSocket: PushServiceSocket
) {
/**
* Gets a v4 attachment upload form, which provides the necessary information to upload an attachment.
*
* GET /v4/attachments/form/upload
* - 200: Success
* - 413: Too many attempts
* - 429: Too many attempts
*/
fun getAttachmentV4UploadForm(): NetworkResult<AttachmentUploadForm> {
val request = WebSocketRequestMessage.get("/v4/attachments/form/upload")
return NetworkResult.fromWebSocketRequest(authWebSocket, request, AttachmentUploadForm::class)
}
/**
* Gets a resumable upload spec, which can be saved and re-used across upload attempts to resume upload progress.
*/
fun getResumableUploadSpec(key: ByteArray, iv: ByteArray, uploadForm: AttachmentUploadForm): NetworkResult<ResumableUploadSpec> {
return getResumableUploadUrl(uploadForm)
.map { url ->
ResumableUploadSpec(
attachmentKey = key,
attachmentIv = iv,
cdnKey = uploadForm.key,
cdnNumber = uploadForm.cdn,
resumeLocation = url,
expirationTimestamp = System.currentTimeMillis() + PushServiceSocket.CDN2_RESUMABLE_LINK_LIFETIME_MILLIS,
headers = uploadForm.headers
)
}
}
/**
* Uploads an attachment using the v4 upload scheme.
*/
fun uploadAttachmentV4(attachmentStream: SignalServiceAttachmentStream): NetworkResult<AttachmentUploadResult> {
if (attachmentStream.resumableUploadSpec.isEmpty) {
throw IllegalStateException("Attachment must have a resumable upload spec!")
}
return NetworkResult.fromFetch {
val resumableUploadSpec = attachmentStream.resumableUploadSpec.get()
val paddedLength = PaddingInputStream.getPaddedSize(attachmentStream.length)
val dataStream: InputStream = PaddingInputStream(attachmentStream.inputStream, attachmentStream.length)
val ciphertextLength = AttachmentCipherStreamUtil.getCiphertextLength(paddedLength)
val attachmentData = PushAttachmentData(
contentType = attachmentStream.contentType,
data = dataStream,
dataSize = ciphertextLength,
incremental = attachmentStream.isFaststart,
outputStreamFactory = AttachmentCipherOutputStreamFactory(resumableUploadSpec.attachmentKey, resumableUploadSpec.attachmentIv),
listener = attachmentStream.listener,
cancelationSignal = attachmentStream.cancelationSignal,
resumableUploadSpec = attachmentStream.resumableUploadSpec.get()
)
val digestInfo = pushServiceSocket.uploadAttachment(attachmentData)
AttachmentUploadResult(
remoteId = SignalServiceAttachmentRemoteId.V4(attachmentData.resumableUploadSpec.cdnKey),
cdnNumber = attachmentData.resumableUploadSpec.cdnNumber,
key = resumableUploadSpec.attachmentKey,
digest = digestInfo.digest,
incrementalDigest = digestInfo.incrementalDigest,
incrementalDigestChunkSize = digestInfo.incrementalMacChunkSize,
uploadTimestamp = attachmentStream.uploadTimestamp,
dataSize = attachmentStream.length,
blurHash = attachmentStream.blurHash.getOrNull()
)
}
}
/**
* Uploads a raw file using the v4 upload scheme. No additional encryption is supplied! Always prefer [uploadAttachmentV4], unless you are using a separate
* encryption scheme (i.e. like backup files).
*/
fun uploadPreEncryptedFileToAttachmentV4(uploadForm: AttachmentUploadForm, resumableUploadUrl: String, inputStream: InputStream, inputStreamLength: Long): NetworkResult<Unit> {
return NetworkResult.fromFetch {
pushServiceSocket.uploadBackupFile(uploadForm, resumableUploadUrl, inputStream, inputStreamLength)
}
}
fun getResumableUploadUrl(uploadForm: AttachmentUploadForm): NetworkResult<String> {
return NetworkResult.fromFetch {
pushServiceSocket.getResumableUploadUrl(uploadForm)
}
}
}

View file

@ -0,0 +1,23 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.signalservice.api.attachment
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentRemoteId
/**
* The result of uploading an attachment. Just the additional metadata related to the upload itself.
*/
class AttachmentUploadResult(
val remoteId: SignalServiceAttachmentRemoteId,
val cdnNumber: Int,
val key: ByteArray,
val digest: ByteArray,
val incrementalDigest: ByteArray?,
val incrementalDigestChunkSize: Int,
val dataSize: Long,
val uploadTimestamp: Long,
val blurHash: String?
)

View file

@ -0,0 +1,25 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.signalservice.api.backup
import org.signal.core.util.Base64
import java.security.MessageDigest
/**
* Safe typing around a backupId, which is a 16-byte array.
*/
@JvmInline
value class BackupId(val value: ByteArray) {
init {
require(value.size == 16) { "BackupId must be 16 bytes!" }
}
/** Encode backup-id for use in a URL/request */
fun encode(): String {
return Base64.encodeUrlSafeWithPadding(MessageDigest.getInstance("SHA-256").digest(value).copyOfRange(0, 16))
}
}

View file

@ -0,0 +1,22 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.signalservice.api.backup
import org.signal.libsignal.protocol.ecc.ECPrivateKey
import org.whispersystems.signalservice.api.push.ServiceId.ACI
/**
* Contains the common properties for all "backup keys", namely the [MessageBackupKey] and [MediaRootBackupKey]
*/
interface BackupKey {
val value: ByteArray
/**
* The private key used to generate anonymous credentials when interacting with the backup service.
*/
fun deriveAnonymousCredentialPrivateKey(aci: ACI): ECPrivateKey
}

View file

@ -0,0 +1,26 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.signalservice.api.backup
import org.signal.core.util.Base64
/**
* Safe typing around a mediaId, which is a 15-byte array.
*/
@JvmInline
value class MediaId(val value: ByteArray) {
constructor(mediaId: String) : this(Base64.decode(mediaId))
init {
require(value.size == 15) { "MediaId must be 15 bytes!" }
}
/** Encode media-id for use in a URL/request */
fun encode(): String {
return Base64.encodeUrlSafeWithPadding(value)
}
}

View file

@ -0,0 +1,37 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.signalservice.api.backup
import org.signal.core.util.Hex
/**
* Represent a media name for the various types of media that can be archived.
*/
@JvmInline
value class MediaName(val name: String) {
companion object {
fun fromPlaintextHashAndRemoteKey(plaintextHash: ByteArray, remoteKey: ByteArray) = MediaName(Hex.toStringCondensed(plaintextHash + remoteKey))
fun fromPlaintextHashAndRemoteKeyForThumbnail(plaintextHash: ByteArray, remoteKey: ByteArray) = MediaName(Hex.toStringCondensed(plaintextHash + remoteKey) + "_thumbnail")
fun forThumbnailFromMediaName(mediaName: String) = MediaName("${mediaName}_thumbnail")
/**
* For java, since it struggles with value classes.
*/
@JvmStatic
fun toMediaIdString(mediaName: String, mediaRootBackupKey: MediaRootBackupKey): String {
return MediaName(mediaName).toMediaId(mediaRootBackupKey).encode()
}
}
fun toMediaId(mediaRootBackupKey: MediaRootBackupKey): MediaId {
return mediaRootBackupKey.deriveMediaId(this)
}
override fun toString(): String {
return name
}
}

View file

@ -0,0 +1,87 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.signalservice.api.backup
import org.signal.libsignal.protocol.ecc.ECPrivateKey
import org.whispersystems.signalservice.api.push.ServiceId.ACI
import org.whispersystems.signalservice.internal.util.Util
import org.signal.libsignal.messagebackup.BackupKey as LibSignalBackupKey
/**
* Safe typing around a media root backup key, which is a 32-byte array.
* This key is a purely random value.
*/
class MediaRootBackupKey(override val value: ByteArray) : BackupKey {
companion object {
fun generate(): MediaRootBackupKey {
return MediaRootBackupKey(Util.getSecretBytes(32))
}
}
/**
* The private key used to generate anonymous credentials when interacting with the backup service.
*/
override fun deriveAnonymousCredentialPrivateKey(aci: ACI): ECPrivateKey {
return LibSignalBackupKey(value).deriveEcKey(aci.libSignalAci)
}
fun deriveMediaId(mediaName: MediaName): MediaId {
return MediaId(LibSignalBackupKey(value).deriveMediaId(mediaName.name))
}
fun deriveMediaSecrets(mediaName: MediaName): MediaKeyMaterial {
val mediaId = deriveMediaId(mediaName)
return deriveMediaSecrets(mediaId)
}
fun deriveMediaSecretsFromMediaId(base64MediaId: String): MediaKeyMaterial {
return deriveMediaSecrets(MediaId(base64MediaId))
}
fun deriveThumbnailTransitKey(thumbnailMediaName: MediaName): ByteArray {
return LibSignalBackupKey(value).deriveThumbnailTransitEncryptionKey(deriveMediaId(thumbnailMediaName).value)
}
private fun deriveMediaSecrets(mediaId: MediaId): MediaKeyMaterial {
val libsignalBackupKey = LibSignalBackupKey(value)
val combinedKey = libsignalBackupKey.deriveMediaEncryptionKey(mediaId.value)
return MediaKeyMaterial(
id = mediaId,
macKey = combinedKey.copyOfRange(0, 32),
aesKey = combinedKey.copyOfRange(32, 64)
)
}
/**
* Identifies a the location of a user's backup.
*/
fun deriveBackupId(aci: ACI): BackupId {
return BackupId(
LibSignalBackupKey(value).deriveBackupId(aci.libSignalAci)
)
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as MediaRootBackupKey
return value.contentEquals(other.value)
}
override fun hashCode(): Int {
return value.contentHashCode()
}
class MediaKeyMaterial(
val id: MediaId,
val macKey: ByteArray,
val aesKey: ByteArray
)
}

View file

@ -0,0 +1,61 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.signalservice.api.backup
import org.signal.libsignal.messagebackup.BackupForwardSecrecyToken
import org.signal.libsignal.protocol.ecc.ECPrivateKey
import org.whispersystems.signalservice.api.push.ServiceId.ACI
import org.signal.libsignal.messagebackup.BackupKey as LibSignalBackupKey
import org.signal.libsignal.messagebackup.MessageBackupKey as LibSignalMessageBackupKey
/**
* Safe typing around a backup key, which is a 32-byte array.
* This key is derived from the AEP.
*/
class MessageBackupKey(override val value: ByteArray) : BackupKey {
init {
require(value.size == 32) { "Backup key must be 32 bytes!" }
}
/**
* The private key used to generate anonymous credentials when interacting with the backup service.
*/
override fun deriveAnonymousCredentialPrivateKey(aci: ACI): ECPrivateKey {
return LibSignalBackupKey(value).deriveEcKey(aci.libSignalAci)
}
/**
* The cryptographic material used to encrypt a backup.
*
* @param forwardSecrecyToken Should be present for any backup located on the archive CDN. Absent for other uses (i.e. link+sync).
*/
fun deriveBackupSecrets(aci: ACI, forwardSecrecyToken: BackupForwardSecrecyToken?): BackupKeyMaterial {
val backupId = deriveBackupId(aci)
val libsignalBackupKey = LibSignalBackupKey(value)
val libsignalMessageMessageBackupKey = LibSignalMessageBackupKey(libsignalBackupKey, backupId.value, forwardSecrecyToken)
return BackupKeyMaterial(
id = backupId,
macKey = libsignalMessageMessageBackupKey.hmacKey,
aesKey = libsignalMessageMessageBackupKey.aesKey
)
}
/**
* Identifies a the location of a user's backup.
*/
fun deriveBackupId(aci: ACI): BackupId {
return BackupId(
LibSignalBackupKey(value).deriveBackupId(aci.libSignalAci)
)
}
class BackupKeyMaterial(
val id: BackupId,
val macKey: ByteArray,
val aesKey: ByteArray
)
}

View file

@ -0,0 +1,83 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.signalservice.api.calling
import org.signal.libsignal.zkgroup.calllinks.CreateCallLinkCredentialRequest
import org.signal.libsignal.zkgroup.calllinks.CreateCallLinkCredentialResponse
import org.whispersystems.signalservice.api.NetworkResult
import org.whispersystems.signalservice.api.messages.calls.CallingResponse
import org.whispersystems.signalservice.api.messages.calls.TurnServerInfo
import org.whispersystems.signalservice.api.websocket.SignalWebSocket
import org.whispersystems.signalservice.internal.get
import org.whispersystems.signalservice.internal.post
import org.whispersystems.signalservice.internal.push.CreateCallLinkAuthRequest
import org.whispersystems.signalservice.internal.push.CreateCallLinkAuthResponse
import org.whispersystems.signalservice.internal.push.GetCallingRelaysResponse
import org.whispersystems.signalservice.internal.push.PushServiceSocket
import org.whispersystems.signalservice.internal.websocket.WebSocketRequestMessage
/**
* Provide calling specific network apis.
*/
class CallingApi(
private val auth: SignalWebSocket.AuthenticatedWebSocket,
private val pushServiceSocket: PushServiceSocket
) {
/**
* Get 1:1 relay addresses in IpV4, Ipv6, and URL formats.
*
* GET /v2/calling/relays
* - 200: Success
* - 400: Invalid request
* - 422: Invalid request format
* - 429: Rate limited
*/
fun getTurnServerInfo(): NetworkResult<List<TurnServerInfo>> {
val request = WebSocketRequestMessage.get("/v2/calling/relays")
return NetworkResult.fromWebSocketRequest(auth, request, GetCallingRelaysResponse::class)
.map { it.relays ?: emptyList() }
}
/**
* Generate a call link credential.
*
* POST /v1/call-link/create-auth
* - 200: Success
* - 400: Invalid request
* - 422: Invalid request format
* - 429: Rate limited
*/
fun createCallLinkCredential(request: CreateCallLinkCredentialRequest): NetworkResult<CreateCallLinkCredentialResponse> {
val request = WebSocketRequestMessage.post("/v1/call-link/create-auth", body = CreateCallLinkAuthRequest.create(request))
return NetworkResult.fromWebSocketRequest(auth, request, CreateCallLinkAuthResponse::class)
.map { it.createCallLinkCredentialResponse }
}
/**
* Send an http request on behalf of the calling infrastructure. Only returns [NetworkResult.Success] with the
* wrapped [CallingResponse] wrapping the error which in practice should never happen.
*
* @param requestId Request identifier
* @param url Fully qualified URL to request
* @param httpMethod Http method to use (e.g., "GET", "POST")
* @param headers Optional list of headers to send with request
* @param body Optional body to send with request
* @return
*/
fun makeCallingRequest(
requestId: Long,
url: String,
httpMethod: String,
headers: List<Pair<String, String>>?,
body: ByteArray?
): NetworkResult<CallingResponse> {
return when (val result = NetworkResult.fromFetch { pushServiceSocket.makeCallingRequest(requestId, url, httpMethod, headers, body) }) {
is NetworkResult.Success -> result
else -> NetworkResult.Success(CallingResponse.Error(requestId, result.getCause()))
}
}
}

View file

@ -0,0 +1,88 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.signalservice.api.cds
import org.signal.core.util.logging.Log
import org.signal.libsignal.net.CdsiProtocolException
import org.signal.libsignal.net.Network
import org.signal.libsignal.zkgroup.profiles.ProfileKey
import org.whispersystems.signalservice.api.NetworkResult
import org.whispersystems.signalservice.api.NetworkResult.StatusCodeError
import org.whispersystems.signalservice.api.push.ServiceId
import org.whispersystems.signalservice.api.push.exceptions.CdsiInvalidTokenException
import org.whispersystems.signalservice.api.push.exceptions.CdsiResourceExhaustedException
import org.whispersystems.signalservice.api.websocket.SignalWebSocket
import org.whispersystems.signalservice.internal.get
import org.whispersystems.signalservice.internal.push.CdsiAuthResponse
import org.whispersystems.signalservice.internal.websocket.WebSocketRequestMessage
import java.io.IOException
import java.util.Optional
import java.util.concurrent.TimeUnit
import java.util.concurrent.TimeoutException
import java.util.function.Consumer
/**
* Contact Discovery Service API endpoint.
*/
class CdsApi(private val authWebSocket: SignalWebSocket.AuthenticatedWebSocket) {
companion object {
private val TAG = Log.tag(CdsApi::class)
}
/**
* Get CDS authentication and then request registered users for the provided e164s.
*
* GET /v2/directory/auth
* - 200: Success
* - 401: Not authenticated
*
* And then CDS websocket communications, can return the following within [StatusCodeError]
* - [CdsiResourceExhaustedException]: Rate limited
* - [CdsiInvalidTokenException]: Token no longer valid
*/
fun getRegisteredUsers(
previousE164s: Set<String>,
newE164s: Set<String>,
serviceIds: Map<ServiceId, ProfileKey>,
token: Optional<ByteArray>,
timeoutMs: Long?,
libsignalNetwork: Network,
tokenSaver: Consumer<ByteArray>
): NetworkResult<CdsiV2Service.Response> {
val authRequest = WebSocketRequestMessage.get("/v2/directory/auth")
return NetworkResult.fromWebSocketRequest(authWebSocket, authRequest, CdsiAuthResponse::class)
.then { auth ->
val service = CdsiV2Service(libsignalNetwork)
val request = CdsiV2Service.Request(previousE164s, newE164s, serviceIds, token)
val single = service.getRegisteredUsers(auth.username, auth.password, request, tokenSaver)
return@then try {
if (timeoutMs == null) {
single
.blockingGet()
} else {
single
.timeout(timeoutMs, TimeUnit.MILLISECONDS)
.blockingGet()
}
} catch (e: RuntimeException) {
when (val cause = e.cause) {
is InterruptedException -> NetworkResult.NetworkError(IOException("Interrupted", cause))
is TimeoutException -> NetworkResult.NetworkError(IOException("Timed out"))
is CdsiProtocolException -> NetworkResult.NetworkError(IOException("CdsiProtocol", cause))
is CdsiInvalidTokenException -> NetworkResult.NetworkError(IOException("CdsiInvalidToken", cause))
else -> {
Log.w(TAG, "Unexpected exception", cause)
NetworkResult.NetworkError(IOException(cause))
}
}
}
}
}
}

View file

@ -0,0 +1,188 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.signalservice.api.cds;
import org.signal.libsignal.net.CdsiLookupRequest;
import org.signal.libsignal.net.CdsiLookupResponse;
import org.signal.libsignal.net.Network;
import org.signal.libsignal.zkgroup.profiles.ProfileKey;
import org.whispersystems.signalservice.api.NetworkResult;
import org.whispersystems.signalservice.api.push.ServiceId;
import org.whispersystems.signalservice.api.push.ServiceId.ACI;
import org.whispersystems.signalservice.api.push.ServiceId.PNI;
import org.whispersystems.signalservice.api.push.exceptions.CdsiInvalidArgumentException;
import org.whispersystems.signalservice.api.push.exceptions.CdsiInvalidTokenException;
import org.whispersystems.signalservice.api.push.exceptions.CdsiResourceExhaustedException;
import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException;
import java.io.IOException;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Future;
import java.util.function.Consumer;
import java.util.stream.Collectors;
import javax.annotation.Nonnull;
import io.reactivex.rxjava3.core.Observable;
import io.reactivex.rxjava3.core.Single;
/**
* Handles network interactions with CDSI, the SGX-backed version of the CDSv2 API.
*/
public final class CdsiV2Service {
private final CdsiRequestHandler cdsiRequestHandler;
public CdsiV2Service(@Nonnull Network network) {
this.cdsiRequestHandler = (username, password, request, tokenSaver) -> {
try {
Future<CdsiLookupResponse> cdsiRequest = network.cdsiLookup(username, password, buildLibsignalRequest(request), tokenSaver);
return Single.fromFuture(cdsiRequest)
.onErrorResumeNext((Throwable err) -> {
if (err instanceof ExecutionException && err.getCause() != null) {
err = err.getCause();
}
return Single.error(mapLibsignalError(err));
})
.map(CdsiV2Service::parseLibsignalResponse)
.toObservable();
} catch (Exception exception) {
return Observable.error(mapLibsignalError(exception));
}
};
}
public Single<NetworkResult<Response>> getRegisteredUsers(String username, String password, Request request, Consumer<byte[]> tokenSaver) {
return cdsiRequestHandler
.handleRequest(username, password, request, tokenSaver)
.collect(Collectors.toList())
.flatMap(pages -> {
Map<String, ResponseItem> all = new HashMap<>();
int quotaUsed = 0;
for (Response page : pages) {
all.putAll(page.getResults());
quotaUsed += page.getQuotaUsedDebugOnly();
}
return Single.<NetworkResult<Response>>just(new NetworkResult.Success<>(new Response(all, quotaUsed)));
})
.onErrorReturn(error -> {
if (error instanceof NonSuccessfulResponseCodeException) {
return new NetworkResult.StatusCodeError<>((NonSuccessfulResponseCodeException) error);
} else if (error instanceof IOException) {
return new NetworkResult.NetworkError<>((IOException) error);
} else {
return new NetworkResult.ApplicationError<>(error);
}
});
}
private static CdsiLookupRequest buildLibsignalRequest(Request request) {
HashMap<org.signal.libsignal.protocol.ServiceId, ProfileKey> serviceIds = new HashMap<>(request.serviceIds.size());
request.serviceIds.forEach((key, value) -> serviceIds.put(key.getLibSignalServiceId(), value));
return new CdsiLookupRequest(request.previousE164s, request.newE164s, serviceIds, Optional.ofNullable(request.token));
}
private static Response parseLibsignalResponse(CdsiLookupResponse response) {
HashMap<String, ResponseItem> responses = new HashMap<>(response.entries().size());
response.entries().forEach((key, value) -> responses.put(key, new ResponseItem(new PNI(value.pni), Optional.ofNullable(value.aci).map(ACI::new))));
return new Response(responses, response.debugPermitsUsed);
}
private static Throwable mapLibsignalError(Throwable lookupError) {
if (lookupError instanceof org.signal.libsignal.net.CdsiInvalidTokenException) {
return new CdsiInvalidTokenException();
} else if (lookupError instanceof org.signal.libsignal.net.RetryLaterException) {
org.signal.libsignal.net.RetryLaterException e = (org.signal.libsignal.net.RetryLaterException) lookupError;
return new CdsiResourceExhaustedException((int) e.duration.getSeconds());
} else if (lookupError instanceof IllegalArgumentException) {
return new CdsiInvalidArgumentException();
} else if (lookupError instanceof org.signal.libsignal.net.CdsiProtocolException) {
return new IOException(lookupError);
}
return lookupError;
}
public static final class Request {
final Set<String> previousE164s;
final Set<String> newE164s;
final Set<String> removedE164s;
final Map<ServiceId, ProfileKey> serviceIds;
final byte[] token;
public Request(Set<String> previousE164s, Set<String> newE164s, Map<ServiceId, ProfileKey> serviceIds, Optional<byte[]> token) {
if (previousE164s.size() > 0 && !token.isPresent()) {
throw new IllegalArgumentException("You must have a token if you have previousE164s!");
}
this.previousE164s = previousE164s;
this.newE164s = newE164s;
this.removedE164s = Collections.emptySet();
this.serviceIds = serviceIds;
this.token = token.orElse(null);
}
public int serviceIdSize() {
return previousE164s.size() + newE164s.size() + removedE164s.size() + serviceIds.size();
}
}
public static final class Response {
private final Map<String, ResponseItem> results;
private final int quotaUsed;
public Response(Map<String, ResponseItem> results, int quoteUsed) {
this.results = results;
this.quotaUsed = quoteUsed;
}
public Map<String, ResponseItem> getResults() {
return results;
}
/**
* Tells you how much quota you used in the request. This should only be used for debugging/logging purposed, and should never be relied upon for making
* actual decisions.
*/
public int getQuotaUsedDebugOnly() {
return quotaUsed;
}
}
public static final class ResponseItem {
private final PNI pni;
private final Optional<ACI> aci;
public ResponseItem(PNI pni, Optional<ACI> aci) {
this.pni = pni;
this.aci = aci;
}
public PNI getPni() {
return pni;
}
public Optional<ACI> getAci() {
return aci;
}
public boolean hasAci() {
return aci.isPresent();
}
}
private interface CdsiRequestHandler {
Observable<Response> handleRequest(String username, String password, Request request, Consumer<byte[]> tokenSaver);
}
}

View file

@ -0,0 +1,38 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.signalservice.api.certificate
import org.whispersystems.signalservice.api.NetworkResult
import org.whispersystems.signalservice.api.websocket.SignalWebSocket
import org.whispersystems.signalservice.internal.get
import org.whispersystems.signalservice.internal.push.SenderCertificate
import org.whispersystems.signalservice.internal.websocket.WebSocketRequestMessage
/**
* Endpoints to get [SenderCertificate]s.
*/
class CertificateApi(private val authWebSocket: SignalWebSocket.AuthenticatedWebSocket) {
/**
* GET /v1/certificate/delivery
* - 200: Success
*/
fun getSenderCertificate(): NetworkResult<ByteArray> {
val request = WebSocketRequestMessage.get("/v1/certificate/delivery")
return NetworkResult.fromWebSocketRequest(authWebSocket, request, SenderCertificate::class)
.map { it.certificate }
}
/**
* GET /v1/certificate/delivery?includeE164=false
* - 200: Success
*/
fun getSenderCertificateForPhoneNumberPrivacy(): NetworkResult<ByteArray> {
val request = WebSocketRequestMessage.get("/v1/certificate/delivery?includeE164=false")
return NetworkResult.fromWebSocketRequest(authWebSocket, request, SenderCertificate::class)
.map { it.certificate }
}
}

View file

@ -0,0 +1,410 @@
/*
* Copyright (C) 2014-2017 Open Whisper Systems
*
* Licensed according to the LICENSE file in this repository.
*/
package org.whispersystems.signalservice.api.crypto
import org.signal.core.util.Base64
import org.signal.core.util.readNBytesOrThrow
import org.signal.core.util.stream.LimitedInputStream
import org.signal.core.util.stream.TrimmingInputStream
import org.signal.libsignal.protocol.InvalidMessageException
import org.signal.libsignal.protocol.incrementalmac.ChunkSizeChoice
import org.signal.libsignal.protocol.incrementalmac.IncrementalMacInputStream
import org.signal.libsignal.protocol.kdf.HKDF
import org.whispersystems.signalservice.api.backup.MediaRootBackupKey.MediaKeyMaterial
import org.whispersystems.signalservice.internal.util.Util
import java.io.ByteArrayInputStream
import java.io.File
import java.io.FileInputStream
import java.io.IOException
import java.io.InputStream
import java.security.InvalidKeyException
import java.security.MessageDigest
import java.security.NoSuchAlgorithmException
import javax.annotation.Nonnull
import javax.crypto.Cipher
import javax.crypto.Mac
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.SecretKeySpec
import kotlin.math.min
/**
* Decrypts an attachment stream that has been encrypted with AES/CBC/PKCS5Padding.
*
* It assumes that the first 16 bytes of the stream are the IV, and that the rest of the stream is encrypted data.
*/
object AttachmentCipherInputStream {
private const val BLOCK_SIZE = 16
private const val CIPHER_KEY_SIZE = 32
private const val MAC_KEY_SIZE = 32
/**
* Creates a stream to decrypt a typical attachment via a [File].
*
* @param incrementalDigest If null, incremental mac validation is disabled.
* @param incrementalMacChunkSize If 0, incremental mac validation is disabled.
*/
@JvmStatic
@Throws(InvalidMessageException::class, IOException::class)
fun createForAttachment(
file: File,
plaintextLength: Long,
combinedKeyMaterial: ByteArray,
integrityCheck: IntegrityCheck,
incrementalDigest: ByteArray?,
incrementalMacChunkSize: Int
): InputStream {
return create(
streamSupplier = { FileInputStream(file) },
streamLength = file.length(),
plaintextLength = plaintextLength,
combinedKeyMaterial = combinedKeyMaterial,
integrityCheck = integrityCheck,
incrementalDigest = incrementalDigest,
incrementalMacChunkSize = incrementalMacChunkSize
)
}
/**
* Creates a stream to decrypt a typical attachment via a [StreamSupplier].
*
* @param incrementalDigest If null, incremental mac validation is disabled.
* @param incrementalMacChunkSize If 0, incremental mac validation is disabled.
*/
@JvmStatic
@Throws(InvalidMessageException::class, IOException::class)
fun createForAttachment(
streamSupplier: StreamSupplier,
streamLength: Long,
plaintextLength: Long,
combinedKeyMaterial: ByteArray,
integrityCheck: IntegrityCheck,
incrementalDigest: ByteArray?,
incrementalMacChunkSize: Int
): InputStream {
return create(
streamSupplier = streamSupplier,
streamLength = streamLength,
plaintextLength = plaintextLength,
combinedKeyMaterial = combinedKeyMaterial,
integrityCheck = integrityCheck,
incrementalDigest = incrementalDigest,
incrementalMacChunkSize = incrementalMacChunkSize
)
}
/**
* When you archive an attachment, you give the server an encrypted attachment, and the server wraps it in *another* layer of encryption.
*
* This creates a stream decrypt both the inner and outer layers of an archived attachment at the same time by basically double-decrypting it.
*
* @param incrementalDigest If null, incremental mac validation is disabled.
* @param incrementalMacChunkSize If 0, incremental mac validation is disabled.
*/
@JvmStatic
@Throws(InvalidMessageException::class, IOException::class)
fun createForArchivedMedia(
archivedMediaKeyMaterial: MediaKeyMaterial,
file: File,
originalCipherTextLength: Long,
plaintextLength: Long,
combinedKeyMaterial: ByteArray,
plaintextHash: ByteArray,
incrementalDigest: ByteArray?,
incrementalMacChunkSize: Int
): InputStream {
val keyMaterial = CombinedKeyMaterial.from(combinedKeyMaterial)
val mac = initMac(keyMaterial.macKey)
if (originalCipherTextLength <= BLOCK_SIZE + mac.macLength) {
throw InvalidMessageException("Message shorter than crypto overhead!")
}
return create(
streamSupplier = { createForArchivedMediaOuterLayer(archivedMediaKeyMaterial, file, originalCipherTextLength) },
streamLength = originalCipherTextLength,
plaintextLength = plaintextLength,
combinedKeyMaterial = combinedKeyMaterial,
integrityCheck = IntegrityCheck(plaintextHash = plaintextHash, encryptedDigest = null),
incrementalDigest = incrementalDigest,
incrementalMacChunkSize = incrementalMacChunkSize
)
}
/**
* When you archive an attachment thumbnail, you give the server an encrypted attachment, and the server wraps it in *another* layer of encryption.
*
* This creates a stream decrypt both the inner and outer layers of an archived attachment at the same time by basically double-decrypting it.
*
* Archive thumbnails are also special in that we:
* - don't know how long they are (meaning you'll get them back with padding at the end, image viewers are ok with this)
* - don't care about external integrity checks (we still validate the MACs)
*
* So there's some code duplication here just to avoid mucking up the reusable functions with special cases.
*/
@JvmStatic
@Throws(InvalidMessageException::class, IOException::class)
fun createForArchivedThumbnail(
archivedMediaKeyMaterial: MediaKeyMaterial,
file: File,
innerCombinedKeyMaterial: ByteArray
): InputStream {
val outerMac = initMac(archivedMediaKeyMaterial.macKey)
if (file.length() <= BLOCK_SIZE + outerMac.macLength) {
throw InvalidMessageException("Message shorter than crypto overhead! Expected at least ${BLOCK_SIZE + outerMac.macLength} bytes, got ${file.length()}")
}
FileInputStream(file).use { macVerificationStream ->
verifyMacAndMaybeEncryptedDigest(macVerificationStream, file.length(), outerMac, null)
}
val outerEncryptedStreamExcludingMac = LimitedInputStream(FileInputStream(file), maxBytes = file.length() - outerMac.macLength)
val outerCipher = createCipher(outerEncryptedStreamExcludingMac, archivedMediaKeyMaterial.aesKey)
val innerEncryptedStream = BetterCipherInputStream(outerEncryptedStreamExcludingMac, outerCipher)
val innerKeyMaterial = CombinedKeyMaterial.from(innerCombinedKeyMaterial)
val innerMac = initMac(innerKeyMaterial.macKey)
val innerEncryptedStreamWithMac = MacValidatingInputStream(innerEncryptedStream, innerMac)
val innerEncryptedStreamExcludingMac = TrimmingInputStream(innerEncryptedStreamWithMac, trimSize = innerMac.macLength, drain = true)
val innerCipher = createCipher(innerEncryptedStreamExcludingMac, innerKeyMaterial.aesKey)
return BetterCipherInputStream(innerEncryptedStreamExcludingMac, innerCipher)
}
/**
* Creates a stream to decrypt sticker data. Stickers have a special path because the key material is derived from the pack key.
*/
@JvmStatic
@Throws(InvalidMessageException::class, IOException::class)
fun createForStickerData(data: ByteArray, packKey: ByteArray): InputStream {
val keyMaterial = CombinedKeyMaterial.from(HKDF.deriveSecrets(packKey, "Sticker Pack".toByteArray(), 64))
val mac = initMac(keyMaterial.macKey)
if (data.size <= BLOCK_SIZE + mac.macLength) {
throw InvalidMessageException("Message shorter than crypto overhead!")
}
ByteArrayInputStream(data).use { inputStream ->
verifyMacAndMaybeEncryptedDigest(inputStream, data.size.toLong(), mac, null)
}
val encryptedStream = ByteArrayInputStream(data)
val encryptedStreamExcludingMac = LimitedInputStream(encryptedStream, data.size.toLong() - mac.macLength)
val cipher = createCipher(encryptedStreamExcludingMac, keyMaterial.aesKey)
return BetterCipherInputStream(encryptedStreamExcludingMac, cipher)
}
/**
* When you archive an attachment, you give the server an encrypted attachment, and the server wraps it in *another* layer of encryption.
* This will return a stream that unwraps the server's layer of encryption, giving you a stream that contains a "normally-encrypted" attachment.
*
* Because we're validating the encryptedDigest/plaintextHash of the inner layer, there's no additional out-of-band validation of this outer layer.
*/
@JvmStatic
@Throws(InvalidMessageException::class, IOException::class)
private fun createForArchivedMediaOuterLayer(archivedMediaKeyMaterial: MediaKeyMaterial, file: File, originalCipherTextLength: Long): LimitedInputStream {
val mac = initMac(archivedMediaKeyMaterial.macKey)
if (file.length() <= BLOCK_SIZE + mac.macLength) {
throw InvalidMessageException("Message shorter than crypto overhead! Expected at least ${BLOCK_SIZE + mac.macLength} bytes, got ${file.length()}")
}
FileInputStream(file).use { macVerificationStream ->
verifyMacAndMaybeEncryptedDigest(macVerificationStream, file.length(), mac, null)
}
val encryptedStream = FileInputStream(file)
val encryptedStreamExcludingMac = LimitedInputStream(encryptedStream, file.length() - mac.macLength)
val cipher = createCipher(encryptedStreamExcludingMac, archivedMediaKeyMaterial.aesKey)
val inputStream: InputStream = BetterCipherInputStream(encryptedStreamExcludingMac, cipher)
return LimitedInputStream(inputStream, originalCipherTextLength)
}
/**
* @param integrityCheck If null, no integrity check is performed! This is a private method, so it's assumed that care has been taken to ensure that this is
* the correct course of action. Public methods should properly enforce when integrity checks are required.
*/
@JvmStatic
@Throws(InvalidMessageException::class, IOException::class)
private fun create(
streamSupplier: StreamSupplier,
streamLength: Long,
plaintextLength: Long,
combinedKeyMaterial: ByteArray,
integrityCheck: IntegrityCheck?,
incrementalDigest: ByteArray?,
incrementalMacChunkSize: Int
): InputStream {
val keyMaterial = CombinedKeyMaterial.from(combinedKeyMaterial)
val mac = initMac(keyMaterial.macKey)
if (streamLength <= BLOCK_SIZE + mac.macLength) {
throw InvalidMessageException("Message shorter than crypto overhead! length: $streamLength")
}
val wrappedStream: InputStream
val hasIncrementalMac = incrementalDigest != null && incrementalDigest.isNotEmpty() && incrementalMacChunkSize > 0
if (hasIncrementalMac) {
if (integrityCheck == null) {
throw InvalidMessageException("Missing integrityCheck for incremental mac validation!")
}
val digestValidatingStream = if (integrityCheck.encryptedDigest != null) {
DigestValidatingInputStream(streamSupplier.openStream(), sha256Digest(), integrityCheck.encryptedDigest)
} else {
streamSupplier.openStream()
}
wrappedStream = IncrementalMacInputStream(
IncrementalMacAdditionalValidationsInputStream(
wrapped = digestValidatingStream,
fileLength = streamLength,
mac = mac
),
keyMaterial.macKey,
ChunkSizeChoice.everyNthByte(incrementalMacChunkSize),
incrementalDigest
)
} else {
streamSupplier.openStream().use { macVerificationStream ->
verifyMacAndMaybeEncryptedDigest(macVerificationStream, streamLength, mac, integrityCheck?.encryptedDigest)
}
wrappedStream = streamSupplier.openStream()
}
val encryptedStreamExcludingMac = LimitedInputStream(wrappedStream, streamLength - mac.macLength)
val cipher = createCipher(encryptedStreamExcludingMac, keyMaterial.aesKey)
val decryptingStream: InputStream = BetterCipherInputStream(encryptedStreamExcludingMac, cipher)
val paddinglessDecryptingStream = LimitedInputStream(decryptingStream, plaintextLength)
return if (integrityCheck?.plaintextHash != null) {
if (integrityCheck.plaintextHash.size != MessageDigest.getInstance("SHA-256").digestLength) {
throw InvalidMessageException("Invalid plaintext hash size: ${integrityCheck.plaintextHash.size}")
}
DigestValidatingInputStream(paddinglessDecryptingStream, sha256Digest(), integrityCheck.plaintextHash)
} else {
paddinglessDecryptingStream
}
}
private fun createCipher(inputStream: InputStream, aesKey: ByteArray): Cipher {
val iv = inputStream.readNBytesOrThrow(BLOCK_SIZE)
return Cipher.getInstance("AES/CBC/PKCS5Padding").apply {
init(Cipher.DECRYPT_MODE, SecretKeySpec(aesKey, "AES"), IvParameterSpec(iv))
}
}
private fun sha256Digest(): MessageDigest {
try {
return MessageDigest.getInstance("SHA-256")
} catch (e: NoSuchAlgorithmException) {
throw AssertionError(e)
}
}
private fun initMac(key: ByteArray): Mac {
try {
val mac = Mac.getInstance("HmacSHA256")
mac.init(SecretKeySpec(key, "HmacSHA256"))
return mac
} catch (e: NoSuchAlgorithmException) {
throw AssertionError(e)
} catch (e: InvalidKeyException) {
throw AssertionError(e)
}
}
@Throws(InvalidMessageException::class)
private fun verifyMacAndMaybeEncryptedDigest(@Nonnull inputStream: InputStream, length: Long, @Nonnull mac: Mac, theirDigest: ByteArray?) {
try {
val digest = MessageDigest.getInstance("SHA256")
var remainingData = Util.toIntExact(length) - mac.macLength
val buffer = ByteArray(4096)
while (remainingData > 0) {
val read = inputStream.read(buffer, 0, min(buffer.size, remainingData))
mac.update(buffer, 0, read)
digest.update(buffer, 0, read)
remainingData -= read
}
val ourMac = mac.doFinal()
val theirMac = ByteArray(mac.macLength)
Util.readFully(inputStream, theirMac)
if (!MessageDigest.isEqual(ourMac, theirMac)) {
throw InvalidMessageException("MAC doesn't match!")
}
val ourDigest = digest.digest(theirMac)
if (theirDigest != null && !MessageDigest.isEqual(ourDigest, theirDigest)) {
throw InvalidMessageException("Digest doesn't match!")
}
} catch (e: IOException) {
throw InvalidMessageException(e)
} catch (e: ArithmeticException) {
throw InvalidMessageException(e)
} catch (e: NoSuchAlgorithmException) {
throw AssertionError(e)
}
}
private class CombinedKeyMaterial(val aesKey: ByteArray, val macKey: ByteArray) {
companion object {
@Throws(InvalidMessageException::class)
fun from(combinedKeyMaterial: ByteArray): CombinedKeyMaterial {
if (combinedKeyMaterial.size != CIPHER_KEY_SIZE + MAC_KEY_SIZE) {
throw InvalidMessageException("Invalid combined key material size: ${combinedKeyMaterial.size}")
}
val parts = Util.split(combinedKeyMaterial, CIPHER_KEY_SIZE, MAC_KEY_SIZE)
return CombinedKeyMaterial(parts[0], parts[1])
}
}
}
fun interface StreamSupplier {
@Nonnull
@Throws(IOException::class)
fun openStream(): InputStream
}
class IntegrityCheck(
val encryptedDigest: ByteArray?,
val plaintextHash: ByteArray?
) {
init {
if (encryptedDigest == null && plaintextHash == null) {
throw IllegalArgumentException("At least one of encryptedDigest or plaintextHash must be provided")
}
}
companion object {
@JvmStatic
fun forEncryptedDigest(encryptedDigest: ByteArray): IntegrityCheck {
return IntegrityCheck(encryptedDigest, null)
}
@JvmStatic
fun forPlaintextHash(plaintextHash: ByteArray): IntegrityCheck {
return IntegrityCheck(null, plaintextHash)
}
@JvmStatic
fun forEncryptedDigestAndPlaintextHash(encryptedDigest: ByteArray?, plaintextHash: String?): IntegrityCheck {
val plaintextHashBytes = plaintextHash?.let { Base64.decode(it) }
return IntegrityCheck(encryptedDigest, plaintextHashBytes)
}
}
}
}

View file

@ -0,0 +1,89 @@
/*
* Copyright (C) 2014-2017 Open Whisper Systems
*
* Licensed according to the LICENSE file in this repository.
*/
package org.whispersystems.signalservice.api.crypto
import org.whispersystems.signalservice.internal.util.Util
import java.io.IOException
import java.io.OutputStream
import javax.crypto.BadPaddingException
import javax.crypto.Cipher
import javax.crypto.IllegalBlockSizeException
import javax.crypto.Mac
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.SecretKeySpec
/**
* An OutputStream for encrypting attachment data.
* The output stream writes the IV, ciphertext, and HMAC in sequence.
*
* @param combinedKeyMaterial The key material used for encryption and authentication. It is expected to be a byte array
* containing two parts: the first half being the AES key and the second half being the HMAC key.
* @param iv The initialization vector (IV) for the cipher, or null to generate a random one.
* @param outputStream The underlying output stream to write the encrypted data to.
*/
class AttachmentCipherOutputStream(
combinedKeyMaterial: ByteArray,
iv: ByteArray?,
outputStream: OutputStream
) : DigestingOutputStream(outputStream) {
private val cipher: Cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
private val mac: Mac = Mac.getInstance("HmacSHA256")
init {
val keyParts = Util.split(combinedKeyMaterial, 32, 32)
if (iv == null) {
cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(keyParts[0], "AES"))
} else {
cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(keyParts[0], "AES"), IvParameterSpec(iv))
}
mac.init(SecretKeySpec(keyParts[1], "HmacSHA256"))
mac.update(cipher.iv)
super.write(cipher.iv)
}
@Throws(IOException::class)
override fun write(buffer: ByteArray) {
write(buffer, 0, buffer.size)
}
@Throws(IOException::class)
override fun write(buffer: ByteArray, offset: Int, length: Int) {
val ciphertext = cipher.update(buffer, offset, length)
if (ciphertext != null) {
mac.update(ciphertext)
super.write(ciphertext)
}
}
@Throws(IOException::class)
override fun write(b: Int) {
val input = ByteArray(1)
input[0] = b.toByte()
write(input, 0, 1)
}
@Throws(IOException::class)
override fun close() {
try {
val ciphertext = cipher.doFinal()
val auth = mac.doFinal(ciphertext)
super.write(ciphertext)
super.write(auth)
super.close()
} catch (e: IllegalBlockSizeException) {
throw AssertionError(e)
} catch (e: BadPaddingException) {
throw AssertionError(e)
}
}
}

View file

@ -0,0 +1,26 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.signalservice.api.crypto
object AttachmentCipherStreamUtil {
/**
* Given the size of the plaintext, this will return the length of ciphertext output.
* @param inputSize Size of the plaintext fed into the stream. This does *not* automatically include padding. Add that yourself before calling if needed.
*/
@JvmStatic
fun getCiphertextLength(plaintextLength: Long): Long {
val ivLength: Long = 16
val macLength: Long = 32
val blockLength: Long = (plaintextLength / 16 + 1) * 16
return ivLength + macLength + blockLength
}
@JvmStatic
fun getPlaintextLength(ciphertextLength: Long): Long {
return ((ciphertextLength - 16 - 32) / 16 - 1) * 16
}
}

View file

@ -0,0 +1,133 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.signalservice.api.crypto
import java.io.FilterInputStream
import java.io.IOException
import java.io.InputStream
import javax.annotation.Nonnull
import javax.crypto.BadPaddingException
import javax.crypto.Cipher
import javax.crypto.IllegalBlockSizeException
import javax.crypto.ShortBufferException
import kotlin.math.min
/**
* This is similar to [javax.crypto.CipherInputStream], but it fixes various issues, including proper error propagation,
* and proper handling of boundary conditions.
*/
class BetterCipherInputStream(
inputStream: InputStream,
val cipher: Cipher
) : FilterInputStream(inputStream) {
private var done = false
private var overflowBuffer: ByteArray? = null
@Throws(IOException::class)
override fun read(): Int {
val buffer = ByteArray(1)
var read: Int = read(buffer)
while (read == 0) {
read = read(buffer)
}
if (read == -1) {
return read
}
return buffer[0].toInt() and 0xFF
}
@Throws(IOException::class)
override fun read(@Nonnull buffer: ByteArray): Int {
return read(buffer, 0, buffer.size)
}
@Throws(IOException::class)
override fun read(@Nonnull buffer: ByteArray, offset: Int, length: Int): Int {
return if (!done) {
readIncremental(buffer, offset, length)
} else {
-1
}
}
override fun markSupported(): Boolean = false
@Throws(IOException::class)
private fun readIncremental(outputBuffer: ByteArray, originalOffset: Int, originalLength: Int): Int {
var offset = originalOffset
var length = originalLength
var readLength = 0
overflowBuffer?.let { overflow ->
if (overflow.size > length) {
overflow.copyInto(destination = outputBuffer, destinationOffset = offset, endIndex = length)
overflowBuffer = overflow.copyOfRange(fromIndex = length, toIndex = overflow.size)
return length
} else if (overflow.size == length) {
overflow.copyInto(destination = outputBuffer, destinationOffset = offset)
overflowBuffer = null
return length
} else {
overflow.copyInto(destination = outputBuffer, destinationOffset = offset)
readLength += overflow.size
offset += readLength
length -= readLength
overflowBuffer = null
}
}
val ciphertextBuffer = ByteArray(length)
val ciphertextRead = super.read(ciphertextBuffer, 0, ciphertextBuffer.size)
if (ciphertextRead == -1) {
return readFinal(outputBuffer, offset, length)
}
try {
var plaintextLength = cipher.getOutputSize(ciphertextRead)
if (plaintextLength <= length) {
readLength += cipher.update(ciphertextBuffer, 0, ciphertextRead, outputBuffer, offset)
return readLength
}
val plaintextBuffer = ByteArray(plaintextLength)
plaintextLength = cipher.update(ciphertextBuffer, 0, ciphertextRead, plaintextBuffer, 0)
if (plaintextLength <= length) {
plaintextBuffer.copyInto(destination = outputBuffer, destinationOffset = offset, endIndex = plaintextLength)
readLength += plaintextLength
} else {
plaintextBuffer.copyInto(destination = outputBuffer, destinationOffset = offset, endIndex = length)
overflowBuffer = plaintextBuffer.copyOfRange(fromIndex = length, toIndex = plaintextLength)
readLength += length
}
return readLength
} catch (e: ShortBufferException) {
throw AssertionError(e)
}
}
@Throws(IOException::class)
private fun readFinal(buffer: ByteArray, offset: Int, length: Int): Int {
try {
val internal = ByteArray(buffer.size)
val actualLength = min(length, cipher.doFinal(internal, 0))
internal.copyInto(destination = buffer, destinationOffset = offset, endIndex = actualLength)
done = true
return actualLength
} catch (e: IllegalBlockSizeException) {
throw IOException(e)
} catch (e: BadPaddingException) {
throw IOException(e)
} catch (e: ShortBufferException) {
throw IOException(e)
}
}
}

View file

@ -0,0 +1,38 @@
package org.whispersystems.signalservice.api.crypto;
import org.signal.libsignal.metadata.protocol.UnidentifiedSenderMessageContent;
import java.util.HashMap;
import java.util.Map;
public enum ContentHint {
/** This message has content, but you shouldn't expect it to be re-sent to you. */
DEFAULT(UnidentifiedSenderMessageContent.CONTENT_HINT_DEFAULT),
/** You should expect to be able to have this content be re-sent to you. */
RESENDABLE(UnidentifiedSenderMessageContent.CONTENT_HINT_RESENDABLE),
/** This message has no real content and likely cannot be re-sent to you. */
IMPLICIT(UnidentifiedSenderMessageContent.CONTENT_HINT_IMPLICIT);
private static final Map<Integer, ContentHint> TYPE_MAP = new HashMap<>();
static {
for (ContentHint value : values()) {
TYPE_MAP.put(value.getType(), value);
}
}
private final int type;
ContentHint(int type) {
this.type = type;
}
public int getType() {
return type;
}
public static ContentHint fromType(int type) {
return TYPE_MAP.getOrDefault(type, DEFAULT);
}
}

View file

@ -0,0 +1,18 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.signalservice.api.crypto
import org.signal.libsignal.protocol.kdf.HKDF
/**
* A collection of cryptographic functions in the same namespace for easy access.
*/
object Crypto {
fun hkdf(inputKeyMaterial: ByteArray, info: ByteArray, outputLength: Int, salt: ByteArray? = null): ByteArray {
return HKDF.deriveSecrets(inputKeyMaterial, salt ?: byteArrayOf(), info, outputLength)
}
}

View file

@ -0,0 +1,76 @@
/*
* Copyright (C) 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.signalservice.api.crypto
import org.signal.libsignal.protocol.InvalidMessageException
import java.io.FilterInputStream
import java.io.IOException
import java.io.InputStream
import java.security.MessageDigest
/**
* An InputStream that enforces hash validation by calculating a digest as data is read
* and verifying it against an expected hash when the stream is fully consumed.
*
* Important: The validation only occurs if you read the entire stream.
*
* @param inputStream The underlying InputStream to read from
* @param digest The MessageDigest instance to use for hash calculation
* @param expectedHash The expected hash value to validate against
*/
class DigestValidatingInputStream(
inputStream: InputStream,
private val digest: MessageDigest,
private val expectedHash: ByteArray
) : FilterInputStream(inputStream) {
var validationAttempted = false
private set
@Throws(IOException::class)
override fun read(): Int {
val byte = super.read()
if (byte != -1) {
digest.update(byte.toByte())
} else {
validateDigest()
}
return byte
}
@Throws(IOException::class)
override fun read(buffer: ByteArray): Int {
return read(buffer, 0, buffer.size)
}
@Throws(IOException::class)
override fun read(buffer: ByteArray, offset: Int, length: Int): Int {
val bytesRead = super.read(buffer, offset, length)
if (bytesRead > 0) {
digest.update(buffer, offset, bytesRead)
} else if (bytesRead == -1) {
validateDigest()
}
return bytesRead
}
/**
* Validates the calculated digest against the expected hash.
* Throws InvalidCiphertextException if they don't match.
*/
@Throws(InvalidMessageException::class)
private fun validateDigest() {
if (validationAttempted) {
return
}
validationAttempted = true
val calculatedHash = digest.digest()
if (!MessageDigest.isEqual(calculatedHash, expectedHash)) {
throw InvalidMessageException("Calculated digest does not match expected hash!")
}
}
}

View file

@ -0,0 +1,58 @@
package org.whispersystems.signalservice.api.crypto;
import java.io.FilterOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
public abstract class DigestingOutputStream extends FilterOutputStream {
private final MessageDigest runningDigest;
private byte[] digest;
private long totalBytesWritten = 0;
public DigestingOutputStream(OutputStream outputStream) {
super(outputStream);
try {
this.runningDigest = MessageDigest.getInstance("SHA-256");
} catch (NoSuchAlgorithmException e) {
throw new AssertionError(e);
}
}
@Override
public void write(byte[] buffer) throws IOException {
runningDigest.update(buffer, 0, buffer.length);
out.write(buffer, 0, buffer.length);
totalBytesWritten += buffer.length;
}
public void write(byte[] buffer, int offset, int length) throws IOException {
runningDigest.update(buffer, offset, length);
out.write(buffer, offset, length);
totalBytesWritten += length;
}
public void write(int b) throws IOException {
runningDigest.update((byte)b);
out.write(b);
totalBytesWritten++;
}
public void close() throws IOException {
digest = runningDigest.digest();
out.close();
}
public byte[] getTransmittedDigest() {
return digest;
}
public long getTotalBytesWritten() {
return totalBytesWritten;
}
}

View file

@ -0,0 +1,173 @@
package org.whispersystems.signalservice.api.crypto;
import org.signal.libsignal.metadata.certificate.SenderCertificate;
import org.signal.libsignal.metadata.protocol.UnidentifiedSenderMessageContent;
import org.signal.libsignal.protocol.InvalidKeyException;
import org.signal.libsignal.protocol.NoSessionException;
import org.signal.libsignal.protocol.SignalProtocolAddress;
import org.signal.libsignal.protocol.UntrustedIdentityException;
import org.signal.libsignal.protocol.message.CiphertextMessage;
import org.signal.libsignal.protocol.message.DecryptionErrorMessage;
import org.signal.libsignal.protocol.message.PlaintextContent;
import org.whispersystems.signalservice.internal.push.Content;
import org.whispersystems.signalservice.internal.push.Envelope.Type;
import org.whispersystems.signalservice.internal.push.OutgoingPushMessage;
import org.whispersystems.signalservice.internal.push.PushTransportDetails;
import org.signal.core.util.Base64;
import java.util.Optional;
/**
* An abstraction over the different types of message contents we can have.
*/
public interface EnvelopeContent {
/**
* Processes the content using sealed sender.
*/
OutgoingPushMessage processSealedSender(SignalSessionCipher sessionCipher,
SignalSealedSessionCipher sealedSessionCipher,
SignalProtocolAddress destination,
SenderCertificate senderCertificate)
throws UntrustedIdentityException, InvalidKeyException, NoSessionException;
/**
* Processes the content using unsealed sender.
*/
OutgoingPushMessage processUnsealedSender(SignalSessionCipher sessionCipher, SignalProtocolAddress destination) throws UntrustedIdentityException, NoSessionException;
/**
* An estimated size, in bytes.
*/
int size();
/**
* A content proto, if applicable.
*/
Optional<Content> getContent();
/**
* Wrap {@link Content} you plan on sending as an encrypted message.
* This is the default. Consider anything else exceptional.
*/
static EnvelopeContent encrypted(Content content, ContentHint contentHint, Optional<byte[]> groupId) {
return new Encrypted(content, contentHint, groupId);
}
/**
* Wraps a {@link PlaintextContent}. This is exceptional, currently limited only to {@link DecryptionErrorMessage}.
*/
static EnvelopeContent plaintext(PlaintextContent content, Optional<byte[]> groupId) {
return new Plaintext(content, groupId);
}
class Encrypted implements EnvelopeContent {
private final Content content;
private final ContentHint contentHint;
private final Optional<byte[]> groupId;
public Encrypted(Content content, ContentHint contentHint, Optional<byte[]> groupId) {
this.content = content;
this.contentHint = contentHint;
this.groupId = groupId;
}
@Override
public OutgoingPushMessage processSealedSender(SignalSessionCipher sessionCipher,
SignalSealedSessionCipher sealedSessionCipher,
SignalProtocolAddress destination,
SenderCertificate senderCertificate)
throws UntrustedIdentityException, InvalidKeyException, NoSessionException
{
PushTransportDetails transportDetails = new PushTransportDetails();
CiphertextMessage message = sessionCipher.encrypt(transportDetails.getPaddedMessageBody(content.encode()));
UnidentifiedSenderMessageContent messageContent = new UnidentifiedSenderMessageContent(message,
senderCertificate,
contentHint.getType(),
groupId);
byte[] ciphertext = sealedSessionCipher.encrypt(destination, messageContent);
String body = Base64.encodeWithPadding(ciphertext);
int remoteRegistrationId = sealedSessionCipher.getRemoteRegistrationId(destination);
return new OutgoingPushMessage(Type.UNIDENTIFIED_SENDER.getValue(), destination.getDeviceId(), remoteRegistrationId, body);
}
@Override
public OutgoingPushMessage processUnsealedSender(SignalSessionCipher sessionCipher, SignalProtocolAddress destination) throws UntrustedIdentityException, NoSessionException {
PushTransportDetails transportDetails = new PushTransportDetails();
CiphertextMessage message = sessionCipher.encrypt(transportDetails.getPaddedMessageBody(content.encode()));
int remoteRegistrationId = sessionCipher.getRemoteRegistrationId();
String body = Base64.encodeWithPadding(message.serialize());
int type;
switch (message.getType()) {
case CiphertextMessage.PREKEY_TYPE: type = Type.PREKEY_BUNDLE.getValue(); break;
case CiphertextMessage.WHISPER_TYPE: type = Type.CIPHERTEXT.getValue(); break;
default: throw new AssertionError("Bad type: " + message.getType());
}
return new OutgoingPushMessage(type, destination.getDeviceId(), remoteRegistrationId, body);
}
@Override
public int size() {
return Content.ADAPTER.encodedSize(content);
}
@Override
public Optional<Content> getContent() {
return Optional.of(content);
}
}
class Plaintext implements EnvelopeContent {
private final PlaintextContent plaintextContent;
private final Optional<byte[]> groupId;
public Plaintext(PlaintextContent plaintextContent, Optional<byte[]> groupId) {
this.plaintextContent = plaintextContent;
this.groupId = groupId;
}
@Override
public OutgoingPushMessage processSealedSender(SignalSessionCipher sessionCipher,
SignalSealedSessionCipher sealedSessionCipher,
SignalProtocolAddress destination,
SenderCertificate senderCertificate)
throws UntrustedIdentityException, InvalidKeyException
{
UnidentifiedSenderMessageContent messageContent = new UnidentifiedSenderMessageContent(plaintextContent,
senderCertificate,
ContentHint.IMPLICIT.getType(),
groupId);
byte[] ciphertext = sealedSessionCipher.encrypt(destination, messageContent);
String body = Base64.encodeWithPadding(ciphertext);
int remoteRegistrationId = sealedSessionCipher.getRemoteRegistrationId(destination);
return new OutgoingPushMessage(Type.UNIDENTIFIED_SENDER.getValue(), destination.getDeviceId(), remoteRegistrationId, body);
}
@Override
public OutgoingPushMessage processUnsealedSender(SignalSessionCipher sessionCipher, SignalProtocolAddress destination) {
String body = Base64.encodeWithPadding(plaintextContent.serialize());
int remoteRegistrationId = sessionCipher.getRemoteRegistrationId();
return new OutgoingPushMessage(Type.PLAINTEXT_CONTENT.getValue(), destination.getDeviceId(), remoteRegistrationId, body);
}
@Override
public int size() {
return plaintextContent.getBody().length;
}
@Override
public Optional<Content> getContent() {
return Optional.empty();
}
}
}

View file

@ -0,0 +1,12 @@
package org.whispersystems.signalservice.api.crypto
import org.whispersystems.signalservice.api.push.ServiceId
class EnvelopeMetadata(
val sourceServiceId: ServiceId,
val sourceE164: String?,
val sourceDeviceId: Int,
val sealedSender: Boolean,
val groupId: ByteArray?,
val destinationServiceId: ServiceId
)

View file

@ -0,0 +1,69 @@
package org.whispersystems.signalservice.api.crypto;
import org.whispersystems.util.StringUtil;
import java.util.Arrays;
import static org.signal.core.util.CryptoUtil.hmacSha256;
import static org.whispersystems.util.ByteArrayUtil.concat;
import static org.whispersystems.util.ByteArrayUtil.xor;
import static java.util.Arrays.copyOfRange;
/**
* Encrypts or decrypts with a Synthetic IV.
* <p>
* Normal Java casing has been ignored to match original specifications.
*/
public final class HmacSIV {
private static final byte[] AUTH_BYTES = StringUtil.utf8("auth");
private static final byte[] ENC_BYTES = StringUtil.utf8("enc");
/**
* Encrypts M with K.
*
* @param K Key
* @param M 32-byte Key to encrypt
* @return (IV, C) 48-bytes: 16-byte Synthetic IV and 32-byte Ciphertext.
*/
public static byte[] encrypt(byte[] K, byte[] M) {
if (K.length != 32) throw new AssertionError("K was wrong length");
if (M.length != 32) throw new AssertionError("M was wrong length");
byte[] Ka = hmacSha256(K, AUTH_BYTES);
byte[] Ke = hmacSha256(K, ENC_BYTES);
byte[] IV = copyOfRange(hmacSha256(Ka, M), 0, 16);
byte[] Kx = hmacSha256(Ke, IV);
byte[] C = xor(Kx, M);
return concat(IV, C);
}
/**
* Decrypts M from (IV, C) with K.
*
* @param K Key
* @param IVC Output from {@link #encrypt(byte[], byte[])}
* @return 32-byte M
* @throws InvalidCiphertextException if the supplied IVC was not correct.
*/
public static byte[] decrypt(byte[] K, byte[] IVC) throws InvalidCiphertextException {
if (K.length != 32) throw new AssertionError("K was wrong length");
if (IVC.length != 48) throw new InvalidCiphertextException("IVC was wrong length");
byte[] IV = copyOfRange(IVC, 0, 16);
byte[] C = copyOfRange(IVC, 16, 48);
byte[] Ka = hmacSha256(K, AUTH_BYTES);
byte[] Ke = hmacSha256(K, ENC_BYTES);
byte[] Kx = hmacSha256(Ke, IV);
byte[] M = xor(Kx, C);
byte[] eExpectedIV = copyOfRange(hmacSha256(Ka, M), 0, 16);
if (Arrays.equals(IV, eExpectedIV)) {
return M;
} else {
throw new InvalidCiphertextException("IV was incorrect");
}
}
}

View file

@ -0,0 +1,113 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.signalservice.api.crypto
import org.signal.libsignal.protocol.InvalidMessageException
import org.whispersystems.signalservice.internal.util.Util
import java.io.FilterInputStream
import java.io.InputStream
import java.security.MessageDigest
import javax.crypto.Mac
import kotlin.math.max
/**
* This is meant as a helper stream to go along with [org.signal.libsignal.protocol.incrementalmac.IncrementalMacInputStream].
* That class does not validate the overall digest, nor the overall MAC. This class does that for us.
*
* To use, wrap the IncrementalMacInputStream around this class, and then this class should wrap the lowest-level data stream.
*/
class IncrementalMacAdditionalValidationsInputStream(
wrapped: InputStream,
fileLength: Long,
private val mac: Mac
) : FilterInputStream(wrapped) {
private val macLength: Int = mac.macLength
private val macBuffer: ByteArray = ByteArray(macLength)
private var validated = false
private var bytesRemaining: Int = fileLength.toInt()
private var macBufferPosition: Int = 0
override fun read(): Int {
throw UnsupportedOperationException()
}
/**
* We need to be very careful to keep track of what data is part of the MAC and what isn't, based on how far we've read into the file.
* As a recap, the digest needs to ingest the entire file, while the MAC needs to ingest everything except the last [macLength] bytes.
* (Because the last [macLength] bytes represents the MAC we're going to verify against.)
*
* The wrapping stream may request the full length of the file, so we need to do some bookkeeping to remember the last [macLength] bytes
* for comparison purposes during [validate] while not ingesting them into the MAC that we're calculating.
*/
override fun read(buffer: ByteArray, offset: Int, length: Int): Int {
val bytesRead = super.read(buffer, offset, length)
if (bytesRead == -1) {
validate()
return bytesRead
}
bytesRemaining -= bytesRead
// This indicates we've read into the last [macLength] bytes of the file, so we need to start our bookkeeping
if (bytesRemaining < macLength) {
val bytesOfMacRead = macLength - bytesRemaining
val newBytesOfMacRead = bytesOfMacRead - macBufferPosition
// There's a possibility that the reader has only partially read the last [macLength] bytes, so we need to keep track of a position in our
// MAC buffer and copy over just the new parts we've read
if (newBytesOfMacRead > 0) {
System.arraycopy(buffer, offset + bytesRead - newBytesOfMacRead, macBuffer, macBufferPosition, newBytesOfMacRead)
macBufferPosition += newBytesOfMacRead
}
// Even though we're reading into the MAC, many of the bytes read in this method call could be non-MAC bytes, so we need to copy
// those over, while excluding the bytes that are part of the MAC.
val bytesOfNonMacRead = max(0, bytesRead - bytesOfMacRead)
if (bytesOfNonMacRead > 0) {
mac.update(buffer, offset, bytesOfNonMacRead)
}
} else {
mac.update(buffer, offset, bytesRead)
}
if (bytesRemaining == 0) {
validate()
}
return bytesRead
}
override fun close() {
// We only want to validate the digest if we've otherwise read the entire stream.
// It's valid to close the stream early, and in this case, we don't want to force reading the whole rest of the stream.
if (bytesRemaining > macLength) {
super.close()
return
}
if (bytesRemaining > 0) {
Util.readFullyAsBytes(this)
}
super.close()
}
private fun validate() {
if (validated) {
return
}
validated = true
val ourMac = mac.doFinal()
val theirMac = macBuffer
if (!MessageDigest.isEqual(ourMac, theirMac)) {
throw InvalidMessageException("MAC doesn't match!")
}
}
}

View file

@ -0,0 +1,11 @@
package org.whispersystems.signalservice.api.crypto;
public class InvalidCiphertextException extends Exception {
public InvalidCiphertextException(Exception nested) {
super(nested);
}
public InvalidCiphertextException(String s) {
super(s);
}
}

View file

@ -0,0 +1,140 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.signalservice.api.crypto
import org.jetbrains.annotations.VisibleForTesting
import org.signal.core.util.stream.LimitedInputStream
import org.signal.core.util.stream.TrimmingInputStream
import org.signal.libsignal.protocol.InvalidMessageException
import java.io.FilterInputStream
import java.io.IOException
import java.io.InputStream
import java.security.MessageDigest
import javax.crypto.Mac
/**
* An InputStream that validates a MAC appended to the end of the stream data.
* This stream will not exclude the MAC from the data it reads, meaning that you may want to pair this with a [LimitedInputStream] or a [TrimmingInputStream]
* if you don't want to read that data to be a part of it.
*
* Important: The MAC is only validated once the stream has been fully read.
*
* @param inputStream The underlying InputStream to read from
* @param mac The Mac instance to use for validation
*/
class MacValidatingInputStream(
inputStream: InputStream,
private val mac: Mac
) : FilterInputStream(inputStream) {
private val macBuffer = ByteArray(mac.macLength)
private val macLength = mac.macLength
private var macBufferPosition = 0
private var streamEnded = false
@VisibleForTesting
var validationAttempted = false
private set
@Throws(IOException::class)
override fun read(): Int {
val singleByteBuffer = ByteArray(1)
val bytesRead = read(singleByteBuffer, 0, 1)
return if (bytesRead == -1) -1 else singleByteBuffer[0].toInt() and 0xFF
}
@Throws(IOException::class)
override fun read(b: ByteArray): Int {
return read(b, 0, b.size)
}
@Throws(IOException::class)
override fun read(outputBuffer: ByteArray, outputOffset: Int, readLength: Int): Int {
if (streamEnded) {
return -1
}
val bytesRead = super.read(outputBuffer, outputOffset, readLength)
if (bytesRead == -1) {
// End of stream - check if we have enough data for MAC validation
if (macBufferPosition < macLength) {
throw InvalidMessageException("Stream ended before MAC could be read. Expected $macLength bytes, got $macBufferPosition")
}
validateMacAndMarkStreamEnded()
return -1
}
// If we've read more than `macLength` bytes, we can just snag the last `macLength` bytes and digest the rest
if (bytesRead >= macLength) {
// Before replacing the macBuffer, process any pre-existing data
if (macBufferPosition > 0) {
mac.update(macBuffer, 0, macBufferPosition)
macBufferPosition = 0
}
// Copy the last `macLength` bytes into the macBuffer
outputBuffer.copyInto(destination = macBuffer, destinationOffset = 0, startIndex = outputOffset + bytesRead - macLength, endIndex = outputOffset + bytesRead)
macBufferPosition = macLength
// Update the mac with the bytes that are not part of the MAC
if (bytesRead > macLength) {
mac.update(outputBuffer, outputOffset, bytesRead - macLength)
}
} else {
val totalBytesAvailable = macBufferPosition + bytesRead
// If the new bytes we've read don't overflow the buffer, we can just append them, and none of them will be digested
if (totalBytesAvailable <= macLength) {
outputBuffer.copyInto(destination = macBuffer, destinationOffset = macBufferPosition, startIndex = outputOffset, endIndex = outputOffset + bytesRead)
macBufferPosition = totalBytesAvailable
} else {
// If we have more bytes than we can hold in the buffer, keep the last `macLength` bytes and digest the rest
// We know that `bytesRead` is less than `macLength`, so we know all of `bytesRead` should go into the buffer
// And we know that the buffer usage + `bytesRead` is greater than `macLength`, so we're guaranteed to be able to digest the first chunk of the buffer.
// We also know that there can't possibly be 0 bytes in the buffer because of how the math of those conditions works out.
val bytesToDigest = totalBytesAvailable - macLength
val bytesOfBufferToDigest = minOf(macBufferPosition, bytesToDigest)
val bytesOfReadToDigest = bytesToDigest - bytesOfBufferToDigest
mac.update(macBuffer, 0, bytesOfBufferToDigest)
macBuffer.copyInto(destination = macBuffer, destinationOffset = 0, startIndex = bytesOfBufferToDigest, endIndex = macBufferPosition)
macBufferPosition -= bytesOfBufferToDigest
if (bytesOfReadToDigest > 0) {
mac.update(outputBuffer, outputOffset, bytesOfReadToDigest)
}
val bytesOfReadRemaining = bytesRead - bytesOfReadToDigest
if (bytesOfReadRemaining > 0) {
outputBuffer.copyInto(destination = macBuffer, destinationOffset = macBufferPosition, startIndex = outputOffset + bytesOfReadToDigest, endIndex = outputOffset + bytesRead)
macBufferPosition += bytesOfReadRemaining
}
}
}
return bytesRead
}
@Throws(InvalidMessageException::class)
private fun validateMacAndMarkStreamEnded() {
if (validationAttempted) {
return
}
validationAttempted = true
streamEnded = true
val calculatedMac = mac.doFinal()
if (!MessageDigest.isEqual(calculatedMac, macBuffer)) {
throw InvalidMessageException("MAC validation failed!")
}
}
private fun minOf(a: Int, b: Int): Int = if (a < b) a else b
}

View file

@ -0,0 +1,13 @@
package org.whispersystems.signalservice.api.crypto;
import java.io.OutputStream;
/**
* Use when the stream is already encrypted.
*/
public final class NoCipherOutputStream extends DigestingOutputStream {
public NoCipherOutputStream(OutputStream outputStream) {
super(outputStream);
}
}

View file

@ -0,0 +1,224 @@
package org.whispersystems.signalservice.api.crypto;
import org.signal.libsignal.protocol.util.ByteUtil;
import org.signal.libsignal.zkgroup.profiles.ProfileKey;
import org.whispersystems.signalservice.internal.util.Util;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.charset.StandardCharsets;
import java.security.InvalidAlgorithmParameterException;
import java.security.InvalidKeyException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Locale;
import java.util.Optional;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import javax.crypto.BadPaddingException;
import javax.crypto.Cipher;
import javax.crypto.IllegalBlockSizeException;
import javax.crypto.Mac;
import javax.crypto.NoSuchPaddingException;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
public class ProfileCipher {
private static final int NAME_PADDED_LENGTH_1 = 53;
private static final int NAME_PADDED_LENGTH_2 = 257;
private static final int ABOUT_PADDED_LENGTH_1 = 128;
private static final int ABOUT_PADDED_LENGTH_2 = 254;
private static final int ABOUT_PADDED_LENGTH_3 = 512;
public static final int MAX_POSSIBLE_NAME_LENGTH = NAME_PADDED_LENGTH_2;
public static final int MAX_POSSIBLE_ABOUT_LENGTH = ABOUT_PADDED_LENGTH_3;
public static final int EMOJI_PADDED_LENGTH = 32;
public static final int ENCRYPTION_OVERHEAD = 28;
public static final int PAYMENTS_ADDRESS_BASE64_FIELD_SIZE = 776;
public static final int PAYMENTS_ADDRESS_CONTENT_SIZE = PAYMENTS_ADDRESS_BASE64_FIELD_SIZE * 6 / 8 - ProfileCipher.ENCRYPTION_OVERHEAD;
private final ProfileKey key;
public ProfileCipher(ProfileKey key) {
this.key = key;
}
/**
* Encrypts an input and ensures padded length.
* <p>
* Padded length does not include {@link #ENCRYPTION_OVERHEAD}.
*/
public byte[] encrypt(byte[] input, int paddedLength) {
try {
byte[] inputPadded = new byte[paddedLength];
if (input.length > inputPadded.length) {
throw new IllegalArgumentException("Input is too long: " + new String(input));
}
System.arraycopy(input, 0, inputPadded, 0, input.length);
byte[] nonce = Util.getSecretBytes(12);
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(key.serialize(), "AES"), new GCMParameterSpec(128, nonce));
byte[] encryptedPadded = ByteUtil.combine(nonce, cipher.doFinal(inputPadded));
if (encryptedPadded.length != (paddedLength + ENCRYPTION_OVERHEAD)) {
throw new AssertionError(String.format(Locale.US, "Wrong output length %d != padded length %d + %d", encryptedPadded.length, paddedLength, ENCRYPTION_OVERHEAD));
}
return encryptedPadded;
} catch (NoSuchAlgorithmException | InvalidAlgorithmParameterException | BadPaddingException | NoSuchPaddingException | IllegalBlockSizeException | InvalidKeyException e) {
throw new AssertionError(e);
}
}
/**
* Returns original data with padding still intact.
*/
public byte[] decrypt(byte[] input) throws InvalidCiphertextException {
try {
if (input.length < 12 + 16 + 1) {
throw new InvalidCiphertextException("Too short: " + input.length);
}
byte[] nonce = new byte[12];
System.arraycopy(input, 0, nonce, 0, nonce.length);
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(key.serialize(), "AES"), new GCMParameterSpec(128, nonce));
return cipher.doFinal(input, nonce.length, input.length - nonce.length);
} catch (NoSuchAlgorithmException | InvalidAlgorithmParameterException | NoSuchPaddingException | IllegalBlockSizeException e) {
throw new AssertionError(e);
} catch (InvalidKeyException | BadPaddingException e) {
throw new InvalidCiphertextException(e);
}
}
/**
* Encrypts a string's UTF bytes representation.
*/
public byte[] encryptString(@Nonnull String input, int paddedLength) {
return encrypt(input.getBytes(StandardCharsets.UTF_8), paddedLength);
}
/**
* Strips 0 char padding from decrypt result.
*/
public String decryptString(byte[] input) throws InvalidCiphertextException {
byte[] paddedPlaintext = decrypt(input);
int plaintextLength = 0;
for (int i = paddedPlaintext.length - 1; i >= 0; i--) {
if (paddedPlaintext[i] != (byte) 0x00) {
plaintextLength = i + 1;
break;
}
}
byte[] plaintext = new byte[plaintextLength];
System.arraycopy(paddedPlaintext, 0, plaintext, 0, plaintextLength);
return new String(plaintext);
}
public byte[] encryptBoolean(boolean input) {
byte[] value = new byte[1];
value[0] = (byte) (input ? 1 : 0);
return encrypt(value, value.length);
}
public Optional<Boolean> decryptBoolean(@Nullable byte[] input) throws InvalidCiphertextException {
if (input == null) {
return Optional.empty();
}
byte[] paddedPlaintext = decrypt(input);
return Optional.of(paddedPlaintext[0] != 0);
}
/**
* Encodes the length, and adds padding.
* <p>
* encrypt(input.length | input | padding)
* <p>
* Padded length does not include 28 bytes encryption overhead.
*/
public byte[] encryptWithLength(byte[] input, int paddedLength) {
ByteBuffer content = ByteBuffer.wrap(new byte[input.length + 4]);
content.order(ByteOrder.LITTLE_ENDIAN);
content.putInt(input.length);
content.put(input);
return encrypt(content.array(), paddedLength);
}
/**
* Extracts result from:
* <p>
* decrypt(encrypt(result.length | result | padding))
*/
public byte[] decryptWithLength(byte[] input) throws InvalidCiphertextException, IOException {
byte[] decrypted = decrypt(input);
int maxLength = decrypted.length - 4;
ByteBuffer content = ByteBuffer.wrap(decrypted);
content.order(ByteOrder.LITTLE_ENDIAN);
int contentLength = content.getInt();
if (contentLength > maxLength) {
throw new IOException("Encoded length exceeds content length");
}
if (contentLength < 0) {
throw new IOException("Encoded length is less than 0");
}
byte[] result = new byte[contentLength];
content.get(result);
return result;
}
public boolean verifyUnidentifiedAccess(byte[] theirUnidentifiedAccessVerifier) {
try {
if (theirUnidentifiedAccessVerifier == null || theirUnidentifiedAccessVerifier.length == 0) return false;
byte[] unidentifiedAccessKey = UnidentifiedAccess.deriveAccessKeyFrom(key);
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(unidentifiedAccessKey, "HmacSHA256"));
byte[] ourUnidentifiedAccessVerifier = mac.doFinal(new byte[32]);
return MessageDigest.isEqual(theirUnidentifiedAccessVerifier, ourUnidentifiedAccessVerifier);
} catch (NoSuchAlgorithmException | InvalidKeyException e) {
throw new AssertionError(e);
}
}
public static int getTargetNameLength(String name) {
int nameLength = name.getBytes(StandardCharsets.UTF_8).length;
if (nameLength <= NAME_PADDED_LENGTH_1) {
return NAME_PADDED_LENGTH_1;
} else {
return NAME_PADDED_LENGTH_2;
}
}
public static int getTargetAboutLength(String about) {
int aboutLength = about.getBytes(StandardCharsets.UTF_8).length;
if (aboutLength <= ABOUT_PADDED_LENGTH_1) {
return ABOUT_PADDED_LENGTH_1;
} else if (aboutLength < ABOUT_PADDED_LENGTH_2){
return ABOUT_PADDED_LENGTH_2;
} else {
return ABOUT_PADDED_LENGTH_3;
}
}
}

View file

@ -0,0 +1,91 @@
package org.whispersystems.signalservice.api.crypto;
import org.signal.libsignal.crypto.Aes256GcmDecryption;
import org.signal.libsignal.protocol.InvalidKeyException;
import org.signal.libsignal.zkgroup.profiles.ProfileKey;
import org.whispersystems.signalservice.internal.util.Util;
import java.io.FilterInputStream;
import java.io.IOException;
import java.io.InputStream;
import static org.signal.libsignal.crypto.Aes256GcmDecryption.TAG_SIZE_IN_BYTES;
public class ProfileCipherInputStream extends FilterInputStream {
private Aes256GcmDecryption aes;
// The buffer size must match the length of the authentication tag.
private byte[] buffer = new byte[TAG_SIZE_IN_BYTES];
private byte[] swapBuffer = new byte[TAG_SIZE_IN_BYTES];
public ProfileCipherInputStream(InputStream in, ProfileKey key) throws IOException {
super(in);
try {
byte[] nonce = new byte[12];
Util.readFully(in, nonce);
Util.readFully(in, buffer);
this.aes = new Aes256GcmDecryption(key.serialize(), nonce, new byte[] {});
} catch (InvalidKeyException e) {
throw new IOException(e);
}
}
@Override
public int read() {
throw new AssertionError("Not supported!");
}
@Override
public int read(byte[] input) throws IOException {
return read(input, 0, input.length);
}
@Override
public int read(byte[] output, int outputOffset, int outputLength) throws IOException {
if (aes == null) return -1;
int read = in.read(output, outputOffset, outputLength);
if (read == -1) {
// We're done. The buffer has the final tag for authentication.
Aes256GcmDecryption aes = this.aes;
this.aes = null;
if (!aes.verifyTag(this.buffer)) {
throw new IOException("authentication of decrypted data failed");
}
return -1;
}
if (read < TAG_SIZE_IN_BYTES) {
// swapBuffer = buffer[read..] + output[offset..][..read]
// output[offset..][..read] = buffer[..read]
System.arraycopy(this.buffer, read, this.swapBuffer, 0, TAG_SIZE_IN_BYTES - read);
System.arraycopy(output, outputOffset, this.swapBuffer, TAG_SIZE_IN_BYTES - read, read);
System.arraycopy(this.buffer, 0, output, outputOffset, read);
} else if (read == TAG_SIZE_IN_BYTES) {
// swapBuffer = output[offset..][..read]
// output[offset..][..read] = buffer
System.arraycopy(output, outputOffset, this.swapBuffer, 0, read);
System.arraycopy(this.buffer, 0, output, outputOffset, read);
} else {
// swapBuffer = output[offset..][(read - TAG_SIZE)..read]
// output[offset..][TAG_SIZE..read] = output[offset..][..(read - TAG_SIZE)]
// output[offset..][..TAG_SIZE] = buffer
System.arraycopy(output, outputOffset + read - TAG_SIZE_IN_BYTES, this.swapBuffer, 0, TAG_SIZE_IN_BYTES);
System.arraycopy(output, outputOffset, output, outputOffset + TAG_SIZE_IN_BYTES, read - TAG_SIZE_IN_BYTES);
System.arraycopy(this.buffer, 0, output, outputOffset, TAG_SIZE_IN_BYTES);
}
// Now swapBuffer has the buffer for next time.
byte[] temp = this.buffer;
this.buffer = this.swapBuffer;
this.swapBuffer = temp;
aes.decrypt(output, outputOffset, read);
return read;
}
}

View file

@ -0,0 +1,80 @@
package org.whispersystems.signalservice.api.crypto;
import org.signal.libsignal.zkgroup.profiles.ProfileKey;
import java.io.IOException;
import java.io.OutputStream;
import java.security.InvalidAlgorithmParameterException;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import javax.crypto.BadPaddingException;
import javax.crypto.Cipher;
import javax.crypto.IllegalBlockSizeException;
import javax.crypto.NoSuchPaddingException;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
public class ProfileCipherOutputStream extends DigestingOutputStream {
private final Cipher cipher;
public ProfileCipherOutputStream(OutputStream out, ProfileKey key) throws IOException {
super(out);
try {
this.cipher = Cipher.getInstance("AES/GCM/NoPadding");
byte[] nonce = generateNonce();
this.cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(key.serialize(), "AES"), new GCMParameterSpec(128, nonce));
super.write(nonce, 0, nonce.length);
} catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidAlgorithmParameterException e) {
throw new AssertionError(e);
} catch (InvalidKeyException e) {
throw new IOException(e);
}
}
@Override
public void write(byte[] buffer) throws IOException {
write(buffer, 0, buffer.length);
}
@Override
public void write(byte[] buffer, int offset, int length) throws IOException {
byte[] output = cipher.update(buffer, offset, length);
super.write(output);
}
@Override
public void write(int b) throws IOException {
byte[] input = new byte[1];
input[0] = (byte)b;
byte[] output = cipher.update(input);
super.write(output);
}
@Override
public void close() throws IOException {
try {
byte[] output = cipher.doFinal();
super.write(output);
super.close();
} catch (BadPaddingException | IllegalBlockSizeException e) {
throw new AssertionError(e);
}
}
private byte[] generateNonce() {
byte[] nonce = new byte[12];
new SecureRandom().nextBytes(nonce);
return nonce;
}
public static long getCiphertextLength(long plaintextLength) {
return 12 + 16 + plaintextLength;
}
}

View file

@ -0,0 +1,178 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.signalservice.api.crypto
import org.signal.core.util.Base64
import org.signal.libsignal.metadata.certificate.SenderCertificate
import org.signal.libsignal.zkgroup.groupsend.GroupSendFullToken
import org.whispersystems.signalservice.api.groupsv2.GroupSendEndorsements
/**
* Provides single interface for the various ways to send via sealed sender.
*/
sealed class SealedSenderAccess {
abstract val senderCertificate: SenderCertificate
abstract val headerName: String
abstract val headerValue: String
val header: String
get() = "$headerName:$headerValue"
abstract fun switchToFallback(): SealedSenderAccess?
open fun applyHeader(): Boolean = true
/**
* For sending to an single recipient using group send endorsement/token first and then fallback to
* access key if available.
*/
class IndividualGroupSendTokenFirst(
private val groupSendToken: GroupSendFullToken,
override val senderCertificate: SenderCertificate,
val unidentifiedAccess: UnidentifiedAccess? = null
) : SealedSenderAccess() {
override val headerName: String = "Group-Send-Token"
override val headerValue: String by lazy { Base64.encodeWithPadding(groupSendToken.serialize()) }
override fun switchToFallback(): SealedSenderAccess? {
return if (unidentifiedAccess != null) {
IndividualUnidentifiedAccessFirst(unidentifiedAccess)
} else {
null
}
}
}
/**
* For sending to an single recipient using access key first and then fallback to group send
* token if available. The token is created lazily via the provided [createGroupSendToken] function.
*/
class IndividualUnidentifiedAccessFirst(
val unidentifiedAccess: UnidentifiedAccess,
private val createGroupSendToken: CreateGroupSendToken? = null
) : SealedSenderAccess() {
override val senderCertificate: SenderCertificate
get() = unidentifiedAccess.unidentifiedCertificate
override val headerName: String = "Unidentified-Access-Key"
override val headerValue: String by lazy { Base64.encodeWithPadding(unidentifiedAccess.unidentifiedAccessKey) }
override fun switchToFallback(): SealedSenderAccess? {
val groupSendToken = createGroupSendToken?.create()
return if (groupSendToken != null) {
IndividualGroupSendTokenFirst(groupSendToken, senderCertificate)
} else {
null
}
}
}
/**
* For sending to a "group" of recipients using group send endorsements/tokens.
*/
class GroupGroupSendToken(
private val groupSendEndorsements: GroupSendEndorsements
) : SealedSenderAccess() {
override val headerName: String = "Group-Send-Token"
override val headerValue: String by lazy { Base64.encodeWithPadding(groupSendEndorsements.serialize()) }
override val senderCertificate: SenderCertificate
get() = groupSendEndorsements.sealedSenderCertificate
override fun switchToFallback(): SealedSenderAccess? {
return null
}
}
class StorySendNoop(override val senderCertificate: SenderCertificate) : SealedSenderAccess() {
override val headerName: String = ""
override val headerValue: String = ""
override fun switchToFallback(): SealedSenderAccess? {
return null
}
override fun applyHeader(): Boolean = false
}
/**
* Provide a lazy way to create a group send token.
*/
fun interface CreateGroupSendToken {
fun create(): GroupSendFullToken?
}
companion object {
@JvmField
val NONE: SealedSenderAccess? = null
@JvmStatic
fun forIndividualWithGroupFallback(
unidentifiedAccess: UnidentifiedAccess?,
senderCertificate: SenderCertificate?,
createGroupSendToken: CreateGroupSendToken?
): SealedSenderAccess? {
if (unidentifiedAccess != null) {
return IndividualUnidentifiedAccessFirst(unidentifiedAccess, createGroupSendToken)
}
val groupSendToken = createGroupSendToken?.create()
if (groupSendToken != null && senderCertificate != null) {
return IndividualGroupSendTokenFirst(groupSendToken, senderCertificate)
}
return null
}
@JvmStatic
fun forIndividual(unidentifiedAccess: UnidentifiedAccess?): SealedSenderAccess? {
return unidentifiedAccess?.let { IndividualUnidentifiedAccessFirst(it) }
}
@JvmStatic
fun forFanOutGroupSend(groupSendTokens: List<GroupSendFullToken?>?, senderCertificate: SenderCertificate?, unidentifiedAccesses: List<UnidentifiedAccess?>): List<SealedSenderAccess?> {
if (groupSendTokens == null) {
return unidentifiedAccesses.map { a -> forIndividual(a) }
}
require(groupSendTokens.size == unidentifiedAccesses.size)
return groupSendTokens
.zip(unidentifiedAccesses)
.map { (token, unidentifiedAccess) ->
if (unidentifiedAccess != null) {
IndividualUnidentifiedAccessFirst(unidentifiedAccess) { token }
} else if (token != null && senderCertificate != null) {
IndividualGroupSendTokenFirst(token, senderCertificate)
} else {
null
}
}
}
@JvmStatic
fun forGroupSend(senderCertificate: SenderCertificate?, groupSendEndorsements: GroupSendEndorsements?, forStory: Boolean): SealedSenderAccess {
if (forStory) {
return StorySendNoop(senderCertificate!!)
}
return GroupGroupSendToken(groupSendEndorsements!!)
}
@JvmStatic
fun isUnrestrictedForStory(sealedSenderAccess: SealedSenderAccess?): Boolean {
return when (sealedSenderAccess) {
is IndividualGroupSendTokenFirst -> sealedSenderAccess.unidentifiedAccess?.isUnrestrictedForStory ?: false
is IndividualUnidentifiedAccessFirst -> sealedSenderAccess.unidentifiedAccess.isUnrestrictedForStory
else -> false
}
}
}
}

View file

@ -0,0 +1,39 @@
package org.whispersystems.signalservice.api.crypto;
import org.signal.libsignal.protocol.DuplicateMessageException;
import org.signal.libsignal.protocol.InvalidMessageException;
import org.signal.libsignal.protocol.LegacyMessageException;
import org.signal.libsignal.protocol.NoSessionException;
import org.signal.libsignal.protocol.groups.GroupCipher;
import org.signal.libsignal.protocol.message.CiphertextMessage;
import org.whispersystems.signalservice.api.SignalSessionLock;
import java.util.UUID;
/**
* A thread-safe wrapper around {@link GroupCipher}.
*/
public class SignalGroupCipher {
private final SignalSessionLock lock;
private final GroupCipher cipher;
public SignalGroupCipher(SignalSessionLock lock, GroupCipher cipher) {
this.lock = lock;
this.cipher = cipher;
}
public CiphertextMessage encrypt(UUID distributionId, byte[] paddedPlaintext) throws NoSessionException {
try (SignalSessionLock.Lock unused = lock.acquire()) {
return cipher.encrypt(distributionId, paddedPlaintext);
}
}
public byte[] decrypt(byte[] senderKeyMessageBytes)
throws LegacyMessageException, DuplicateMessageException, InvalidMessageException, NoSessionException
{
try (SignalSessionLock.Lock unused = lock.acquire()) {
return cipher.decrypt(senderKeyMessageBytes);
}
}
}

View file

@ -0,0 +1,35 @@
package org.whispersystems.signalservice.api.crypto;
import org.signal.libsignal.protocol.SessionBuilder;
import org.signal.libsignal.protocol.SignalProtocolAddress;
import org.signal.libsignal.protocol.groups.GroupSessionBuilder;
import org.signal.libsignal.protocol.message.SenderKeyDistributionMessage;
import org.whispersystems.signalservice.api.SignalSessionLock;
import java.util.UUID;
/**
* A thread-safe wrapper around {@link SessionBuilder}.
*/
public class SignalGroupSessionBuilder {
private final SignalSessionLock lock;
private final GroupSessionBuilder builder;
public SignalGroupSessionBuilder(SignalSessionLock lock, GroupSessionBuilder builder) {
this.lock = lock;
this.builder = builder;
}
public void process(SignalProtocolAddress sender, SenderKeyDistributionMessage senderKeyDistributionMessage) {
try (SignalSessionLock.Lock unused = lock.acquire()) {
builder.process(sender, senderKeyDistributionMessage);
}
}
public SenderKeyDistributionMessage create(SignalProtocolAddress sender, UUID distributionId) {
try (SignalSessionLock.Lock unused = lock.acquire()) {
return builder.create(sender, distributionId);
}
}
}

View file

@ -0,0 +1,83 @@
package org.whispersystems.signalservice.api.crypto;
import org.signal.libsignal.metadata.InvalidMetadataMessageException;
import org.signal.libsignal.metadata.InvalidMetadataVersionException;
import org.signal.libsignal.metadata.ProtocolDuplicateMessageException;
import org.signal.libsignal.metadata.ProtocolInvalidKeyException;
import org.signal.libsignal.metadata.ProtocolInvalidKeyIdException;
import org.signal.libsignal.metadata.ProtocolInvalidMessageException;
import org.signal.libsignal.metadata.ProtocolInvalidVersionException;
import org.signal.libsignal.metadata.ProtocolLegacyMessageException;
import org.signal.libsignal.metadata.ProtocolNoSessionException;
import org.signal.libsignal.metadata.ProtocolUntrustedIdentityException;
import org.signal.libsignal.metadata.SealedSessionCipher;
import org.signal.libsignal.metadata.SelfSendException;
import org.signal.libsignal.metadata.certificate.CertificateValidator;
import org.signal.libsignal.metadata.protocol.UnidentifiedSenderMessageContent;
import org.signal.libsignal.protocol.InvalidKeyException;
import org.signal.libsignal.protocol.InvalidRegistrationIdException;
import org.signal.libsignal.protocol.NoSessionException;
import org.signal.libsignal.protocol.SignalProtocolAddress;
import org.signal.libsignal.protocol.UntrustedIdentityException;
import org.signal.libsignal.protocol.state.SessionRecord;
import org.signal.libsignal.protocol.state.SignalProtocolStore;
import org.whispersystems.signalservice.api.SignalSessionLock;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;
/**
* A thread-safe wrapper around {@link SealedSessionCipher}.
*/
public class SignalSealedSessionCipher {
private final SignalSessionLock lock;
private final SealedSessionCipher cipher;
public SignalSealedSessionCipher(SignalSessionLock lock, SealedSessionCipher cipher) {
this.lock = lock;
this.cipher = cipher;
}
public byte[] encrypt(SignalProtocolAddress destinationAddress, UnidentifiedSenderMessageContent content)
throws InvalidKeyException, UntrustedIdentityException
{
try (SignalSessionLock.Lock unused = lock.acquire()) {
return cipher.encrypt(destinationAddress, content);
}
}
public byte[] multiRecipientEncrypt(List<SignalProtocolAddress> recipients, Map<SignalProtocolAddress, SessionRecord> sessionMap, UnidentifiedSenderMessageContent content)
throws InvalidKeyException, UntrustedIdentityException, NoSessionException, InvalidRegistrationIdException
{
try (SignalSessionLock.Lock unused = lock.acquire()) {
List<SessionRecord> recipientSessions = recipients.stream().map(sessionMap::get).collect(Collectors.toList());
if (recipientSessions.contains(null)) {
throw new NoSessionException("No session for some recipients");
}
return cipher.multiRecipientEncrypt(recipients, recipientSessions, content);
}
}
public SealedSessionCipher.DecryptionResult decrypt(CertificateValidator validator, byte[] ciphertext, long timestamp) throws InvalidMetadataMessageException, InvalidMetadataVersionException, ProtocolInvalidMessageException, ProtocolInvalidKeyException, ProtocolNoSessionException, ProtocolLegacyMessageException, ProtocolInvalidVersionException, ProtocolDuplicateMessageException, ProtocolInvalidKeyIdException, ProtocolUntrustedIdentityException, SelfSendException {
try (SignalSessionLock.Lock unused = lock.acquire()) {
return cipher.decrypt(validator, ciphertext, timestamp);
}
}
public int getSessionVersion(SignalProtocolAddress remoteAddress) {
try (SignalSessionLock.Lock unused = lock.acquire()) {
return cipher.getSessionVersion(remoteAddress);
}
}
public int getRemoteRegistrationId(SignalProtocolAddress remoteAddress) {
try (SignalSessionLock.Lock unused = lock.acquire()) {
return cipher.getRemoteRegistrationId(remoteAddress);
}
}
}

View file

@ -0,0 +1,271 @@
/*
* Copyright (C) 2014-2016 Open Whisper Systems
*
* Licensed according to the LICENSE file in this repository.
*/
package org.whispersystems.signalservice.api.crypto;
import org.signal.libsignal.metadata.InvalidMetadataMessageException;
import org.signal.libsignal.metadata.InvalidMetadataVersionException;
import org.signal.libsignal.metadata.ProtocolDuplicateMessageException;
import org.signal.libsignal.metadata.ProtocolInvalidKeyException;
import org.signal.libsignal.metadata.ProtocolInvalidKeyIdException;
import org.signal.libsignal.metadata.ProtocolInvalidMessageException;
import org.signal.libsignal.metadata.ProtocolInvalidVersionException;
import org.signal.libsignal.metadata.ProtocolLegacyMessageException;
import org.signal.libsignal.metadata.ProtocolNoSessionException;
import org.signal.libsignal.metadata.ProtocolUntrustedIdentityException;
import org.signal.libsignal.metadata.SealedSessionCipher;
import org.signal.libsignal.metadata.SealedSessionCipher.DecryptionResult;
import org.signal.libsignal.metadata.SelfSendException;
import org.signal.libsignal.metadata.certificate.CertificateValidator;
import org.signal.libsignal.metadata.certificate.SenderCertificate;
import org.signal.libsignal.metadata.protocol.UnidentifiedSenderMessageContent;
import org.signal.libsignal.protocol.DuplicateMessageException;
import org.signal.libsignal.protocol.InvalidKeyException;
import org.signal.libsignal.protocol.InvalidKeyIdException;
import org.signal.libsignal.protocol.InvalidMessageException;
import org.signal.libsignal.protocol.InvalidRegistrationIdException;
import org.signal.libsignal.protocol.InvalidSessionException;
import org.signal.libsignal.protocol.InvalidVersionException;
import org.signal.libsignal.protocol.LegacyMessageException;
import org.signal.libsignal.protocol.NoSessionException;
import org.signal.libsignal.protocol.SessionCipher;
import org.signal.libsignal.protocol.SignalProtocolAddress;
import org.signal.libsignal.protocol.UntrustedIdentityException;
import org.signal.libsignal.protocol.groups.GroupCipher;
import org.signal.libsignal.protocol.logging.Log;
import org.signal.libsignal.protocol.message.CiphertextMessage;
import org.signal.libsignal.protocol.message.PlaintextContent;
import org.signal.libsignal.protocol.message.PreKeySignalMessage;
import org.signal.libsignal.protocol.message.SignalMessage;
import org.signal.libsignal.protocol.state.SessionRecord;
import org.whispersystems.signalservice.api.InvalidMessageStructureException;
import org.whispersystems.signalservice.api.SignalServiceAccountDataStore;
import org.whispersystems.signalservice.api.SignalSessionLock;
import org.whispersystems.signalservice.api.messages.SignalServiceMetadata;
import org.whispersystems.signalservice.api.push.DistributionId;
import org.whispersystems.signalservice.api.push.ServiceId;
import org.whispersystems.signalservice.api.push.ServiceId.ACI;
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
import org.whispersystems.signalservice.api.util.UuidUtil;
import org.whispersystems.signalservice.internal.push.Content;
import org.whispersystems.signalservice.internal.push.Envelope;
import org.whispersystems.signalservice.internal.push.OutgoingPushMessage;
import org.whispersystems.signalservice.internal.push.PushTransportDetails;
import java.io.IOException;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import javax.annotation.Nullable;
/**
* This is used to encrypt + decrypt received envelopes.
*/
public class SignalServiceCipher {
@SuppressWarnings("unused")
private static final String TAG = SignalServiceCipher.class.getSimpleName();
private final SignalServiceAccountDataStore signalProtocolStore;
private final SignalSessionLock sessionLock;
private final SignalServiceAddress localAddress;
private final int localDeviceId;
private final CertificateValidator certificateValidator;
public SignalServiceCipher(SignalServiceAddress localAddress,
int localDeviceId,
SignalServiceAccountDataStore signalProtocolStore,
SignalSessionLock sessionLock,
CertificateValidator certificateValidator)
{
this.signalProtocolStore = signalProtocolStore;
this.sessionLock = sessionLock;
this.localAddress = localAddress;
this.localDeviceId = localDeviceId;
this.certificateValidator = certificateValidator;
}
public byte[] encryptForGroup(DistributionId distributionId,
List<SignalProtocolAddress> destinations,
Map<SignalProtocolAddress, SessionRecord> sessionMap,
SenderCertificate senderCertificate,
byte[] unpaddedMessage,
ContentHint contentHint,
Optional<byte[]> groupId)
throws NoSessionException, UntrustedIdentityException, InvalidKeyException, InvalidRegistrationIdException
{
PushTransportDetails transport = new PushTransportDetails();
SignalProtocolAddress localProtocolAddress = new SignalProtocolAddress(localAddress.getIdentifier(), localDeviceId);
SignalGroupCipher groupCipher = new SignalGroupCipher(sessionLock, new GroupCipher(signalProtocolStore, localProtocolAddress));
SignalSealedSessionCipher sessionCipher = new SignalSealedSessionCipher(sessionLock, new SealedSessionCipher(signalProtocolStore, localAddress.getServiceId().getRawUuid(), localAddress.getNumber().orElse(null), localDeviceId));
CiphertextMessage message = groupCipher.encrypt(distributionId.asUuid(), transport.getPaddedMessageBody(unpaddedMessage));
UnidentifiedSenderMessageContent messageContent = new UnidentifiedSenderMessageContent(message,
senderCertificate,
contentHint.getType(),
groupId);
return sessionCipher.multiRecipientEncrypt(destinations, sessionMap, messageContent);
}
public OutgoingPushMessage encrypt(SignalProtocolAddress destination,
@Nullable SealedSenderAccess sealedSenderAccess,
EnvelopeContent content)
throws UntrustedIdentityException, InvalidKeyException
{
try {
SignalSessionCipher sessionCipher = new SignalSessionCipher(sessionLock, new SessionCipher(signalProtocolStore, destination));
if (sealedSenderAccess != null) {
SignalSealedSessionCipher sealedSessionCipher = new SignalSealedSessionCipher(sessionLock, new SealedSessionCipher(signalProtocolStore, localAddress.getServiceId().getRawUuid(), localAddress.getNumber()
.orElse(null), localDeviceId));
return content.processSealedSender(sessionCipher, sealedSessionCipher, destination, sealedSenderAccess.getSenderCertificate());
} else {
return content.processUnsealedSender(sessionCipher, destination);
}
} catch (NoSessionException e) {
throw new InvalidSessionException("Session not found: " + destination);
}
}
public SignalServiceCipherResult decrypt(Envelope envelope, long serverDeliveredTimestamp)
throws InvalidMetadataMessageException, InvalidMetadataVersionException,
ProtocolInvalidKeyIdException, ProtocolLegacyMessageException,
ProtocolUntrustedIdentityException, ProtocolNoSessionException,
ProtocolInvalidVersionException, ProtocolInvalidMessageException,
ProtocolInvalidKeyException, ProtocolDuplicateMessageException,
SelfSendException, InvalidMessageStructureException
{
try {
if (envelope.content != null) {
Plaintext plaintext = decryptInternal(envelope, serverDeliveredTimestamp);
Content content = Content.ADAPTER.decode(plaintext.getData());
return new SignalServiceCipherResult(
content,
new EnvelopeMetadata(
plaintext.metadata.getSender().getServiceId(),
plaintext.metadata.getSender().getNumber().orElse(null),
plaintext.metadata.getSenderDevice(),
plaintext.metadata.isNeedsReceipt(),
plaintext.metadata.getGroupId().orElse(null),
localAddress.getServiceId()
)
);
} else {
return null;
}
} catch (IOException | IllegalArgumentException e) {
throw new InvalidMetadataMessageException(e);
}
}
private Plaintext decryptInternal(Envelope envelope, long serverDeliveredTimestamp)
throws InvalidMetadataMessageException, InvalidMetadataVersionException,
ProtocolDuplicateMessageException, ProtocolUntrustedIdentityException,
ProtocolLegacyMessageException, ProtocolInvalidKeyException,
ProtocolInvalidVersionException, ProtocolInvalidMessageException,
ProtocolInvalidKeyIdException, ProtocolNoSessionException,
SelfSendException, InvalidMessageStructureException
{
ServiceId sourceServiceId = ServiceId.parseOrNull(envelope.sourceServiceId, envelope.sourceServiceIdBinary);
try {
ServiceId destinationServiceId = ServiceId.parseOrNull(envelope.destinationServiceId, envelope.destinationServiceIdBinary);
String destinationStr = (destinationServiceId != null) ? destinationServiceId.toString() : "";
String serverGuid = UuidUtil.getStringUUID(envelope.serverGuid, envelope.serverGuidBinary);
byte[] paddedMessage;
SignalServiceMetadata metadata;
if (sourceServiceId == null && envelope.type != Envelope.Type.UNIDENTIFIED_SENDER) {
throw new InvalidMessageStructureException("Non-UD envelope is missing a UUID!");
}
if (envelope.type == Envelope.Type.PREKEY_BUNDLE) {
SignalProtocolAddress sourceAddress = new SignalProtocolAddress(sourceServiceId.toString(), envelope.sourceDevice);
SignalSessionCipher sessionCipher = new SignalSessionCipher(sessionLock, new SessionCipher(signalProtocolStore, sourceAddress));
paddedMessage = sessionCipher.decrypt(new PreKeySignalMessage(envelope.content.toByteArray()));
metadata = new SignalServiceMetadata(getSourceAddress(envelope), envelope.sourceDevice, envelope.timestamp, envelope.serverTimestamp, serverDeliveredTimestamp, false, serverGuid, Optional.empty(), destinationStr);
signalProtocolStore.clearSenderKeySharedWith(Collections.singleton(sourceAddress));
} else if (envelope.type == Envelope.Type.CIPHERTEXT) {
SignalProtocolAddress sourceAddress = new SignalProtocolAddress(sourceServiceId.toString(), envelope.sourceDevice);
SignalSessionCipher sessionCipher = new SignalSessionCipher(sessionLock, new SessionCipher(signalProtocolStore, sourceAddress));
paddedMessage = sessionCipher.decrypt(new SignalMessage(envelope.content.toByteArray()));
metadata = new SignalServiceMetadata(getSourceAddress(envelope), envelope.sourceDevice, envelope.timestamp, envelope.serverTimestamp, serverDeliveredTimestamp, false, serverGuid, Optional.empty(), destinationStr);
} else if (envelope.type == Envelope.Type.PLAINTEXT_CONTENT) {
paddedMessage = new PlaintextContent(envelope.content.toByteArray()).getBody();
metadata = new SignalServiceMetadata(getSourceAddress(envelope), envelope.sourceDevice, envelope.timestamp, envelope.serverTimestamp, serverDeliveredTimestamp, false, serverGuid, Optional.empty(), destinationStr);
} else if (envelope.type == Envelope.Type.UNIDENTIFIED_SENDER) {
SignalSealedSessionCipher sealedSessionCipher = new SignalSealedSessionCipher(sessionLock, new SealedSessionCipher(signalProtocolStore, localAddress.getServiceId().getRawUuid(), localAddress.getNumber().orElse(null), localDeviceId));
DecryptionResult result = sealedSessionCipher.decrypt(certificateValidator, envelope.content.toByteArray(), envelope.serverTimestamp);
SignalServiceAddress resultAddress = new SignalServiceAddress(ACI.parseOrThrow(result.getSenderUuid()), result.getSenderE164());
Optional<byte[]> groupId = result.getGroupId();
boolean needsReceipt = true;
if (sourceServiceId != null) {
Log.w(TAG, "[" + envelope.timestamp + "] Received a UD-encrypted message sent over an identified channel. Marking as needsReceipt=false");
needsReceipt = false;
}
if (result.getCiphertextMessageType() == CiphertextMessage.PREKEY_TYPE) {
signalProtocolStore.clearSenderKeySharedWith(Collections.singleton(new SignalProtocolAddress(result.getSenderUuid(), result.getDeviceId())));
}
paddedMessage = result.getPaddedMessage();
metadata = new SignalServiceMetadata(resultAddress, result.getDeviceId(), envelope.timestamp, envelope.serverTimestamp, serverDeliveredTimestamp, needsReceipt, serverGuid, groupId, destinationStr);
} else {
throw new InvalidMetadataMessageException("Unknown type: " + envelope.type);
}
PushTransportDetails transportDetails = new PushTransportDetails();
byte[] data = transportDetails.getStrippedPaddingMessageBody(paddedMessage);
return new Plaintext(metadata, data);
} catch (DuplicateMessageException e) {
throw new ProtocolDuplicateMessageException(e, sourceServiceId.toString(), envelope.sourceDevice);
} catch (LegacyMessageException e) {
throw new ProtocolLegacyMessageException(e, sourceServiceId.toString(), envelope.sourceDevice);
} catch (InvalidMessageException e) {
throw new ProtocolInvalidMessageException(e, sourceServiceId.toString(), envelope.sourceDevice);
} catch (InvalidKeyIdException e) {
throw new ProtocolInvalidKeyIdException(e, sourceServiceId.toString(), envelope.sourceDevice);
} catch (InvalidKeyException e) {
throw new ProtocolInvalidKeyException(e, sourceServiceId.toString(), envelope.sourceDevice);
} catch (UntrustedIdentityException e) {
throw new ProtocolUntrustedIdentityException(e, sourceServiceId.toString(), envelope.sourceDevice);
} catch (InvalidVersionException e) {
throw new ProtocolInvalidVersionException(e, sourceServiceId.toString(), envelope.sourceDevice);
} catch (NoSessionException e) {
throw new ProtocolNoSessionException(e, sourceServiceId.toString(), envelope.sourceDevice);
}
}
private static SignalServiceAddress getSourceAddress(Envelope envelope) {
return new SignalServiceAddress(ServiceId.parseOrNull(envelope.sourceServiceId, envelope.sourceServiceIdBinary));
}
private static class Plaintext {
private final SignalServiceMetadata metadata;
private final byte[] data;
private Plaintext(SignalServiceMetadata metadata, byte[] data) {
this.metadata = metadata;
this.data = data;
}
public SignalServiceMetadata getMetadata() {
return metadata;
}
public byte[] getData() {
return data;
}
}
}

View file

@ -0,0 +1,15 @@
package org.whispersystems.signalservice.api.crypto
import org.whispersystems.signalservice.internal.push.Content
/**
* Represents the output of decrypting a [SignalServiceProtos.Envelope] via [SignalServiceCipher.decrypt]
*
* @param content The [SignalServiceProtos.Content] that was decrypted from the envelope.
* @param metadata The decrypted metadata of the envelope. Represents sender information that may have
* been encrypted with sealed sender.
*/
data class SignalServiceCipherResult(
val content: Content,
val metadata: EnvelopeMetadata
)

View file

@ -0,0 +1,27 @@
package org.whispersystems.signalservice.api.crypto;
import org.signal.libsignal.protocol.InvalidKeyException;
import org.signal.libsignal.protocol.SessionBuilder;
import org.signal.libsignal.protocol.UntrustedIdentityException;
import org.signal.libsignal.protocol.state.PreKeyBundle;
import org.whispersystems.signalservice.api.SignalSessionLock;
/**
* A thread-safe wrapper around {@link SessionBuilder}.
*/
public class SignalSessionBuilder {
private final SignalSessionLock lock;
private final SessionBuilder builder;
public SignalSessionBuilder(SignalSessionLock lock, SessionBuilder builder) {
this.lock = lock;
this.builder = builder;
}
public void process(PreKeyBundle preKey) throws InvalidKeyException, UntrustedIdentityException {
try (SignalSessionLock.Lock unused = lock.acquire()) {
builder.process(preKey);
}
}
}

View file

@ -0,0 +1,59 @@
package org.whispersystems.signalservice.api.crypto;
import org.signal.libsignal.protocol.DuplicateMessageException;
import org.signal.libsignal.protocol.InvalidKeyException;
import org.signal.libsignal.protocol.InvalidKeyIdException;
import org.signal.libsignal.protocol.InvalidMessageException;
import org.signal.libsignal.protocol.InvalidVersionException;
import org.signal.libsignal.protocol.LegacyMessageException;
import org.signal.libsignal.protocol.NoSessionException;
import org.signal.libsignal.protocol.SessionCipher;
import org.signal.libsignal.protocol.UntrustedIdentityException;
import org.signal.libsignal.protocol.message.CiphertextMessage;
import org.signal.libsignal.protocol.message.PreKeySignalMessage;
import org.signal.libsignal.protocol.message.SignalMessage;
import org.whispersystems.signalservice.api.SignalSessionLock;
/**
* A thread-safe wrapper around {@link SessionCipher}.
*/
public class SignalSessionCipher {
private final SignalSessionLock lock;
private final SessionCipher cipher;
public SignalSessionCipher(SignalSessionLock lock, SessionCipher cipher) {
this.lock = lock;
this.cipher = cipher;
}
public CiphertextMessage encrypt(byte[] paddedMessage) throws org.signal.libsignal.protocol.UntrustedIdentityException, NoSessionException {
try (SignalSessionLock.Lock unused = lock.acquire()) {
return cipher.encrypt(paddedMessage);
}
}
public byte[] decrypt(PreKeySignalMessage ciphertext) throws DuplicateMessageException, LegacyMessageException, InvalidMessageException, InvalidKeyIdException, InvalidKeyException, org.signal.libsignal.protocol.UntrustedIdentityException {
try (SignalSessionLock.Lock unused = lock.acquire()) {
return cipher.decrypt(ciphertext);
}
}
public byte[] decrypt(SignalMessage ciphertext) throws InvalidMessageException, InvalidVersionException, DuplicateMessageException, LegacyMessageException, NoSessionException, UntrustedIdentityException {
try (SignalSessionLock.Lock unused = lock.acquire()) {
return cipher.decrypt(ciphertext);
}
}
public int getRemoteRegistrationId() {
try (SignalSessionLock.Lock unused = lock.acquire()) {
return cipher.getRemoteRegistrationId();
}
}
public int getSessionVersion() {
try (SignalSessionLock.Lock unused = lock.acquire()) {
return cipher.getSessionVersion();
}
}
}

View file

@ -0,0 +1,52 @@
package org.whispersystems.signalservice.api.crypto;
import java.io.FilterOutputStream;
import java.io.IOException;
import java.io.OutputStream;
/**
* SkippingOutputStream will skip a number of bytes being written as specified by toSkip and then
* continue writing all remaining bytes to the wrapped output stream.
*/
public class SkippingOutputStream extends FilterOutputStream {
private long toSkip;
public SkippingOutputStream(long toSkip, OutputStream wrapped) {
super(wrapped);
this.toSkip = toSkip;
}
public void write(int b) throws IOException {
if (toSkip > 0) {
toSkip--;
} else {
out.write(b);
}
}
public void write(byte[] b) throws IOException {
write(b, 0, b.length);
}
public void write(byte[] b, int off, int len) throws IOException {
if (b == null) {
throw new NullPointerException();
}
if (off < 0 || off > b.length || len < 0 || len + off > b.length || len + off < 0) {
throw new IndexOutOfBoundsException();
}
if (toSkip > 0) {
if (len <= toSkip) {
toSkip -= len;
} else {
out.write(b, off + (int) toSkip, len - (int) toSkip);
toSkip = 0;
}
} else {
out.write(b, off, len);
}
}
}

View file

@ -0,0 +1,71 @@
package org.whispersystems.signalservice.api.crypto;
import org.signal.libsignal.metadata.certificate.InvalidCertificateException;
import org.signal.libsignal.metadata.certificate.SenderCertificate;
import org.signal.libsignal.protocol.util.ByteUtil;
import org.signal.libsignal.zkgroup.internal.ByteArray;
import org.signal.libsignal.zkgroup.profiles.ProfileKey;
import java.security.InvalidAlgorithmParameterException;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import javax.crypto.BadPaddingException;
import javax.crypto.Cipher;
import javax.crypto.IllegalBlockSizeException;
import javax.crypto.NoSuchPaddingException;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
public class UnidentifiedAccess {
private final byte[] unidentifiedAccessKey;
private final SenderCertificate unidentifiedCertificate;
private final boolean isUnrestrictedForStory;
/**
* @param isUnrestrictedForStory When sending to a story, we always want to use sealed sender. Receivers will accept it for story messages. However, there are
* some situations where we need to know if this access key will be correct for non-story purposes. Set this flag to true if
* the access key is a synthetic one that would only be valid for story messages.
*/
public UnidentifiedAccess(byte[] unidentifiedAccessKey, byte[] unidentifiedCertificate, boolean isUnrestrictedForStory)
throws InvalidCertificateException
{
this.unidentifiedAccessKey = unidentifiedAccessKey;
this.unidentifiedCertificate = new SenderCertificate(unidentifiedCertificate);
this.isUnrestrictedForStory = isUnrestrictedForStory;
}
public byte[] getUnidentifiedAccessKey() {
return unidentifiedAccessKey;
}
public SenderCertificate getUnidentifiedCertificate() {
return unidentifiedCertificate;
}
public boolean isUnrestrictedForStory() {
return isUnrestrictedForStory;
}
public static byte[] deriveAccessKeyFrom(ProfileKey profileKey) {
try {
byte[] nonce = createEmptyByteArray(12);
byte[] input = createEmptyByteArray(16);
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(profileKey.serialize(), "AES"), new GCMParameterSpec(128, nonce));
byte[] ciphertext = cipher.doFinal(input);
return ByteUtil.trim(ciphertext, 16);
} catch (NoSuchAlgorithmException | InvalidKeyException | NoSuchPaddingException | InvalidAlgorithmParameterException | BadPaddingException | IllegalBlockSizeException e) {
throw new AssertionError(e);
}
}
private static byte[] createEmptyByteArray(int length) {
return new byte[length];
}
}

View file

@ -0,0 +1,34 @@
/**
* Copyright (C) 2014-2016 Open Whisper Systems
*
* Licensed according to the LICENSE file in this repository.
*/
package org.whispersystems.signalservice.api.crypto;
import org.signal.libsignal.protocol.IdentityKey;
public class UntrustedIdentityException extends Exception {
private final IdentityKey identityKey;
private final String identifier;
public UntrustedIdentityException(String s, String identifier, IdentityKey identityKey) {
super(s);
this.identifier = identifier;
this.identityKey = identityKey;
}
public UntrustedIdentityException(UntrustedIdentityException e) {
this(e.getMessage(), e.getIdentifier(), e.getIdentityKey());
}
public IdentityKey getIdentityKey() {
return identityKey;
}
public String getIdentifier() {
return identifier;
}
}

View file

@ -0,0 +1,29 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.signalservice.api.donations;
import com.fasterxml.jackson.annotation.JsonProperty;
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialRequest;
import org.signal.core.util.Base64;
import org.whispersystems.signalservice.internal.push.DonationProcessor;
class BoostReceiptCredentialRequestJson {
@JsonProperty("paymentIntentId")
private final String paymentIntentId;
@JsonProperty("receiptCredentialRequest")
private final String receiptCredentialRequest;
@JsonProperty("processor")
private final String processor;
BoostReceiptCredentialRequestJson(String paymentIntentId, ReceiptCredentialRequest receiptCredentialRequest, DonationProcessor processor) {
this.paymentIntentId = paymentIntentId;
this.receiptCredentialRequest = Base64.encodeWithPadding(receiptCredentialRequest.serialize());
this.processor = processor.getCode();
}
}

View file

@ -0,0 +1,324 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.signalservice.api.donations
import org.signal.core.util.Base64
import org.signal.core.util.urlEncode
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialPresentation
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialRequest
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialResponse
import org.whispersystems.signalservice.api.NetworkResult
import org.whispersystems.signalservice.api.push.exceptions.MalformedResponseException
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
import org.whispersystems.signalservice.api.subscriptions.PayPalConfirmPaymentIntentResponse
import org.whispersystems.signalservice.api.subscriptions.PayPalCreatePaymentIntentResponse
import org.whispersystems.signalservice.api.subscriptions.PayPalCreatePaymentMethodResponse
import org.whispersystems.signalservice.api.subscriptions.StripeClientSecret
import org.whispersystems.signalservice.api.subscriptions.SubscriberId
import org.whispersystems.signalservice.api.websocket.SignalWebSocket
import org.whispersystems.signalservice.internal.delete
import org.whispersystems.signalservice.internal.get
import org.whispersystems.signalservice.internal.post
import org.whispersystems.signalservice.internal.push.BankMandate
import org.whispersystems.signalservice.internal.push.DonationProcessor
import org.whispersystems.signalservice.internal.push.SubscriptionsConfiguration
import org.whispersystems.signalservice.internal.put
import org.whispersystems.signalservice.internal.websocket.WebSocketRequestMessage
import java.util.Locale
/**
* One-stop shop for Signal service calls related to in-app payments.
*
* Be sure to check for cached versions of these methods in DonationsService before calling these methods elsewhere.
*/
class DonationsApi(private val authWebSocket: SignalWebSocket.AuthenticatedWebSocket, private val unauthWebSocket: SignalWebSocket.UnauthenticatedWebSocket) {
/**
* Get configuration data associated with donations, like gift, one-time, and monthly levels, etc.
*
* Note, this will skip cached values, causing us to hit the network more than necessary. Consider accessing this method via the DonationsService instead.
*
* GET /v1/subscription/configuration
* - 200: Success
*/
fun getDonationsConfiguration(locale: Locale): NetworkResult<SubscriptionsConfiguration> {
val request = WebSocketRequestMessage.get("/v1/subscription/configuration", locale.toAcceptLanguageHeaders())
return NetworkResult.fromWebSocketRequest(unauthWebSocket, request, SubscriptionsConfiguration::class)
}
/**
* Get localized bank mandate text for the given [bankTransferType].
*
* GET /v1/subscription/bank_mandate/[bankTransferType]
* - 200: Success
*
* @param bankTransferType Valid values for bankTransferType are 'SEPA_DEBIT'.
*/
fun getBankMandate(locale: Locale, bankTransferType: String): NetworkResult<BankMandate> {
val request = WebSocketRequestMessage.get("/v1/subscription/bank_mandate/$bankTransferType", locale.toAcceptLanguageHeaders())
return NetworkResult.fromWebSocketRequest(unauthWebSocket, request, BankMandate::class)
}
/**
* GET /v1/subscription/[subscriberId]
* - 200: Success
* - 403: Invalid or malformed [subscriberId]
* - 404: [subscriberId] not found
*/
fun getSubscription(subscriberId: SubscriberId): NetworkResult<ActiveSubscription> {
val request = WebSocketRequestMessage.get("/v1/subscription/${subscriberId.serialize()}")
return NetworkResult.fromWebSocketRequest(unauthWebSocket, request, ActiveSubscription::class)
}
/**
* Creates a subscriber record on the signal server.
*
* PUT /v1/subscription/[subscriberId]
* - 200: Success
* - 403, 404: Invalid or malformed [subscriberId]
*/
fun putSubscription(subscriberId: SubscriberId): NetworkResult<Unit> {
val request = WebSocketRequestMessage.put("/v1/subscription/${subscriberId.serialize()}", body = "")
return NetworkResult.fromWebSocketRequest(unauthWebSocket, request)
}
/**
* DELETE /v1/subscription/[subscriberId]
* - 204: Success
* - 403: Invalid or malformed [subscriberId]
* - 404: [subscriberId] not found
*/
fun deleteSubscription(subscriberId: SubscriberId): NetworkResult<Unit> {
val request = WebSocketRequestMessage.delete("/v1/subscription/${subscriberId.serialize()}")
return NetworkResult.fromWebSocketRequest(unauthWebSocket, request)
}
/**
* Updates the current subscription to the given level and currency.
* - 200: Success
*/
fun updateSubscriptionLevel(subscriberId: SubscriberId, level: String, currencyCode: String, idempotencyKey: String): NetworkResult<Unit> {
val request = WebSocketRequestMessage.put("/v1/subscription/${subscriberId.serialize()}/level/${level.urlEncode()}/${currencyCode.urlEncode()}/$idempotencyKey", "")
return NetworkResult.fromWebSocketRequest(unauthWebSocket, request)
}
/**
* Submits price information to the server to generate a payment intent via the payment gateway.
*
* POST /v1/subscription/boost/create
*
* @param amount Price, in the minimum currency unit (e.g. cents or yen)
* @param currencyCode The currency code for the amount
*/
fun createStripeOneTimePaymentIntent(currencyCode: String, paymentMethod: String, amount: Long, level: Long): NetworkResult<StripeClientSecret> {
val body = StripeOneTimePaymentIntentPayload(amount, currencyCode, level, paymentMethod)
val request = WebSocketRequestMessage.post("/v1/subscription/boost/create", body)
return NetworkResult.fromWebSocketRequest(unauthWebSocket, request, StripeClientSecret::class)
}
/**
* PUT /v1/subscription/[subscriberId]/create_payment_method?type=[type]
* - 200: Success
*
* @param type One of CARD or SEPA_DEBIT
*/
fun createStripeSubscriptionPaymentMethod(subscriberId: SubscriberId, type: String): NetworkResult<StripeClientSecret> {
val request = WebSocketRequestMessage.post("/v1/subscription/${subscriberId.serialize()}/create_payment_method?type=${type.urlEncode()}", "")
return NetworkResult.fromWebSocketRequest(unauthWebSocket, request, StripeClientSecret::class)
}
/**
* Creates a PayPal one-time payment and returns the approval URL.
*
* POST /v1/subscription/boost/paypal/create
* - 200: Success
* - 400: Request error
* - 409: Level requires a valid currency/amount combination that does not match
*
* @param locale User locale for proper language presentation
* @param currencyCode 3 letter currency code of the desired currency
* @param amount Stringified minimum precision amount
* @param level The badge level to purchase
* @param returnUrl The 'return' url after a successful login and confirmation
* @param cancelUrl The 'cancel' url for a cancelled confirmation
*/
fun createPayPalOneTimePaymentIntent(
locale: Locale,
currencyCode: String,
amount: Long,
level: Long,
returnUrl: String,
cancelUrl: String
): NetworkResult<PayPalCreatePaymentIntentResponse> {
val body = PayPalCreateOneTimePaymentIntentPayload(amount, currencyCode, level, returnUrl, cancelUrl)
val request = WebSocketRequestMessage.post("/v1/subscription/boost/paypal/create", body, locale.toAcceptLanguageHeaders())
return NetworkResult.fromWebSocketRequest(unauthWebSocket, request, PayPalCreatePaymentIntentResponse::class)
}
/**
* Confirms a PayPal one-time payment and returns the paymentId for receipt credentials
*
* POST /v1/subscription/boost/paypal/confirm
* - 200: Success
* - 400: Request error
* - 409: Level requires a valid currency/amount combination that does not match
*
* @param currency 3 letter currency code of the desired currency
* @param amount Stringified minimum precision amount
* @param level The badge level to purchase
* @param payerId Passed as a URL parameter back to returnUrl
* @param paymentId Passed as a URL parameter back to returnUrl
* @param paymentToken Passed as a URL parameter back to returnUrl
*/
fun confirmPayPalOneTimePaymentIntent(
currency: String,
amount: String,
level: Long,
payerId: String,
paymentId: String,
paymentToken: String
): NetworkResult<PayPalConfirmPaymentIntentResponse> {
val body = PayPalConfirmOneTimePaymentIntentPayload(amount, currency, level, payerId, paymentId, paymentToken)
val request = WebSocketRequestMessage.post("/v1/subscription/boost/paypal/confirm", body)
return NetworkResult.fromWebSocketRequest(unauthWebSocket, request, PayPalConfirmPaymentIntentResponse::class)
}
/**
* Sets up a payment method via PayPal for recurring charges.
*
* POST /v1/subscription/[subscriberId]/create_payment_method/paypal
* - 200: success
* - 403: subscriberId password mismatches OR account authentication is present
* - 404: subscriberId is not found or malformed
*
* @param locale User locale
* @param subscriberId User subscriber id
* @param returnUrl A success URL
* @param cancelUrl A cancel URL
* @return A response with an approval url and token
*/
fun createPayPalPaymentMethod(
locale: Locale,
subscriberId: SubscriberId,
returnUrl: String,
cancelUrl: String
): NetworkResult<PayPalCreatePaymentMethodResponse> {
val body = PayPalCreatePaymentMethodPayload(returnUrl, cancelUrl)
val request = WebSocketRequestMessage.post("/v1/subscription/${subscriberId.serialize()}/create_payment_method/paypal", body, locale.toAcceptLanguageHeaders())
return NetworkResult.fromWebSocketRequest(unauthWebSocket, request, PayPalCreatePaymentMethodResponse::class)
}
/**
* POST /v1/subscription/[subscriberId]/receipt_credentials
*/
fun submitReceiptCredentials(subscriberId: SubscriberId, receiptCredentialRequest: ReceiptCredentialRequest): NetworkResult<ReceiptCredentialResponse> {
val body = ReceiptCredentialRequestJson(receiptCredentialRequest)
val request = WebSocketRequestMessage.post("/v1/subscription/${subscriberId.serialize()}/receipt_credentials", body)
return NetworkResult.fromWebSocketRequest(unauthWebSocket, request, webSocketResponseConverter = NetworkResult.LongPollingWebSocketConverter(ReceiptCredentialResponseJson::class))
.map { it.receiptCredentialResponse }
.then {
if (it != null) {
NetworkResult.Success(it)
} else {
NetworkResult.NetworkError(MalformedResponseException("Unable to parse response"))
}
}
}
/**
* Given a completed payment intent and a receipt credential request produces a receipt credential response. Clients
* should always use the same ReceiptCredentialRequest with the same payment intent id. This request is repeatable so
* long as the two values are reused.
*
* POST /v1/subscription/boost/receipt_credentials
*
* @param paymentIntentId PaymentIntent ID from a boost donation intent response.
* @param receiptCredentialRequest Client-generated request token
*/
fun submitBoostReceiptCredentials(paymentIntentId: String, receiptCredentialRequest: ReceiptCredentialRequest, processor: DonationProcessor): NetworkResult<ReceiptCredentialResponse> {
val body = BoostReceiptCredentialRequestJson(paymentIntentId, receiptCredentialRequest, processor)
val request = WebSocketRequestMessage.post("/v1/subscription/boost/receipt_credentials", body)
return NetworkResult.fromWebSocketRequest(unauthWebSocket, request, webSocketResponseConverter = NetworkResult.LongPollingWebSocketConverter(ReceiptCredentialResponseJson::class))
.map { it.receiptCredentialResponse }
.then {
if (it != null) {
NetworkResult.Success(it)
} else {
NetworkResult.NetworkError(MalformedResponseException("Unable to parse response"))
}
}
}
/**
* POST /v1/subscription/[subscriberId]/default_payment_method/stripe/[paymentMethodId]
* - 200: Success
*/
fun setDefaultStripeSubscriptionPaymentMethod(subscriberId: SubscriberId, paymentMethodId: String): NetworkResult<Unit> {
val request = WebSocketRequestMessage.post("/v1/subscription/${subscriberId.serialize()}/default_payment_method/stripe/${paymentMethodId.urlEncode()}", "")
return NetworkResult.fromWebSocketRequest(unauthWebSocket, request)
}
/**
* POST /v1/subscription/[subscriberId]/default_payment_method_for_ideal/[setupIntentId]
* - 200: Success
*/
fun setDefaultIdealSubscriptionPaymentMethod(subscriberId: SubscriberId, setupIntentId: String): NetworkResult<Unit> {
val request = WebSocketRequestMessage.post("/v1/subscription/${subscriberId.serialize()}/default_payment_method_for_ideal/${setupIntentId.urlEncode()}", "")
return NetworkResult.fromWebSocketRequest(unauthWebSocket, request)
}
/**
* POST /v1/subscription/[subscriberId]/default_payment_method/braintree/[paymentMethodId]
* - 200: Success
*/
fun setDefaultPaypalSubscriptionPaymentMethod(subscriberId: SubscriberId, paymentMethodId: String): NetworkResult<Unit> {
val request = WebSocketRequestMessage.post("/v1/subscription/${subscriberId.serialize()}/default_payment_method/braintree/${paymentMethodId.urlEncode()}", "")
return NetworkResult.fromWebSocketRequest(unauthWebSocket, request)
}
/**
* POST /v1/subscription/[subscriberId]/playbilling/[purchaseToken]
* - 200: Success
*/
fun linkPlayBillingPurchaseToken(subscriberId: SubscriberId, purchaseToken: String): NetworkResult<Unit> {
val request = WebSocketRequestMessage.post("/v1/subscription/${subscriberId.serialize()}/playbilling/${purchaseToken.urlEncode()}", "")
return NetworkResult.fromWebSocketRequest(unauthWebSocket, request)
}
/**
* Allows a user to redeem a given receipt they were given after submitting a donation successfully.
*
* POST /v1/donation/redeem-receipt
* - 200: Success
*
* @param receiptCredentialPresentation Receipt
* @param visible Whether the badge will be visible on the user's profile immediately after redemption
* @param primary Whether the badge will be made primary immediately after redemption
*/
fun redeemDonationReceipt(receiptCredentialPresentation: ReceiptCredentialPresentation, visible: Boolean, primary: Boolean): NetworkResult<Unit> {
val body = RedeemDonationReceiptRequest(Base64.encodeWithPadding(receiptCredentialPresentation.serialize()), visible, primary)
val request = WebSocketRequestMessage.post("/v1/donation/redeem-receipt", body)
return NetworkResult.fromWebSocketRequest(authWebSocket, request)
}
/**
* Allows a user to redeem a given receipt they were given after submitting a donation successfully.
*
* POST /v1/archives/redeem-receipt
* - 200: Success
*
* @param receiptCredentialPresentation Receipt
*/
fun redeemArchivesReceipt(receiptCredentialPresentation: ReceiptCredentialPresentation): NetworkResult<Unit> {
val body = RedeemArchivesReceiptRequest(Base64.encodeWithPadding(receiptCredentialPresentation.serialize()))
val request = WebSocketRequestMessage.post("/v1/archives/redeem-receipt", body)
return NetworkResult.fromWebSocketRequest(authWebSocket, request)
}
private fun Locale.toAcceptLanguageHeaders(): Map<String, String> {
return mapOf("Accept-Language" to "${this.language}${if (this.country.isNotEmpty()) "-${this.country}" else ""}")
}
}

View file

@ -0,0 +1,40 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.signalservice.api.donations;
import com.fasterxml.jackson.annotation.JsonProperty;
/**
* Request JSON for confirming a PayPal one-time payment intent
*/
class PayPalConfirmOneTimePaymentIntentPayload {
@JsonProperty
private String amount;
@JsonProperty
private String currency;
@JsonProperty
private long level;
@JsonProperty
private String payerId;
@JsonProperty
private String paymentId;
@JsonProperty
private String paymentToken;
public PayPalConfirmOneTimePaymentIntentPayload(String amount, String currency, long level, String payerId, String paymentId, String paymentToken) {
this.amount = amount;
this.currency = currency;
this.level = level;
this.payerId = payerId;
this.paymentId = paymentId;
this.paymentToken = paymentToken;
}
}

View file

@ -0,0 +1,36 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.signalservice.api.donations;
import com.fasterxml.jackson.annotation.JsonProperty;
/**
* Request JSON for creating a PayPal one-time payment intent
*/
class PayPalCreateOneTimePaymentIntentPayload {
@JsonProperty
private long amount;
@JsonProperty
private String currency;
@JsonProperty
private long level;
@JsonProperty
private String returnUrl;
@JsonProperty
private String cancelUrl;
public PayPalCreateOneTimePaymentIntentPayload(long amount, String currency, long level, String returnUrl, String cancelUrl) {
this.amount = amount;
this.currency = currency;
this.level = level;
this.returnUrl = returnUrl;
this.cancelUrl = cancelUrl;
}
}

View file

@ -0,0 +1,21 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.signalservice.api.donations;
import com.fasterxml.jackson.annotation.JsonProperty;
class PayPalCreatePaymentMethodPayload {
@JsonProperty
private String returnUrl;
@JsonProperty
private String cancelUrl;
PayPalCreatePaymentMethodPayload(String returnUrl, String cancelUrl) {
this.returnUrl = returnUrl;
this.cancelUrl = cancelUrl;
}
}

View file

@ -0,0 +1,20 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.signalservice.api.donations;
import com.fasterxml.jackson.annotation.JsonProperty;
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialRequest;
import org.signal.core.util.Base64;
class ReceiptCredentialRequestJson {
@JsonProperty("receiptCredentialRequest")
private final String receiptCredentialRequest;
ReceiptCredentialRequestJson(ReceiptCredentialRequest receiptCredentialRequest) {
this.receiptCredentialRequest = Base64.encodeWithPadding(receiptCredentialRequest.serialize());
}
}

View file

@ -0,0 +1,36 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.signalservice.api.donations;
import com.fasterxml.jackson.annotation.JsonProperty;
import org.signal.libsignal.zkgroup.InvalidInputException;
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialResponse;
import org.signal.core.util.Base64;
import java.io.IOException;
import javax.annotation.Nullable;
class ReceiptCredentialResponseJson {
private final ReceiptCredentialResponse receiptCredentialResponse;
ReceiptCredentialResponseJson(@JsonProperty("receiptCredentialResponse") String receiptCredentialResponse) {
ReceiptCredentialResponse response;
try {
response = new ReceiptCredentialResponse(Base64.decode(receiptCredentialResponse));
} catch (IOException | InvalidInputException e) {
response = null;
}
this.receiptCredentialResponse = response;
}
public @Nullable ReceiptCredentialResponse getReceiptCredentialResponse() {
return receiptCredentialResponse;
}
}

View file

@ -0,0 +1,18 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.signalservice.api.donations
import com.fasterxml.jackson.annotation.JsonCreator
import com.fasterxml.jackson.annotation.JsonProperty
/**
* POST /v1/archives/redeem-receipt
*
* Request object for redeeming a receipt from a donation transaction.
*
* @param receiptCredentialPresentation base64-encoded no-newlines standard-character-set with-padding of the bytes of a [ReceiptCredentialPresentation] object
*/
internal class RedeemArchivesReceiptRequest @JsonCreator constructor(@param:JsonProperty("receiptCredentialPresentation") val receiptCredentialPresentation: String)

View file

@ -0,0 +1,50 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.signalservice.api.donations;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialPresentation;
/**
* POST /v1/donation/redeem-receipt
*
* Request object for redeeming a receipt from a donation transaction.
*/
class RedeemDonationReceiptRequest {
private final String receiptCredentialPresentation;
private final boolean visible;
private final boolean primary;
/**
* @param receiptCredentialPresentation base64-encoded no-newlines standard-character-set with-padding of the bytes of a {@link ReceiptCredentialPresentation} object
* @param visible boolean indicating if the new badge should be visible or not on the profile
* @param primary boolean indicating if the new badge should be primary or not on the profile; is always treated as false if `visible` is false
*/
@JsonCreator RedeemDonationReceiptRequest(
@JsonProperty("receiptCredentialPresentation") String receiptCredentialPresentation,
@JsonProperty("visible") boolean visible,
@JsonProperty("primary") boolean primary) {
this.receiptCredentialPresentation = receiptCredentialPresentation;
this.visible = visible;
this.primary = primary;
}
public String getReceiptCredentialPresentation() {
return receiptCredentialPresentation;
}
public boolean isVisible() {
return visible;
}
public boolean isPrimary() {
return primary;
}
}

View file

@ -0,0 +1,29 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.signalservice.api.donations;
import com.fasterxml.jackson.annotation.JsonProperty;
class StripeOneTimePaymentIntentPayload {
@JsonProperty
private long amount;
@JsonProperty
private String currency;
@JsonProperty
private long level;
@JsonProperty
private String paymentMethod;
public StripeOneTimePaymentIntentPayload(long amount, String currency, long level, String paymentMethod) {
this.amount = amount;
this.currency = currency;
this.level = level;
this.paymentMethod = paymentMethod;
}
}

View file

@ -0,0 +1,49 @@
package org.whispersystems.signalservice.api.groupsv2;
public interface ChangeSetModifier {
void removeAddMembers(int i);
void moveAddToPromote(int i);
void removeDeleteMembers(int i);
void removeModifyMemberRoles(int i);
void removeModifyMemberProfileKeys(int i);
void removeAddPendingMembers(int i);
void removeDeletePendingMembers(int i);
void removePromotePendingMembers(int i);
void clearModifyTitle();
void clearModifyAvatar();
void clearModifyDisappearingMessagesTimer();
void clearModifyAttributesAccess();
void clearModifyMemberAccess();
void clearModifyAddFromInviteLinkAccess();
void removeAddRequestingMembers(int i);
void moveAddRequestingMembersToPromote(int i);
void removeDeleteRequestingMembers(int i);
void removePromoteRequestingMembers(int i);
void clearModifyDescription();
void clearModifyAnnouncementsOnly();
void removeAddBannedMembers(int i);
void removeDeleteBannedMembers(int i);
void removePromotePendingPniAciMembers(int i);
}

View file

@ -0,0 +1,52 @@
package org.whispersystems.signalservice.api.groupsv2;
import org.signal.libsignal.zkgroup.InvalidInputException;
import org.signal.libsignal.zkgroup.ServerPublicParams;
import org.signal.libsignal.zkgroup.auth.ClientZkAuthOperations;
import org.signal.libsignal.zkgroup.profiles.ClientZkProfileOperations;
import org.signal.libsignal.zkgroup.receipts.ClientZkReceiptOperations;
import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration;
/**
* Contains access to all ZK group operations for the client.
* <p>
* Authorization and profile operations.
*/
public final class ClientZkOperations {
private final ClientZkAuthOperations clientZkAuthOperations;
private final ClientZkProfileOperations clientZkProfileOperations;
private final ClientZkReceiptOperations clientZkReceiptOperations;
private final ServerPublicParams serverPublicParams;
public ClientZkOperations(ServerPublicParams serverPublicParams) {
this.serverPublicParams = serverPublicParams;
this.clientZkAuthOperations = new ClientZkAuthOperations (serverPublicParams);
this.clientZkProfileOperations = new ClientZkProfileOperations(serverPublicParams);
this.clientZkReceiptOperations = new ClientZkReceiptOperations(serverPublicParams);
}
public static ClientZkOperations create(SignalServiceConfiguration configuration) {
try {
return new ClientZkOperations(new ServerPublicParams(configuration.getZkGroupServerPublicParams()));
} catch (InvalidInputException e) {
throw new AssertionError(e);
}
}
public ClientZkAuthOperations getAuthOperations() {
return clientZkAuthOperations;
}
public ClientZkProfileOperations getProfileOperations() {
return clientZkProfileOperations;
}
public ClientZkReceiptOperations getReceiptOperations() {
return clientZkReceiptOperations;
}
public ServerPublicParams getServerPublicParams() {
return serverPublicParams;
}
}

View file

@ -0,0 +1,20 @@
package org.whispersystems.signalservice.api.groupsv2;
import com.fasterxml.jackson.annotation.JsonProperty;
public class CredentialResponse {
@JsonProperty
private TemporalCredential[] credentials;
@JsonProperty
private TemporalCredential[] callLinkAuthCredentials;
public TemporalCredential[] getCredentials() {
return credentials;
}
public TemporalCredential[] getCallLinkAuthCredentials() {
return callLinkAuthCredentials;
}
}

Some files were not shown because too many files have changed in this diff Show more