Source added
This commit is contained in:
parent
b2864b500e
commit
ba28ca859e
8352 changed files with 1487182 additions and 1 deletions
1
libsignal-service/.gitignore
vendored
Normal file
1
libsignal-service/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/build
|
||||
115
libsignal-service/build.gradle.kts
Normal file
115
libsignal-service/build.gradle.kts
Normal 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)
|
||||
}
|
||||
7
libsignal-service/lint.xml
Normal file
7
libsignal-service/lint.xml
Normal 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>
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package org.whispersystems.signalservice.api;
|
||||
|
||||
public class ContentTooLargeException extends IllegalStateException {
|
||||
public ContentTooLargeException(long size) {
|
||||
super("Too large! Size: " + size + " bytes");
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package org.whispersystems.signalservice.api;
|
||||
|
||||
public final class SvrNoDataException extends Exception {
|
||||
|
||||
public SvrNoDataException() {
|
||||
}
|
||||
}
|
||||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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>?
|
||||
)
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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?
|
||||
)
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
@ -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?
|
||||
)
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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]!!
|
||||
}
|
||||
|
|
@ -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()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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?
|
||||
)
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
@ -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()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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!")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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!")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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];
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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 ""}")
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue