mirror of
https://github.com/AsamK/signal-cli
synced 2025-09-02 20:40:38 +00:00
try to merge again
This commit is contained in:
parent
6d18f311e6
commit
685fce477c
184 changed files with 14906 additions and 1705 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -10,3 +10,4 @@ local.properties
|
|||
.project
|
||||
.settings/
|
||||
out/
|
||||
.DS_Store
|
||||
|
|
67
CHANGELOG.md
67
CHANGELOG.md
|
@ -2,6 +2,73 @@
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.8.1] - 2021-03-02
|
||||
### Added
|
||||
- New dbus commands: updateProfile, listNumbers, getContactNumber, quitGroup, isContactBlocked, isGroupBlocked, isMember, joinGroup (Thanks @bublath)
|
||||
- Additional output for json format: shared contacts (Thanks @Atomic-Bean)
|
||||
- Improved plain text output to be more consistent and synced messages are now indented
|
||||
|
||||
### Fixed
|
||||
- Issue with broken sessions with linked devices
|
||||
|
||||
### Changed
|
||||
- Behavior of `trust` command improved, when trusting a new identity key all other known keys for
|
||||
the same number are removed.
|
||||
|
||||
## [0.8.0] - 2021-02-14
|
||||
**Attention**: For all signal protocol functionality an additional native library is now required: [libsignal-client](https://github.com/signalapp/libsignal-client/).
|
||||
See https://github.com/AsamK/signal-cli/wiki/Provide-native-lib-for-libsignal for more information.
|
||||
|
||||
### Added
|
||||
- Experimental support for building a GraalVM native image
|
||||
- Support for setting profile about text and emoji
|
||||
|
||||
### Fixed
|
||||
- Incorrect error message when removing a non-existent profile avatar
|
||||
|
||||
## [0.7.4] - 2021-01-19
|
||||
### Changed
|
||||
- Notify linked devices after profile has been updated
|
||||
|
||||
### Fixed
|
||||
- After registering a new account, receiving messages didn't work
|
||||
You may have to register and verify again to fix the issue.
|
||||
- Creating v1 groups works again
|
||||
|
||||
## [0.7.3] - 2021-01-17
|
||||
### Added
|
||||
- `getUserStatus` command to check if a user is registered on Signal (Thanks @Atomic-Bean)
|
||||
- Global `--verbose` flag to increase log level
|
||||
- Global `--output=json` flag, currently supported by `receive`, `daemon`, `getUserStatus`, `listGroups`
|
||||
- `--note-to-self` flag for `send` command to send a note to linked devices
|
||||
- More info for received messages in json output: stickers, viewOnce, typing, remoteDelete
|
||||
|
||||
### Changed
|
||||
- signal-cli can now be used without the username `-u` flag
|
||||
For daemon command all local users will be exposed as dbus objects.
|
||||
If only one local user exists, all other commands will use that user,
|
||||
otherwise a user has to be specified.
|
||||
- Messages sent to self number will be sent as normal Signal messages again, to
|
||||
send a sync message, use the new `--note-to-self` flag
|
||||
- Ignore messages with group context sent by non group member
|
||||
- Profile key is sent along with all direct messages
|
||||
- In json output unnecessary fields that are null are now omitted
|
||||
|
||||
### Fixed
|
||||
- Disable registration lock before removing the PIN
|
||||
- Fix PIN hash version to match the official clients.
|
||||
If you had previously set a PIN you need to set it again to be able to unlock the registration lock later.
|
||||
- Issue with saving account file after linking
|
||||
|
||||
## [0.7.2] - 2020-12-31
|
||||
### Added
|
||||
- Implement new registration lock PIN with `setPin` and `removePin` (with KBS)
|
||||
- Include quotes, mentions and reactions in json output (Thanks @Atomic-Bean)
|
||||
|
||||
### Fixed
|
||||
- Retrieve avatars for v2 groups
|
||||
- Download attachment thumbnail for quoted attachments
|
||||
|
||||
## [0.7.1] - 2020-12-21
|
||||
### Added
|
||||
- Accept group invitation with `updateGroup -g GROUP_ID`
|
||||
|
|
12
CONTRIBUTING.md
Normal file
12
CONTRIBUTING.md
Normal file
|
@ -0,0 +1,12 @@
|
|||
# Question
|
||||
If you have a question you can ask it in the [GitHub discussions page](https://github.com/AsamK/signal-cli/discussions)
|
||||
|
||||
# Report a bug
|
||||
- Search [existing issues](https://github.com/AsamK/signal-cli/issues?q=is%3Aissue) if it has been reported already
|
||||
- If you're unable to find an open issue addressing the problem, [open a new one](https://github.com/AsamK/signal-cli/issues/new).
|
||||
- Be sure to include a **title and clear description**, as much relevant information as possible.
|
||||
- Run the failing command with `--verbose` flag to get a more detailed log output and include that in the bug report
|
||||
|
||||
# Pull request
|
||||
- Code style should match the existing code, IntelliJ users can use the auto formatter
|
||||
- Separate PRs should be opened for each implemented feature or bug fix
|
1
FUNDING.yml
Normal file
1
FUNDING.yml
Normal file
|
@ -0,0 +1 @@
|
|||
liberapay: asamk
|
32
README.md
32
README.md
|
@ -19,11 +19,21 @@ expect the wiki and builds to be broken for now.
|
|||
signal-cli is a commandline interface for [libsignal-service-java](https://github.com/WhisperSystems/libsignal-service-java). It supports registering, verifying, sending and receiving messages.
|
||||
To be able to link to an existing Signal-Android/signal-cli instance, signal-cli uses a [patched libsignal-service-java](https://github.com/technillogue/libsignal-service-java), because libsignal-service-java does not yet support [provisioning as a slave device](https://github.com/WhisperSystems/libsignal-service-java/pull/21).
|
||||
For registering you need a phone number where you can receive SMS or incoming calls.
|
||||
signal-cli is primarily intended to be used on servers to notify admins of important events. For this use-case, it has a dbus interface, that can be used to send messages from any programming language that has dbus bindings.
|
||||
signal-cli is primarily intended to be used on servers to notify admins of important events. For this use-case, it has a dbus interface ([man page](https://github.com/AsamK/signal-cli/blob/master/man/signal-cli-dbus.5.adoc)), that can be used to send messages from any programming language that has dbus bindings.
|
||||
|
||||
## Installation
|
||||
|
||||
<<<<<<< HEAD
|
||||
You can [build signal-cli](#building) yourself, or use the [provided binary files](https://github.com/technillogue/signal-cli/releases/latest), which should work on Linux, macOS and Windows. For Arch Linux there is also a [package in AUR](https://aur.archlinux.org/packages/signal-cli/) and there is a [FreeBSD port](https://www.freshports.org/net-im/signal-cli) available as well. You need to have at least JRE 11 installed, to run signal-cli.
|
||||
=======
|
||||
You can [build signal-cli](#building) yourself, or use the [provided binary files](https://github.com/AsamK/signal-cli/releases/latest), which should work on Linux, macOS and Windows. For Arch Linux there is also a [package in AUR](https://aur.archlinux.org/packages/signal-cli/) and there is a [FreeBSD port](https://www.freshports.org/net-im/signal-cli) available as well.
|
||||
|
||||
System requirements:
|
||||
- at least Java Runtime Environment (JRE) 11
|
||||
- native libraries: libzkgroup, libsignal-client
|
||||
|
||||
Those are bundled for x86_64 Linux, for other systems/architectures see: [Provide native lib for libsignal](https://github.com/AsamK/signal-cli/wiki/Provide-native-lib-for-libsignal)
|
||||
>>>>>>> upstream/master
|
||||
|
||||
### Install system-wide on Linux
|
||||
See [latest version](https://github.com/technillogue/signal-cli/releases).
|
||||
|
@ -39,7 +49,9 @@ You can find further instructions on the Wiki:
|
|||
|
||||
## Usage
|
||||
|
||||
Important: The USERNAME (your phone number) must include the country calling code, i.e. the number must start with a "+" sign. (See [Wikipedia](https://en.wikipedia.org/wiki/List_of_country_calling_codes) for a list of all country codes.)
|
||||
For a complete usage overview please read the [man page](https://github.com/AsamK/signal-cli/blob/master/man/signal-cli.1.adoc) and the [wiki](https://github.com/AsamK/signal-cli/wiki).
|
||||
|
||||
Important: The USERNAME is your phone number in international format and must include the country calling code. Hence it should start with a "+" sign. (See [Wikipedia](https://en.wikipedia.org/wiki/List_of_country_calling_codes) for a list of all country codes.)
|
||||
|
||||
* Register a number (with SMS verification)
|
||||
|
||||
|
@ -63,7 +75,10 @@ Important: The USERNAME (your phone number) must include the country calling cod
|
|||
|
||||
signal-cli -u USERNAME receive
|
||||
|
||||
<<<<<<< HEAD
|
||||
For more information read the [man page](https://github.com/technillogue/signal-cli/blob/master/man/signal-cli.1.adoc) and the [wiki](https://github.com/technillogue/signal-cli/wiki).
|
||||
=======
|
||||
>>>>>>> upstream/master
|
||||
|
||||
## Storage
|
||||
|
||||
|
@ -98,6 +113,19 @@ dependencies. If you have a recent gradle version installed, you can replace `./
|
|||
|
||||
./gradlew distTar
|
||||
|
||||
### Building a native binary with GraalVM (EXPERIMENTAL)
|
||||
|
||||
It is possible to build a native binary with [GraalVM](https://www.graalvm.org).
|
||||
This is still experimental and will not work in all situations.
|
||||
|
||||
1. [Install GraalVM and setup the enviroment](https://www.graalvm.org/docs/getting-started/#install-graalvm)
|
||||
2. [Install prerequisites](https://www.graalvm.org/reference-manual/native-image/#prerequisites)
|
||||
3. Execute Gradle:
|
||||
|
||||
./gradlew assembleNativeImage
|
||||
|
||||
The binary is available at *build/native-image/signal-cli*
|
||||
|
||||
## Troubleshooting
|
||||
If you use a version of the Oracle JRE and get an InvalidKeyException you need to enable unlimited strength crypto. See https://stackoverflow.com/questions/6481627/java-security-illegal-key-size-or-default-parameters for instructions.
|
||||
|
||||
|
|
96
build.gradle.kts
Normal file
96
build.gradle.kts
Normal file
|
@ -0,0 +1,96 @@
|
|||
plugins {
|
||||
java
|
||||
application
|
||||
eclipse
|
||||
`check-lib-versions`
|
||||
}
|
||||
|
||||
version = "0.8.1"
|
||||
|
||||
java {
|
||||
sourceCompatibility = JavaVersion.VERSION_11
|
||||
targetCompatibility = JavaVersion.VERSION_11
|
||||
}
|
||||
|
||||
application {
|
||||
mainClass.set("org.asamk.signal.Main")
|
||||
}
|
||||
|
||||
repositories {
|
||||
mavenLocal()
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation("org.bouncycastle:bcprov-jdk15on:1.68")
|
||||
implementation("net.sourceforge.argparse4j:argparse4j:0.8.1")
|
||||
implementation("com.github.hypfvieh:dbus-java:3.2.4")
|
||||
implementation("org.slf4j:slf4j-simple:1.7.30")
|
||||
implementation(project(":lib"))
|
||||
}
|
||||
|
||||
configurations {
|
||||
implementation {
|
||||
resolutionStrategy.failOnVersionConflict()
|
||||
}
|
||||
}
|
||||
|
||||
tasks.withType<JavaCompile> {
|
||||
options.encoding = "UTF-8"
|
||||
}
|
||||
|
||||
tasks.withType<Jar> {
|
||||
manifest {
|
||||
attributes(
|
||||
"Implementation-Title" to project.name,
|
||||
"Implementation-Version" to project.version,
|
||||
"Main-Class" to application.mainClass.get()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
tasks.withType<JavaExec> {
|
||||
val appArgs: String? by project
|
||||
if (appArgs != null) {
|
||||
// allow passing command-line arguments to the main application e.g.:
|
||||
// $ gradle run -PappArgs="['-u', '+...', 'daemon', '--json']"
|
||||
args = groovy.util.Eval.me(appArgs) as MutableList<String>
|
||||
}
|
||||
}
|
||||
|
||||
val assembleNativeImage by tasks.registering {
|
||||
dependsOn("assemble")
|
||||
|
||||
var graalVMHome = ""
|
||||
doFirst {
|
||||
graalVMHome = System.getenv("GRAALVM_HOME")
|
||||
?: throw GradleException("Required GRAALVM_HOME environment variable not set.")
|
||||
}
|
||||
|
||||
doLast {
|
||||
val nativeBinaryOutputPath = "$buildDir/native-image"
|
||||
val nativeBinaryName = "signal-cli"
|
||||
|
||||
mkdir(nativeBinaryOutputPath)
|
||||
|
||||
exec {
|
||||
workingDir = File(".")
|
||||
commandLine("$graalVMHome/bin/native-image",
|
||||
"-H:Path=$nativeBinaryOutputPath",
|
||||
"-H:Name=$nativeBinaryName",
|
||||
"-H:JNIConfigurationFiles=graalvm-config-dir/jni-config.json",
|
||||
"-H:DynamicProxyConfigurationFiles=graalvm-config-dir/proxy-config.json",
|
||||
"-H:ResourceConfigurationFiles=graalvm-config-dir/resource-config.json",
|
||||
"-H:ReflectionConfigurationFiles=graalvm-config-dir/reflect-config.json",
|
||||
"--no-fallback",
|
||||
"--allow-incomplete-classpath",
|
||||
"--report-unsupported-elements-at-runtime",
|
||||
"--enable-url-protocols=http,https",
|
||||
"--enable-https",
|
||||
"--enable-all-security-services",
|
||||
"-cp",
|
||||
sourceSets.main.get().runtimeClasspath.asPath,
|
||||
application.mainClass.get())
|
||||
}
|
||||
}
|
||||
}
|
16
buildSrc/build.gradle.kts
Normal file
16
buildSrc/build.gradle.kts
Normal file
|
@ -0,0 +1,16 @@
|
|||
plugins {
|
||||
`kotlin-dsl`
|
||||
}
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
gradlePlugin {
|
||||
plugins {
|
||||
register("check-lib-versions") {
|
||||
id = "check-lib-versions"
|
||||
implementationClass = "CheckLibVersionsPlugin"
|
||||
}
|
||||
}
|
||||
}
|
39
buildSrc/src/main/kotlin/CheckLibVersionsPlugin.kt
Normal file
39
buildSrc/src/main/kotlin/CheckLibVersionsPlugin.kt
Normal file
|
@ -0,0 +1,39 @@
|
|||
import groovy.util.XmlSlurper
|
||||
import groovy.util.slurpersupport.GPathResult
|
||||
import org.codehaus.groovy.runtime.ResourceGroovyMethods
|
||||
import org.gradle.api.Plugin
|
||||
import org.gradle.api.Project
|
||||
import org.gradle.api.Task
|
||||
import org.gradle.api.artifacts.Dependency
|
||||
|
||||
class CheckLibVersionsPlugin : Plugin<Project> {
|
||||
override fun apply(project: Project) {
|
||||
project.task("checkLibVersions") {
|
||||
description = "Find any 3rd party libraries which have released new versions to the central Maven repo since we last upgraded."
|
||||
doLast {
|
||||
project.configurations.flatMap { it.allDependencies }
|
||||
.toSet()
|
||||
.forEach { checkDependency(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Task.checkDependency(dependency: Dependency) {
|
||||
val version = dependency.version
|
||||
val group = dependency.group
|
||||
val path = group?.replace(".", "/") ?: ""
|
||||
val name = dependency.name
|
||||
val metaDataUrl = "https://repo1.maven.org/maven2/$path/$name/maven-metadata.xml"
|
||||
try {
|
||||
val url = ResourceGroovyMethods.toURL(metaDataUrl)
|
||||
val metaDataText = ResourceGroovyMethods.getText(url)
|
||||
val metadata = XmlSlurper().parseText(metaDataText)
|
||||
val newest = (metadata.getProperty("versioning") as GPathResult).getProperty("latest")
|
||||
if (version != newest.toString()) {
|
||||
println("UPGRADE {\"group\": \"$group\", \"name\": \"$name\", \"current\": \"$version\", \"latest\": \"$newest\"}")
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
logger.debug("Unable to download or parse $metaDataUrl: $e.message")
|
||||
}
|
||||
}
|
||||
}
|
16
data/signal-cli.service
Normal file
16
data/signal-cli.service
Normal file
|
@ -0,0 +1,16 @@
|
|||
[Unit]
|
||||
Description=Send secure messages to Signal clients
|
||||
Requires=dbus.socket
|
||||
After=dbus.socket
|
||||
Wants=network-online.target
|
||||
After=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=dbus
|
||||
Environment="SIGNAL_CLI_OPTS=-Xms2m"
|
||||
ExecStart=%dir%/bin/signal-cli --config /var/lib/signal-cli daemon --system
|
||||
User=signal-cli
|
||||
BusName=org.asamk.Signal
|
||||
|
||||
[Install]
|
||||
Alias=dbus-org.asamk.Signal.service
|
|
@ -13,4 +13,4 @@ User=signal-cli
|
|||
BusName=org.asamk.Signal
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
Alias=dbus-org.asamk.Signal.service
|
||||
|
|
97
graalvm-config-dir/jni-config.json
Normal file
97
graalvm-config-dir/jni-config.json
Normal file
|
@ -0,0 +1,97 @@
|
|||
[
|
||||
{
|
||||
"name":"java.lang.ClassLoader",
|
||||
"methods":[{"name":"getPlatformClassLoader","parameterTypes":[] }]
|
||||
},
|
||||
{
|
||||
"name":"java.lang.IllegalStateException",
|
||||
"methods":[{"name":"<init>","parameterTypes":["java.lang.String"] }]
|
||||
},
|
||||
{
|
||||
"name":"java.lang.NoSuchMethodError"
|
||||
},
|
||||
{
|
||||
"name":"java.lang.UnsatisfiedLinkError",
|
||||
"methods":[{"name":"<init>","parameterTypes":["java.lang.String"] }]
|
||||
},
|
||||
{
|
||||
"name":"org.asamk.signal.manager.storage.protocol.JsonSignalProtocolStore",
|
||||
"methods":[
|
||||
{"name":"getIdentity","parameterTypes":["org.whispersystems.libsignal.SignalProtocolAddress"] },
|
||||
{"name":"getIdentityKeyPair","parameterTypes":[] },
|
||||
{"name":"getLocalRegistrationId","parameterTypes":[] },
|
||||
{"name":"isTrustedIdentity","parameterTypes":["org.whispersystems.libsignal.SignalProtocolAddress","org.whispersystems.libsignal.IdentityKey","org.whispersystems.libsignal.state.IdentityKeyStore$Direction"] },
|
||||
{"name":"loadPreKey","parameterTypes":["int"] },
|
||||
{"name":"loadSession","parameterTypes":["org.whispersystems.libsignal.SignalProtocolAddress"] },
|
||||
{"name":"loadSignedPreKey","parameterTypes":["int"] },
|
||||
{"name":"removePreKey","parameterTypes":["int"] },
|
||||
{"name":"saveIdentity","parameterTypes":["org.whispersystems.libsignal.SignalProtocolAddress","org.whispersystems.libsignal.IdentityKey"] },
|
||||
{"name":"storeSession","parameterTypes":["org.whispersystems.libsignal.SignalProtocolAddress","org.whispersystems.libsignal.state.SessionRecord"] }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name":"org.whispersystems.libsignal.DuplicateMessageException",
|
||||
"methods":[{"name":"<init>","parameterTypes":["java.lang.String"] }]
|
||||
},
|
||||
{
|
||||
"name":"org.whispersystems.libsignal.IdentityKey",
|
||||
"methods":[
|
||||
{"name":"<init>","parameterTypes":["byte[]"] },
|
||||
{"name":"serialize","parameterTypes":[] }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name":"org.whispersystems.libsignal.IdentityKeyPair",
|
||||
"methods":[{"name":"serialize","parameterTypes":[] }]
|
||||
},
|
||||
{
|
||||
"name":"org.whispersystems.libsignal.InvalidMessageException",
|
||||
"methods":[{"name":"<init>","parameterTypes":["java.lang.String"] }]
|
||||
},
|
||||
{
|
||||
"name":"org.whispersystems.libsignal.SignalProtocolAddress",
|
||||
"methods":[{"name":"<init>","parameterTypes":["java.lang.String","int"] }]
|
||||
},
|
||||
{
|
||||
"name":"org.whispersystems.libsignal.protocol.PreKeySignalMessage",
|
||||
"methods":[{"name":"<init>","parameterTypes":["long"] }]
|
||||
},
|
||||
{
|
||||
"name":"org.whispersystems.libsignal.protocol.SignalMessage",
|
||||
"methods":[{"name":"<init>","parameterTypes":["long"] }]
|
||||
},
|
||||
{
|
||||
"name":"org.whispersystems.libsignal.state.IdentityKeyStore"
|
||||
},
|
||||
{
|
||||
"name":"org.whispersystems.libsignal.state.IdentityKeyStore$Direction",
|
||||
"fields":[
|
||||
{"name":"RECEIVING"},
|
||||
{"name":"SENDING"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name":"org.whispersystems.libsignal.state.PreKeyRecord",
|
||||
"methods":[{"name":"nativeHandle","parameterTypes":[] }]
|
||||
},
|
||||
{
|
||||
"name":"org.whispersystems.libsignal.state.PreKeyStore"
|
||||
},
|
||||
{
|
||||
"name":"org.whispersystems.libsignal.state.SessionRecord",
|
||||
"methods":[
|
||||
{"name":"<init>","parameterTypes":["byte[]"] },
|
||||
{"name":"nativeHandle","parameterTypes":[] }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name":"org.whispersystems.libsignal.state.SessionStore"
|
||||
},
|
||||
{
|
||||
"name":"org.whispersystems.libsignal.state.SignedPreKeyRecord",
|
||||
"methods":[{"name":"nativeHandle","parameterTypes":[] }]
|
||||
},
|
||||
{
|
||||
"name":"org.whispersystems.libsignal.state.SignedPreKeyStore"
|
||||
}
|
||||
]
|
3
graalvm-config-dir/proxy-config.json
Normal file
3
graalvm-config-dir/proxy-config.json
Normal file
|
@ -0,0 +1,3 @@
|
|||
[
|
||||
["org.freedesktop.DBus"]
|
||||
]
|
2018
graalvm-config-dir/reflect-config.json
Normal file
2018
graalvm-config-dir/reflect-config.json
Normal file
File diff suppressed because it is too large
Load diff
13
graalvm-config-dir/resource-config.json
Normal file
13
graalvm-config-dir/resource-config.json
Normal file
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"resources":{
|
||||
"includes":[
|
||||
{"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_DE\\E"},
|
||||
{"pattern":"\\Qlibsignal_jni.so\\E"},
|
||||
{"pattern":"\\Qlibzkgroup.so\\E"},
|
||||
{"pattern":"\\Qorg/asamk/signal/manager/config/ias.store\\E"},
|
||||
{"pattern":"\\Qorg/asamk/signal/manager/config/whisper.store\\E"},
|
||||
{"pattern":"\\Qorg/slf4j/impl/StaticLoggerBinder.class\\E"},
|
||||
{"pattern":"com/google/i18n/phonenumbers/data/.*"}
|
||||
]},
|
||||
"bundles":[{"name":"net.sourceforge.argparse4j.internal.ArgumentParserImpl"}]
|
||||
}
|
2
graalvm-config-dir/serialization-config.json
Normal file
2
graalvm-config-dir/serialization-config.json
Normal file
|
@ -0,0 +1,2 @@
|
|||
[
|
||||
]
|
2
gradle/wrapper/gradle-wrapper.properties
vendored
2
gradle/wrapper/gradle-wrapper.properties
vendored
|
@ -1,5 +1,5 @@
|
|||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-6.6.1-bin.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-7.0-bin.zip
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
|
|
31
lib/build.gradle.kts
Normal file
31
lib/build.gradle.kts
Normal file
|
@ -0,0 +1,31 @@
|
|||
plugins {
|
||||
`java-library`
|
||||
`check-lib-versions`
|
||||
}
|
||||
|
||||
java {
|
||||
sourceCompatibility = JavaVersion.VERSION_11
|
||||
targetCompatibility = JavaVersion.VERSION_11
|
||||
}
|
||||
|
||||
repositories {
|
||||
mavenLocal()
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
api("com.github.turasa:signal-service-java:2.15.3_unofficial_19")
|
||||
implementation("com.google.protobuf:protobuf-javalite:3.10.0")
|
||||
implementation("org.bouncycastle:bcprov-jdk15on:1.68")
|
||||
implementation("org.slf4j:slf4j-api:1.7.30")
|
||||
}
|
||||
|
||||
configurations {
|
||||
implementation {
|
||||
resolutionStrategy.failOnVersionConflict()
|
||||
}
|
||||
}
|
||||
|
||||
tasks.withType<JavaCompile> {
|
||||
options.encoding = "UTF-8"
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
package org.asamk.signal.manager;
|
||||
|
||||
public class AttachmentInvalidException extends Exception {
|
||||
|
||||
public AttachmentInvalidException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public AttachmentInvalidException(String attachment, Exception e) {
|
||||
super(attachment + ": " + e.getMessage());
|
||||
}
|
||||
}
|
|
@ -0,0 +1,55 @@
|
|||
package org.asamk.signal.manager;
|
||||
|
||||
import org.asamk.signal.manager.util.IOUtils;
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentRemoteId;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
|
||||
public class AttachmentStore {
|
||||
|
||||
private final File attachmentsPath;
|
||||
|
||||
public AttachmentStore(final File attachmentsPath) {
|
||||
this.attachmentsPath = attachmentsPath;
|
||||
}
|
||||
|
||||
public void storeAttachmentPreview(
|
||||
final SignalServiceAttachmentRemoteId attachmentId, final AttachmentStorer storer
|
||||
) throws IOException {
|
||||
storeAttachment(getAttachmentPreviewFile(attachmentId), storer);
|
||||
}
|
||||
|
||||
public void storeAttachment(
|
||||
final SignalServiceAttachmentRemoteId attachmentId, final AttachmentStorer storer
|
||||
) throws IOException {
|
||||
storeAttachment(getAttachmentFile(attachmentId), storer);
|
||||
}
|
||||
|
||||
private void storeAttachment(final File attachmentFile, final AttachmentStorer storer) throws IOException {
|
||||
createAttachmentsDir();
|
||||
try (OutputStream output = new FileOutputStream(attachmentFile)) {
|
||||
storer.store(output);
|
||||
}
|
||||
}
|
||||
|
||||
private File getAttachmentPreviewFile(SignalServiceAttachmentRemoteId attachmentId) {
|
||||
return new File(attachmentsPath, attachmentId.toString() + ".preview");
|
||||
}
|
||||
|
||||
public File getAttachmentFile(SignalServiceAttachmentRemoteId attachmentId) {
|
||||
return new File(attachmentsPath, attachmentId.toString());
|
||||
}
|
||||
|
||||
private void createAttachmentsDir() throws IOException {
|
||||
IOUtils.createPrivateDirectories(attachmentsPath);
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
public interface AttachmentStorer {
|
||||
|
||||
void store(OutputStream outputStream) throws IOException;
|
||||
}
|
||||
}
|
93
lib/src/main/java/org/asamk/signal/manager/AvatarStore.java
Normal file
93
lib/src/main/java/org/asamk/signal/manager/AvatarStore.java
Normal file
|
@ -0,0 +1,93 @@
|
|||
package org.asamk.signal.manager;
|
||||
|
||||
import org.asamk.signal.manager.groups.GroupId;
|
||||
import org.asamk.signal.manager.util.IOUtils;
|
||||
import org.asamk.signal.manager.util.Utils;
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
|
||||
import org.whispersystems.signalservice.api.util.StreamDetails;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.nio.file.Files;
|
||||
|
||||
public class AvatarStore {
|
||||
|
||||
private final File avatarsPath;
|
||||
|
||||
public AvatarStore(final File avatarsPath) {
|
||||
this.avatarsPath = avatarsPath;
|
||||
}
|
||||
|
||||
public StreamDetails retrieveContactAvatar(SignalServiceAddress address) throws IOException {
|
||||
return retrieveAvatar(getContactAvatarFile(address));
|
||||
}
|
||||
|
||||
public StreamDetails retrieveProfileAvatar(SignalServiceAddress address) throws IOException {
|
||||
return retrieveAvatar(getProfileAvatarFile(address));
|
||||
}
|
||||
|
||||
public StreamDetails retrieveGroupAvatar(GroupId groupId) throws IOException {
|
||||
final var groupAvatarFile = getGroupAvatarFile(groupId);
|
||||
return retrieveAvatar(groupAvatarFile);
|
||||
}
|
||||
|
||||
public void storeContactAvatar(SignalServiceAddress address, AvatarStorer storer) throws IOException {
|
||||
storeAvatar(getContactAvatarFile(address), storer);
|
||||
}
|
||||
|
||||
public void storeProfileAvatar(SignalServiceAddress address, AvatarStorer storer) throws IOException {
|
||||
storeAvatar(getProfileAvatarFile(address), storer);
|
||||
}
|
||||
|
||||
public void storeGroupAvatar(GroupId groupId, AvatarStorer storer) throws IOException {
|
||||
storeAvatar(getGroupAvatarFile(groupId), storer);
|
||||
}
|
||||
|
||||
public void deleteProfileAvatar(SignalServiceAddress address) throws IOException {
|
||||
deleteAvatar(getProfileAvatarFile(address));
|
||||
}
|
||||
|
||||
private StreamDetails retrieveAvatar(final File avatarFile) throws IOException {
|
||||
if (!avatarFile.exists()) {
|
||||
return null;
|
||||
}
|
||||
return Utils.createStreamDetailsFromFile(avatarFile);
|
||||
}
|
||||
|
||||
private void storeAvatar(final File avatarFile, final AvatarStorer storer) throws IOException {
|
||||
createAvatarsDir();
|
||||
try (OutputStream output = new FileOutputStream(avatarFile)) {
|
||||
storer.store(output);
|
||||
}
|
||||
}
|
||||
|
||||
private void deleteAvatar(final File avatarFile) throws IOException {
|
||||
if (avatarFile.exists()) {
|
||||
Files.delete(avatarFile.toPath());
|
||||
}
|
||||
}
|
||||
|
||||
private File getGroupAvatarFile(GroupId groupId) {
|
||||
return new File(avatarsPath, "group-" + groupId.toBase64().replace("/", "_"));
|
||||
}
|
||||
|
||||
private File getContactAvatarFile(SignalServiceAddress address) {
|
||||
return new File(avatarsPath, "contact-" + address.getLegacyIdentifier());
|
||||
}
|
||||
|
||||
private File getProfileAvatarFile(SignalServiceAddress address) {
|
||||
return new File(avatarsPath, "profile-" + address.getLegacyIdentifier());
|
||||
}
|
||||
|
||||
private void createAvatarsDir() throws IOException {
|
||||
IOUtils.createPrivateDirectories(avatarsPath);
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
public interface AvatarStorer {
|
||||
|
||||
void store(OutputStream outputStream) throws IOException;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,76 @@
|
|||
package org.asamk.signal.manager;
|
||||
|
||||
import org.whispersystems.libsignal.InvalidKeyException;
|
||||
import org.whispersystems.libsignal.ecc.Curve;
|
||||
import org.whispersystems.libsignal.ecc.ECPublicKey;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
import java.net.URLDecoder;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Base64;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.whispersystems.signalservice.internal.util.Util.isEmpty;
|
||||
|
||||
public class DeviceLinkInfo {
|
||||
|
||||
final String deviceIdentifier;
|
||||
final ECPublicKey deviceKey;
|
||||
|
||||
public static DeviceLinkInfo parseDeviceLinkUri(URI linkUri) throws InvalidKeyException {
|
||||
final var rawQuery = linkUri.getRawQuery();
|
||||
if (isEmpty(rawQuery)) {
|
||||
throw new RuntimeException("Invalid device link uri");
|
||||
}
|
||||
|
||||
var query = getQueryMap(rawQuery);
|
||||
var deviceIdentifier = query.get("uuid");
|
||||
var publicKeyEncoded = query.get("pub_key");
|
||||
|
||||
if (isEmpty(deviceIdentifier) || isEmpty(publicKeyEncoded)) {
|
||||
throw new RuntimeException("Invalid device link uri");
|
||||
}
|
||||
|
||||
final byte[] publicKeyBytes;
|
||||
try {
|
||||
publicKeyBytes = Base64.getDecoder().decode(publicKeyEncoded);
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw new RuntimeException("Invalid device link uri", e);
|
||||
}
|
||||
var deviceKey = Curve.decodePoint(publicKeyBytes, 0);
|
||||
|
||||
return new DeviceLinkInfo(deviceIdentifier, deviceKey);
|
||||
}
|
||||
|
||||
private static Map<String, String> getQueryMap(String query) {
|
||||
var params = query.split("&");
|
||||
var map = new HashMap<String, String>();
|
||||
for (var param : params) {
|
||||
final var paramParts = param.split("=");
|
||||
var name = URLDecoder.decode(paramParts[0], StandardCharsets.UTF_8);
|
||||
var value = URLDecoder.decode(paramParts[1], StandardCharsets.UTF_8);
|
||||
map.put(name, value);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
public DeviceLinkInfo(final String deviceIdentifier, final ECPublicKey deviceKey) {
|
||||
this.deviceIdentifier = deviceIdentifier;
|
||||
this.deviceKey = deviceKey;
|
||||
}
|
||||
|
||||
public URI createDeviceLinkUri() {
|
||||
final var deviceKeyString = Base64.getEncoder().encodeToString(deviceKey.serialize()).replace("=", "");
|
||||
try {
|
||||
return new URI("tsdevice:/?uuid="
|
||||
+ URLEncoder.encode(deviceIdentifier, StandardCharsets.UTF_8)
|
||||
+ "&pub_key="
|
||||
+ URLEncoder.encode(deviceKeyString, StandardCharsets.UTF_8));
|
||||
} catch (URISyntaxException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
}
|
159
lib/src/main/java/org/asamk/signal/manager/HandleAction.java
Normal file
159
lib/src/main/java/org/asamk/signal/manager/HandleAction.java
Normal file
|
@ -0,0 +1,159 @@
|
|||
package org.asamk.signal.manager;
|
||||
|
||||
import org.asamk.signal.manager.groups.GroupIdV1;
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
interface HandleAction {
|
||||
|
||||
void execute(Manager m) throws Throwable;
|
||||
}
|
||||
|
||||
class SendReceiptAction implements HandleAction {
|
||||
|
||||
private final SignalServiceAddress address;
|
||||
private final long timestamp;
|
||||
|
||||
public SendReceiptAction(final SignalServiceAddress address, final long timestamp) {
|
||||
this.address = address;
|
||||
this.timestamp = timestamp;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void execute(Manager m) throws Throwable {
|
||||
m.sendReceipt(address, timestamp);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(final Object o) {
|
||||
if (this == o) return true;
|
||||
if (o == null || getClass() != o.getClass()) return false;
|
||||
final var that = (SendReceiptAction) o;
|
||||
return timestamp == that.timestamp && address.equals(that.address);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(address, timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
class SendSyncContactsAction implements HandleAction {
|
||||
|
||||
private static final SendSyncContactsAction INSTANCE = new SendSyncContactsAction();
|
||||
|
||||
private SendSyncContactsAction() {
|
||||
}
|
||||
|
||||
public static SendSyncContactsAction create() {
|
||||
return INSTANCE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void execute(Manager m) throws Throwable {
|
||||
m.sendContacts();
|
||||
}
|
||||
}
|
||||
|
||||
class SendSyncGroupsAction implements HandleAction {
|
||||
|
||||
private static final SendSyncGroupsAction INSTANCE = new SendSyncGroupsAction();
|
||||
|
||||
private SendSyncGroupsAction() {
|
||||
}
|
||||
|
||||
public static SendSyncGroupsAction create() {
|
||||
return INSTANCE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void execute(Manager m) throws Throwable {
|
||||
m.sendGroups();
|
||||
}
|
||||
}
|
||||
|
||||
class SendSyncBlockedListAction implements HandleAction {
|
||||
|
||||
private static final SendSyncBlockedListAction INSTANCE = new SendSyncBlockedListAction();
|
||||
|
||||
private SendSyncBlockedListAction() {
|
||||
}
|
||||
|
||||
public static SendSyncBlockedListAction create() {
|
||||
return INSTANCE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void execute(Manager m) throws Throwable {
|
||||
m.sendBlockedList();
|
||||
}
|
||||
}
|
||||
|
||||
class SendGroupInfoRequestAction implements HandleAction {
|
||||
|
||||
private final SignalServiceAddress address;
|
||||
private final GroupIdV1 groupId;
|
||||
|
||||
public SendGroupInfoRequestAction(final SignalServiceAddress address, final GroupIdV1 groupId) {
|
||||
this.address = address;
|
||||
this.groupId = groupId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void execute(Manager m) throws Throwable {
|
||||
m.sendGroupInfoRequest(groupId, address);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(final Object o) {
|
||||
if (this == o) return true;
|
||||
if (o == null || getClass() != o.getClass()) return false;
|
||||
|
||||
final var that = (SendGroupInfoRequestAction) o;
|
||||
|
||||
if (!address.equals(that.address)) return false;
|
||||
return groupId.equals(that.groupId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
var result = address.hashCode();
|
||||
result = 31 * result + groupId.hashCode();
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
class SendGroupInfoAction implements HandleAction {
|
||||
|
||||
private final SignalServiceAddress address;
|
||||
private final GroupIdV1 groupId;
|
||||
|
||||
public SendGroupInfoAction(final SignalServiceAddress address, final GroupIdV1 groupId) {
|
||||
this.address = address;
|
||||
this.groupId = groupId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void execute(Manager m) throws Throwable {
|
||||
m.sendGroupInfoMessage(groupId, address);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(final Object o) {
|
||||
if (this == o) return true;
|
||||
if (o == null || getClass() != o.getClass()) return false;
|
||||
|
||||
final var that = (SendGroupInfoAction) o;
|
||||
|
||||
if (!address.equals(that.address)) return false;
|
||||
return groupId.equals(that.groupId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
var result = address.hashCode();
|
||||
result = 31 * result + groupId.hashCode();
|
||||
return result;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,29 @@
|
|||
package org.asamk.signal.manager;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public class JsonStickerPack {
|
||||
|
||||
@JsonProperty
|
||||
public String title;
|
||||
|
||||
@JsonProperty
|
||||
public String author;
|
||||
|
||||
@JsonProperty
|
||||
public JsonSticker cover;
|
||||
|
||||
@JsonProperty
|
||||
public List<JsonSticker> stickers;
|
||||
|
||||
public static class JsonSticker {
|
||||
|
||||
@JsonProperty
|
||||
public String emoji;
|
||||
|
||||
@JsonProperty
|
||||
public String file;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,41 @@
|
|||
package org.asamk.signal.manager;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.whispersystems.libsignal.logging.SignalProtocolLogger;
|
||||
import org.whispersystems.libsignal.logging.SignalProtocolLoggerProvider;
|
||||
|
||||
public class LibSignalLogger implements SignalProtocolLogger {
|
||||
|
||||
private final static Logger logger = LoggerFactory.getLogger("LibSignal");
|
||||
|
||||
public static void initLogger() {
|
||||
SignalProtocolLoggerProvider.setProvider(new LibSignalLogger());
|
||||
}
|
||||
|
||||
private LibSignalLogger() {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void log(final int priority, final String tag, final String message) {
|
||||
final var logMessage = String.format("[%s]: %s", tag, message);
|
||||
switch (priority) {
|
||||
case SignalProtocolLogger.VERBOSE:
|
||||
logger.trace(logMessage);
|
||||
break;
|
||||
case SignalProtocolLogger.DEBUG:
|
||||
logger.debug(logMessage);
|
||||
break;
|
||||
case SignalProtocolLogger.INFO:
|
||||
logger.info(logMessage);
|
||||
break;
|
||||
case SignalProtocolLogger.WARN:
|
||||
logger.warn(logMessage);
|
||||
break;
|
||||
case SignalProtocolLogger.ERROR:
|
||||
case SignalProtocolLogger.ASSERT:
|
||||
logger.error(logMessage);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
2664
lib/src/main/java/org/asamk/signal/manager/Manager.java
Normal file
2664
lib/src/main/java/org/asamk/signal/manager/Manager.java
Normal file
File diff suppressed because it is too large
Load diff
|
@ -0,0 +1,8 @@
|
|||
package org.asamk.signal.manager;
|
||||
|
||||
public class NotRegisteredException extends Exception {
|
||||
|
||||
public NotRegisteredException() {
|
||||
super("User is not registered.");
|
||||
}
|
||||
}
|
34
lib/src/main/java/org/asamk/signal/manager/PathConfig.java
Normal file
34
lib/src/main/java/org/asamk/signal/manager/PathConfig.java
Normal file
|
@ -0,0 +1,34 @@
|
|||
package org.asamk.signal.manager;
|
||||
|
||||
import java.io.File;
|
||||
|
||||
public class PathConfig {
|
||||
|
||||
private final File dataPath;
|
||||
private final File attachmentsPath;
|
||||
private final File avatarsPath;
|
||||
|
||||
public static PathConfig createDefault(final File settingsPath) {
|
||||
return new PathConfig(new File(settingsPath, "data"),
|
||||
new File(settingsPath, "attachments"),
|
||||
new File(settingsPath, "avatars"));
|
||||
}
|
||||
|
||||
private PathConfig(final File dataPath, final File attachmentsPath, final File avatarsPath) {
|
||||
this.dataPath = dataPath;
|
||||
this.attachmentsPath = attachmentsPath;
|
||||
this.avatarsPath = avatarsPath;
|
||||
}
|
||||
|
||||
public File getDataPath() {
|
||||
return dataPath;
|
||||
}
|
||||
|
||||
public File getAttachmentsPath() {
|
||||
return attachmentsPath;
|
||||
}
|
||||
|
||||
public File getAvatarsPath() {
|
||||
return avatarsPath;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,170 @@
|
|||
/*
|
||||
Copyright (C) 2015-2021 AsamK and contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.asamk.signal.manager;
|
||||
|
||||
import org.asamk.signal.manager.config.ServiceConfig;
|
||||
import org.asamk.signal.manager.config.ServiceEnvironment;
|
||||
import org.asamk.signal.manager.config.ServiceEnvironmentConfig;
|
||||
import org.asamk.signal.manager.storage.SignalAccount;
|
||||
import org.asamk.signal.manager.util.KeyUtils;
|
||||
import org.signal.zkgroup.InvalidInputException;
|
||||
import org.signal.zkgroup.profiles.ProfileKey;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.whispersystems.libsignal.IdentityKeyPair;
|
||||
import org.whispersystems.libsignal.InvalidKeyException;
|
||||
import org.whispersystems.libsignal.util.KeyHelper;
|
||||
import org.whispersystems.signalservice.api.SignalServiceAccountManager;
|
||||
import org.whispersystems.signalservice.api.groupsv2.ClientZkOperations;
|
||||
import org.whispersystems.signalservice.api.groupsv2.GroupsV2Operations;
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
|
||||
import org.whispersystems.signalservice.api.util.SleepTimer;
|
||||
import org.whispersystems.signalservice.api.util.UptimeSleepTimer;
|
||||
import org.whispersystems.signalservice.internal.util.DynamicCredentialsProvider;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
|
||||
public class ProvisioningManager {
|
||||
|
||||
private final static Logger logger = LoggerFactory.getLogger(ProvisioningManager.class);
|
||||
|
||||
private final PathConfig pathConfig;
|
||||
private final ServiceEnvironmentConfig serviceEnvironmentConfig;
|
||||
private final String userAgent;
|
||||
|
||||
private final SignalServiceAccountManager accountManager;
|
||||
private final IdentityKeyPair identityKey;
|
||||
private final int registrationId;
|
||||
private final String password;
|
||||
|
||||
ProvisioningManager(PathConfig pathConfig, ServiceEnvironmentConfig serviceEnvironmentConfig, String userAgent) {
|
||||
this.pathConfig = pathConfig;
|
||||
this.serviceEnvironmentConfig = serviceEnvironmentConfig;
|
||||
this.userAgent = userAgent;
|
||||
|
||||
identityKey = KeyUtils.generateIdentityKeyPair();
|
||||
registrationId = KeyHelper.generateRegistrationId(false);
|
||||
password = KeyUtils.createPassword();
|
||||
final SleepTimer timer = new UptimeSleepTimer();
|
||||
GroupsV2Operations groupsV2Operations;
|
||||
try {
|
||||
groupsV2Operations = new GroupsV2Operations(ClientZkOperations.create(serviceEnvironmentConfig.getSignalServiceConfiguration()));
|
||||
} catch (Throwable ignored) {
|
||||
groupsV2Operations = null;
|
||||
}
|
||||
accountManager = new SignalServiceAccountManager(serviceEnvironmentConfig.getSignalServiceConfiguration(),
|
||||
new DynamicCredentialsProvider(null, null, password, SignalServiceAddress.DEFAULT_DEVICE_ID),
|
||||
userAgent,
|
||||
groupsV2Operations,
|
||||
ServiceConfig.AUTOMATIC_NETWORK_RETRY,
|
||||
timer);
|
||||
}
|
||||
|
||||
public static ProvisioningManager init(
|
||||
File settingsPath, ServiceEnvironment serviceEnvironment, String userAgent
|
||||
) {
|
||||
var pathConfig = PathConfig.createDefault(settingsPath);
|
||||
|
||||
final var serviceConfiguration = ServiceConfig.getServiceEnvironmentConfig(serviceEnvironment, userAgent);
|
||||
|
||||
return new ProvisioningManager(pathConfig, serviceConfiguration, userAgent);
|
||||
}
|
||||
|
||||
public URI getDeviceLinkUri() throws TimeoutException, IOException {
|
||||
var deviceUuid = accountManager.getNewDeviceUuid();
|
||||
|
||||
return new DeviceLinkInfo(deviceUuid, identityKey.getPublicKey().getPublicKey()).createDeviceLinkUri();
|
||||
}
|
||||
|
||||
public Manager finishDeviceLink(String deviceName) throws IOException, InvalidKeyException, TimeoutException, UserAlreadyExists {
|
||||
var ret = accountManager.finishNewDeviceRegistration(identityKey, false, true, registrationId, deviceName);
|
||||
|
||||
var username = ret.getNumber();
|
||||
// TODO do this check before actually registering
|
||||
if (SignalAccount.userExists(pathConfig.getDataPath(), username)) {
|
||||
throw new UserAlreadyExists(username, SignalAccount.getFileName(pathConfig.getDataPath(), username));
|
||||
}
|
||||
|
||||
// Create new account with the synced identity
|
||||
var profileKeyBytes = ret.getProfileKey();
|
||||
ProfileKey profileKey;
|
||||
if (profileKeyBytes == null) {
|
||||
profileKey = KeyUtils.createProfileKey();
|
||||
} else {
|
||||
try {
|
||||
profileKey = new ProfileKey(profileKeyBytes);
|
||||
} catch (InvalidInputException e) {
|
||||
throw new IOException("Received invalid profileKey", e);
|
||||
}
|
||||
}
|
||||
|
||||
SignalAccount account = null;
|
||||
try {
|
||||
account = SignalAccount.createLinkedAccount(pathConfig.getDataPath(),
|
||||
username,
|
||||
ret.getUuid(),
|
||||
password,
|
||||
ret.getDeviceId(),
|
||||
ret.getIdentity(),
|
||||
registrationId,
|
||||
profileKey);
|
||||
account.save();
|
||||
|
||||
Manager m = null;
|
||||
try {
|
||||
m = new Manager(account, pathConfig, serviceEnvironmentConfig, userAgent);
|
||||
|
||||
try {
|
||||
m.refreshPreKeys();
|
||||
} catch (Exception e) {
|
||||
logger.error("Failed to refresh prekeys.");
|
||||
throw e;
|
||||
}
|
||||
|
||||
try {
|
||||
m.requestSyncGroups();
|
||||
m.requestSyncContacts();
|
||||
m.requestSyncBlocked();
|
||||
m.requestSyncConfiguration();
|
||||
m.requestSyncKeys();
|
||||
} catch (Exception e) {
|
||||
logger.error("Failed to request sync messages from linked device.");
|
||||
throw e;
|
||||
}
|
||||
|
||||
account.save();
|
||||
|
||||
final var result = m;
|
||||
account = null;
|
||||
m = null;
|
||||
|
||||
return result;
|
||||
} finally {
|
||||
if (m != null) {
|
||||
m.close();
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (account != null) {
|
||||
account.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,214 @@
|
|||
/*
|
||||
Copyright (C) 2015-2021 AsamK and contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.asamk.signal.manager;
|
||||
|
||||
import org.asamk.signal.manager.config.ServiceConfig;
|
||||
import org.asamk.signal.manager.config.ServiceEnvironment;
|
||||
import org.asamk.signal.manager.config.ServiceEnvironmentConfig;
|
||||
import org.asamk.signal.manager.helper.PinHelper;
|
||||
import org.asamk.signal.manager.storage.SignalAccount;
|
||||
import org.asamk.signal.manager.util.KeyUtils;
|
||||
import org.whispersystems.libsignal.util.KeyHelper;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
import org.whispersystems.signalservice.api.KeyBackupServicePinException;
|
||||
import org.whispersystems.signalservice.api.KeyBackupSystemNoDataException;
|
||||
import org.whispersystems.signalservice.api.SignalServiceAccountManager;
|
||||
import org.whispersystems.signalservice.api.groupsv2.ClientZkOperations;
|
||||
import org.whispersystems.signalservice.api.groupsv2.GroupsV2Operations;
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
|
||||
import org.whispersystems.signalservice.api.util.SleepTimer;
|
||||
import org.whispersystems.signalservice.api.util.UptimeSleepTimer;
|
||||
import org.whispersystems.signalservice.api.util.UuidUtil;
|
||||
import org.whispersystems.signalservice.internal.push.LockedException;
|
||||
import org.whispersystems.signalservice.internal.push.VerifyAccountResponse;
|
||||
import org.whispersystems.signalservice.internal.util.DynamicCredentialsProvider;
|
||||
|
||||
import java.io.Closeable;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.util.Locale;
|
||||
|
||||
public class RegistrationManager implements Closeable {
|
||||
|
||||
private SignalAccount account;
|
||||
private final PathConfig pathConfig;
|
||||
private final ServiceEnvironmentConfig serviceEnvironmentConfig;
|
||||
private final String userAgent;
|
||||
|
||||
private final SignalServiceAccountManager accountManager;
|
||||
private final PinHelper pinHelper;
|
||||
|
||||
public RegistrationManager(
|
||||
SignalAccount account,
|
||||
PathConfig pathConfig,
|
||||
ServiceEnvironmentConfig serviceEnvironmentConfig,
|
||||
String userAgent
|
||||
) {
|
||||
this.account = account;
|
||||
this.pathConfig = pathConfig;
|
||||
this.serviceEnvironmentConfig = serviceEnvironmentConfig;
|
||||
this.userAgent = userAgent;
|
||||
|
||||
final SleepTimer timer = new UptimeSleepTimer();
|
||||
GroupsV2Operations groupsV2Operations;
|
||||
try {
|
||||
groupsV2Operations = new GroupsV2Operations(ClientZkOperations.create(serviceEnvironmentConfig.getSignalServiceConfiguration()));
|
||||
} catch (Throwable ignored) {
|
||||
groupsV2Operations = null;
|
||||
}
|
||||
this.accountManager = new SignalServiceAccountManager(serviceEnvironmentConfig.getSignalServiceConfiguration(),
|
||||
new DynamicCredentialsProvider(
|
||||
// Using empty UUID, because registering doesn't work otherwise
|
||||
null, account.getUsername(), account.getPassword(), SignalServiceAddress.DEFAULT_DEVICE_ID),
|
||||
userAgent,
|
||||
groupsV2Operations,
|
||||
ServiceConfig.AUTOMATIC_NETWORK_RETRY,
|
||||
timer);
|
||||
final var keyBackupService = accountManager.getKeyBackupService(ServiceConfig.getIasKeyStore(),
|
||||
serviceEnvironmentConfig.getKeyBackupConfig().getEnclaveName(),
|
||||
serviceEnvironmentConfig.getKeyBackupConfig().getServiceId(),
|
||||
serviceEnvironmentConfig.getKeyBackupConfig().getMrenclave(),
|
||||
10);
|
||||
this.pinHelper = new PinHelper(keyBackupService);
|
||||
}
|
||||
|
||||
public static RegistrationManager init(
|
||||
String username, File settingsPath, ServiceEnvironment serviceEnvironment, String userAgent
|
||||
) throws IOException {
|
||||
var pathConfig = PathConfig.createDefault(settingsPath);
|
||||
|
||||
final var serviceConfiguration = ServiceConfig.getServiceEnvironmentConfig(serviceEnvironment, userAgent);
|
||||
if (!SignalAccount.userExists(pathConfig.getDataPath(), username)) {
|
||||
var identityKey = KeyUtils.generateIdentityKeyPair();
|
||||
var registrationId = KeyHelper.generateRegistrationId(false);
|
||||
|
||||
var profileKey = KeyUtils.createProfileKey();
|
||||
var account = SignalAccount.create(pathConfig.getDataPath(),
|
||||
username,
|
||||
identityKey,
|
||||
registrationId,
|
||||
profileKey);
|
||||
account.save();
|
||||
|
||||
return new RegistrationManager(account, pathConfig, serviceConfiguration, userAgent);
|
||||
}
|
||||
|
||||
var account = SignalAccount.load(pathConfig.getDataPath(), username);
|
||||
|
||||
return new RegistrationManager(account, pathConfig, serviceConfiguration, userAgent);
|
||||
}
|
||||
|
||||
public void register(boolean voiceVerification, String captcha) throws IOException {
|
||||
if (account.getPassword() == null) {
|
||||
account.setPassword(KeyUtils.createPassword());
|
||||
}
|
||||
|
||||
if (voiceVerification) {
|
||||
accountManager.requestVoiceVerificationCode(Locale.getDefault(),
|
||||
Optional.fromNullable(captcha),
|
||||
Optional.absent());
|
||||
} else {
|
||||
accountManager.requestSmsVerificationCode(false, Optional.fromNullable(captcha), Optional.absent());
|
||||
}
|
||||
|
||||
account.save();
|
||||
}
|
||||
|
||||
public Manager verifyAccount(
|
||||
String verificationCode, String pin
|
||||
) throws IOException, KeyBackupSystemNoDataException, KeyBackupServicePinException {
|
||||
verificationCode = verificationCode.replace("-", "");
|
||||
VerifyAccountResponse response;
|
||||
try {
|
||||
response = verifyAccountWithCode(verificationCode, pin, null);
|
||||
account.setPinMasterKey(null);
|
||||
} catch (LockedException e) {
|
||||
if (pin == null) {
|
||||
throw e;
|
||||
}
|
||||
|
||||
var registrationLockData = pinHelper.getRegistrationLockData(pin, e);
|
||||
if (registrationLockData == null) {
|
||||
throw e;
|
||||
}
|
||||
|
||||
var registrationLock = registrationLockData.getMasterKey().deriveRegistrationLock();
|
||||
try {
|
||||
response = verifyAccountWithCode(verificationCode, null, registrationLock);
|
||||
} catch (LockedException _e) {
|
||||
throw new AssertionError("KBS Pin appeared to matched but reg lock still failed!");
|
||||
}
|
||||
account.setPinMasterKey(registrationLockData.getMasterKey());
|
||||
}
|
||||
|
||||
// TODO response.isStorageCapable()
|
||||
//accountManager.setGcmId(Optional.of(GoogleCloudMessaging.getInstance(this).register(REGISTRATION_ID)));
|
||||
|
||||
account.setDeviceId(SignalServiceAddress.DEFAULT_DEVICE_ID);
|
||||
account.setMultiDevice(false);
|
||||
account.setRegistered(true);
|
||||
account.setUuid(UuidUtil.parseOrNull(response.getUuid()));
|
||||
account.setRegistrationLockPin(pin);
|
||||
account.getSignalProtocolStore().archiveAllSessions();
|
||||
account.getSignalProtocolStore()
|
||||
.saveIdentity(account.getSelfAddress(),
|
||||
account.getSignalProtocolStore().getIdentityKeyPair().getPublicKey(),
|
||||
TrustLevel.TRUSTED_VERIFIED);
|
||||
|
||||
Manager m = null;
|
||||
try {
|
||||
m = new Manager(account, pathConfig, serviceEnvironmentConfig, userAgent);
|
||||
|
||||
m.refreshPreKeys();
|
||||
|
||||
account.save();
|
||||
|
||||
final var result = m;
|
||||
account = null;
|
||||
m = null;
|
||||
|
||||
return result;
|
||||
} finally {
|
||||
if (m != null) {
|
||||
m.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private VerifyAccountResponse verifyAccountWithCode(
|
||||
final String verificationCode, final String legacyPin, final String registrationLock
|
||||
) throws IOException {
|
||||
return accountManager.verifyAccountWithCode(verificationCode,
|
||||
null,
|
||||
account.getSignalProtocolStore().getLocalRegistrationId(),
|
||||
true,
|
||||
legacyPin,
|
||||
registrationLock,
|
||||
account.getSelfUnidentifiedAccessKey(),
|
||||
account.isUnrestrictedUnidentifiedAccess(),
|
||||
ServiceConfig.capabilities,
|
||||
account.isDiscoverableByPhoneNumber());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws IOException {
|
||||
if (account != null) {
|
||||
account.close();
|
||||
account = null;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
package org.asamk.signal.manager;
|
||||
|
||||
public class StickerPackInvalidException extends Exception {
|
||||
|
||||
public StickerPackInvalidException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
42
lib/src/main/java/org/asamk/signal/manager/TrustLevel.java
Normal file
42
lib/src/main/java/org/asamk/signal/manager/TrustLevel.java
Normal file
|
@ -0,0 +1,42 @@
|
|||
package org.asamk.signal.manager;
|
||||
|
||||
import org.whispersystems.signalservice.api.messages.multidevice.VerifiedMessage;
|
||||
|
||||
public enum TrustLevel {
|
||||
UNTRUSTED,
|
||||
TRUSTED_UNVERIFIED,
|
||||
TRUSTED_VERIFIED;
|
||||
|
||||
private static TrustLevel[] cachedValues = null;
|
||||
|
||||
public static TrustLevel fromInt(int i) {
|
||||
if (TrustLevel.cachedValues == null) {
|
||||
TrustLevel.cachedValues = TrustLevel.values();
|
||||
}
|
||||
return TrustLevel.cachedValues[i];
|
||||
}
|
||||
|
||||
public static TrustLevel fromVerifiedState(VerifiedMessage.VerifiedState verifiedState) {
|
||||
switch (verifiedState) {
|
||||
case DEFAULT:
|
||||
return TRUSTED_UNVERIFIED;
|
||||
case UNVERIFIED:
|
||||
return UNTRUSTED;
|
||||
case VERIFIED:
|
||||
return TRUSTED_VERIFIED;
|
||||
}
|
||||
throw new RuntimeException("Unknown verified state: " + verifiedState);
|
||||
}
|
||||
|
||||
public VerifiedMessage.VerifiedState toVerifiedState() {
|
||||
switch (this) {
|
||||
case TRUSTED_UNVERIFIED:
|
||||
return VerifiedMessage.VerifiedState.DEFAULT;
|
||||
case UNTRUSTED:
|
||||
return VerifiedMessage.VerifiedState.UNVERIFIED;
|
||||
case TRUSTED_VERIFIED:
|
||||
return VerifiedMessage.VerifiedState.VERIFIED;
|
||||
}
|
||||
throw new RuntimeException("Unknown verified state: " + this);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
package org.asamk.signal.manager;
|
||||
|
||||
import java.io.File;
|
||||
|
||||
public class UserAlreadyExists extends Exception {
|
||||
|
||||
private final String username;
|
||||
private final File fileName;
|
||||
|
||||
public UserAlreadyExists(String username, File fileName) {
|
||||
this.username = username;
|
||||
this.fileName = fileName;
|
||||
}
|
||||
|
||||
public String getUsername() {
|
||||
return username;
|
||||
}
|
||||
|
||||
public File getFileName() {
|
||||
return fileName;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,18 @@
|
|||
package org.asamk.signal.manager.config;
|
||||
|
||||
import org.whispersystems.signalservice.api.push.TrustStore;
|
||||
|
||||
import java.io.InputStream;
|
||||
|
||||
class IasTrustStore implements TrustStore {
|
||||
|
||||
@Override
|
||||
public InputStream getKeyStoreInputStream() {
|
||||
return IasTrustStore.class.getResourceAsStream("ias.store");
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getKeyStorePassword() {
|
||||
return "whisper";
|
||||
}
|
||||
}
|
|
@ -0,0 +1,26 @@
|
|||
package org.asamk.signal.manager.config;
|
||||
|
||||
public class KeyBackupConfig {
|
||||
|
||||
private final String enclaveName;
|
||||
private final byte[] serviceId;
|
||||
private final String mrenclave;
|
||||
|
||||
public KeyBackupConfig(final String enclaveName, final byte[] serviceId, final String mrenclave) {
|
||||
this.enclaveName = enclaveName;
|
||||
this.serviceId = serviceId;
|
||||
this.mrenclave = mrenclave;
|
||||
}
|
||||
|
||||
public String getEnclaveName() {
|
||||
return enclaveName;
|
||||
}
|
||||
|
||||
public byte[] getServiceId() {
|
||||
return serviceId;
|
||||
}
|
||||
|
||||
public String getMrenclave() {
|
||||
return mrenclave;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,85 @@
|
|||
package org.asamk.signal.manager.config;
|
||||
|
||||
import org.bouncycastle.util.encoders.Hex;
|
||||
import org.whispersystems.libsignal.InvalidKeyException;
|
||||
import org.whispersystems.libsignal.ecc.Curve;
|
||||
import org.whispersystems.libsignal.ecc.ECPublicKey;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
import org.whispersystems.signalservice.api.push.TrustStore;
|
||||
import org.whispersystems.signalservice.internal.configuration.SignalCdnUrl;
|
||||
import org.whispersystems.signalservice.internal.configuration.SignalContactDiscoveryUrl;
|
||||
import org.whispersystems.signalservice.internal.configuration.SignalKeyBackupServiceUrl;
|
||||
import org.whispersystems.signalservice.internal.configuration.SignalProxy;
|
||||
import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration;
|
||||
import org.whispersystems.signalservice.internal.configuration.SignalServiceUrl;
|
||||
import org.whispersystems.signalservice.internal.configuration.SignalStorageUrl;
|
||||
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import okhttp3.Dns;
|
||||
import okhttp3.Interceptor;
|
||||
|
||||
class LiveConfig {
|
||||
|
||||
private final static byte[] UNIDENTIFIED_SENDER_TRUST_ROOT = Base64.getDecoder()
|
||||
.decode("BXu6QIKVz5MA8gstzfOgRQGqyLqOwNKHL6INkv3IHWMF");
|
||||
private final static String CDS_MRENCLAVE = "c98e00a4e3ff977a56afefe7362a27e4961e4f19e211febfbb19b897e6b80b15";
|
||||
|
||||
private final static String KEY_BACKUP_ENCLAVE_NAME = "fe7c1bfae98f9b073d220366ea31163ee82f6d04bead774f71ca8e5c40847bfe";
|
||||
private final static byte[] KEY_BACKUP_SERVICE_ID = Hex.decode(
|
||||
"fe7c1bfae98f9b073d220366ea31163ee82f6d04bead774f71ca8e5c40847bfe");
|
||||
private final static String KEY_BACKUP_MRENCLAVE = "a3baab19ef6ce6f34ab9ebb25ba722725ae44a8872dc0ff08ad6d83a9489de87";
|
||||
|
||||
private final static String URL = "https://textsecure-service.whispersystems.org";
|
||||
private final static String CDN_URL = "https://cdn.signal.org";
|
||||
private final static String CDN2_URL = "https://cdn2.signal.org";
|
||||
private final static String SIGNAL_CONTACT_DISCOVERY_URL = "https://api.directory.signal.org";
|
||||
private final static String SIGNAL_KEY_BACKUP_URL = "https://api.backup.signal.org";
|
||||
private final static String STORAGE_URL = "https://storage.signal.org";
|
||||
private final static TrustStore TRUST_STORE = new WhisperTrustStore();
|
||||
|
||||
private final static Optional<Dns> dns = Optional.absent();
|
||||
private final static Optional<SignalProxy> proxy = Optional.absent();
|
||||
|
||||
private final static byte[] zkGroupServerPublicParams = Base64.getDecoder()
|
||||
.decode("AMhf5ywVwITZMsff/eCyudZx9JDmkkkbV6PInzG4p8x3VqVJSFiMvnvlEKWuRob/1eaIetR31IYeAbm0NdOuHH8Qi+Rexi1wLlpzIo1gstHWBfZzy1+qHRV5A4TqPp15YzBPm0WSggW6PbSn+F4lf57VCnHF7p8SvzAA2ZZJPYJURt8X7bbg+H3i+PEjH9DXItNEqs2sNcug37xZQDLm7X0=");
|
||||
|
||||
static SignalServiceConfiguration createDefaultServiceConfiguration(
|
||||
final List<Interceptor> interceptors
|
||||
) {
|
||||
return new SignalServiceConfiguration(new SignalServiceUrl[]{new SignalServiceUrl(URL, TRUST_STORE)},
|
||||
Map.of(0,
|
||||
new SignalCdnUrl[]{new SignalCdnUrl(CDN_URL, TRUST_STORE)},
|
||||
2,
|
||||
new SignalCdnUrl[]{new SignalCdnUrl(CDN2_URL, TRUST_STORE)}),
|
||||
new SignalContactDiscoveryUrl[]{new SignalContactDiscoveryUrl(SIGNAL_CONTACT_DISCOVERY_URL,
|
||||
TRUST_STORE)},
|
||||
new SignalKeyBackupServiceUrl[]{new SignalKeyBackupServiceUrl(SIGNAL_KEY_BACKUP_URL, TRUST_STORE)},
|
||||
new SignalStorageUrl[]{new SignalStorageUrl(STORAGE_URL, TRUST_STORE)},
|
||||
interceptors,
|
||||
dns,
|
||||
proxy,
|
||||
zkGroupServerPublicParams);
|
||||
}
|
||||
|
||||
static ECPublicKey getUnidentifiedSenderTrustRoot() {
|
||||
try {
|
||||
return Curve.decodePoint(UNIDENTIFIED_SENDER_TRUST_ROOT, 0);
|
||||
} catch (InvalidKeyException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
|
||||
static KeyBackupConfig createKeyBackupConfig() {
|
||||
return new KeyBackupConfig(KEY_BACKUP_ENCLAVE_NAME, KEY_BACKUP_SERVICE_ID, KEY_BACKUP_MRENCLAVE);
|
||||
}
|
||||
|
||||
static String getCdsMrenclave() {
|
||||
return CDS_MRENCLAVE;
|
||||
}
|
||||
|
||||
private LiveConfig() {
|
||||
}
|
||||
}
|
|
@ -0,0 +1,85 @@
|
|||
package org.asamk.signal.manager.config;
|
||||
|
||||
import org.bouncycastle.util.encoders.Hex;
|
||||
import org.whispersystems.libsignal.InvalidKeyException;
|
||||
import org.whispersystems.libsignal.ecc.Curve;
|
||||
import org.whispersystems.libsignal.ecc.ECPublicKey;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
import org.whispersystems.signalservice.api.push.TrustStore;
|
||||
import org.whispersystems.signalservice.internal.configuration.SignalCdnUrl;
|
||||
import org.whispersystems.signalservice.internal.configuration.SignalContactDiscoveryUrl;
|
||||
import org.whispersystems.signalservice.internal.configuration.SignalKeyBackupServiceUrl;
|
||||
import org.whispersystems.signalservice.internal.configuration.SignalProxy;
|
||||
import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration;
|
||||
import org.whispersystems.signalservice.internal.configuration.SignalServiceUrl;
|
||||
import org.whispersystems.signalservice.internal.configuration.SignalStorageUrl;
|
||||
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import okhttp3.Dns;
|
||||
import okhttp3.Interceptor;
|
||||
|
||||
class SandboxConfig {
|
||||
|
||||
private final static byte[] UNIDENTIFIED_SENDER_TRUST_ROOT = Base64.getDecoder()
|
||||
.decode("BbqY1DzohE4NUZoVF+L18oUPrK3kILllLEJh2UnPSsEx");
|
||||
private final static String CDS_MRENCLAVE = "c98e00a4e3ff977a56afefe7362a27e4961e4f19e211febfbb19b897e6b80b15";
|
||||
|
||||
private final static String KEY_BACKUP_ENCLAVE_NAME = "823a3b2c037ff0cbe305cc48928cfcc97c9ed4a8ca6d49af6f7d6981fb60a4e9";
|
||||
private final static byte[] KEY_BACKUP_SERVICE_ID = Hex.decode(
|
||||
"51a56084c0b21c6b8f62b1bc792ec9bedac4c7c3964bb08ddcab868158c09982");
|
||||
private final static String KEY_BACKUP_MRENCLAVE = "a3baab19ef6ce6f34ab9ebb25ba722725ae44a8872dc0ff08ad6d83a9489de87";
|
||||
|
||||
private final static String URL = "https://textsecure-service-staging.whispersystems.org";
|
||||
private final static String CDN_URL = "https://cdn-staging.signal.org";
|
||||
private final static String CDN2_URL = "https://cdn2-staging.signal.org";
|
||||
private final static String SIGNAL_CONTACT_DISCOVERY_URL = "https://api-staging.directory.signal.org";
|
||||
private final static String SIGNAL_KEY_BACKUP_URL = "https://api-staging.backup.signal.org";
|
||||
private final static String STORAGE_URL = "https://storage-staging.signal.org";
|
||||
private final static TrustStore TRUST_STORE = new WhisperTrustStore();
|
||||
|
||||
private final static Optional<Dns> dns = Optional.absent();
|
||||
private final static Optional<SignalProxy> proxy = Optional.absent();
|
||||
|
||||
private final static byte[] zkGroupServerPublicParams = Base64.getDecoder()
|
||||
.decode("ABSY21VckQcbSXVNCGRYJcfWHiAMZmpTtTELcDmxgdFbtp/bWsSxZdMKzfCp8rvIs8ocCU3B37fT3r4Mi5qAemeGeR2X+/YmOGR5ofui7tD5mDQfstAI9i+4WpMtIe8KC3wU5w3Inq3uNWVmoGtpKndsNfwJrCg0Hd9zmObhypUnSkfYn2ooMOOnBpfdanRtrvetZUayDMSC5iSRcXKpdls=");
|
||||
|
||||
static SignalServiceConfiguration createDefaultServiceConfiguration(
|
||||
final List<Interceptor> interceptors
|
||||
) {
|
||||
return new SignalServiceConfiguration(new SignalServiceUrl[]{new SignalServiceUrl(URL, TRUST_STORE)},
|
||||
Map.of(0,
|
||||
new SignalCdnUrl[]{new SignalCdnUrl(CDN_URL, TRUST_STORE)},
|
||||
2,
|
||||
new SignalCdnUrl[]{new SignalCdnUrl(CDN2_URL, TRUST_STORE)}),
|
||||
new SignalContactDiscoveryUrl[]{new SignalContactDiscoveryUrl(SIGNAL_CONTACT_DISCOVERY_URL,
|
||||
TRUST_STORE)},
|
||||
new SignalKeyBackupServiceUrl[]{new SignalKeyBackupServiceUrl(SIGNAL_KEY_BACKUP_URL, TRUST_STORE)},
|
||||
new SignalStorageUrl[]{new SignalStorageUrl(STORAGE_URL, TRUST_STORE)},
|
||||
interceptors,
|
||||
dns,
|
||||
proxy,
|
||||
zkGroupServerPublicParams);
|
||||
}
|
||||
|
||||
static ECPublicKey getUnidentifiedSenderTrustRoot() {
|
||||
try {
|
||||
return Curve.decodePoint(UNIDENTIFIED_SENDER_TRUST_ROOT, 0);
|
||||
} catch (InvalidKeyException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
|
||||
static KeyBackupConfig createKeyBackupConfig() {
|
||||
return new KeyBackupConfig(KEY_BACKUP_ENCLAVE_NAME, KEY_BACKUP_SERVICE_ID, KEY_BACKUP_MRENCLAVE);
|
||||
}
|
||||
|
||||
static String getCdsMrenclave() {
|
||||
return CDS_MRENCLAVE;
|
||||
}
|
||||
|
||||
private SandboxConfig() {
|
||||
}
|
||||
}
|
|
@ -0,0 +1,94 @@
|
|||
package org.asamk.signal.manager.config;
|
||||
|
||||
import org.signal.zkgroup.internal.Native;
|
||||
import org.whispersystems.signalservice.api.account.AccountAttributes;
|
||||
import org.whispersystems.signalservice.api.push.TrustStore;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.security.KeyStore;
|
||||
import java.security.KeyStoreException;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.cert.CertificateException;
|
||||
import java.util.List;
|
||||
|
||||
import okhttp3.Interceptor;
|
||||
|
||||
public class ServiceConfig {
|
||||
|
||||
public final static int PREKEY_MINIMUM_COUNT = 20;
|
||||
public final static int PREKEY_BATCH_SIZE = 100;
|
||||
public final static int MAX_ATTACHMENT_SIZE = 150 * 1024 * 1024;
|
||||
public final static long MAX_ENVELOPE_SIZE = 0;
|
||||
public final static long AVATAR_DOWNLOAD_FAILSAFE_MAX_SIZE = 10 * 1024 * 1024;
|
||||
public final static boolean AUTOMATIC_NETWORK_RETRY = true;
|
||||
|
||||
private final static KeyStore iasKeyStore;
|
||||
|
||||
public static final AccountAttributes.Capabilities capabilities;
|
||||
|
||||
static {
|
||||
boolean zkGroupAvailable;
|
||||
try {
|
||||
Native.serverPublicParamsCheckValidContentsJNI(new byte[]{});
|
||||
zkGroupAvailable = true;
|
||||
} catch (Throwable ignored) {
|
||||
zkGroupAvailable = false;
|
||||
}
|
||||
capabilities = new AccountAttributes.Capabilities(false, zkGroupAvailable, false, zkGroupAvailable);
|
||||
|
||||
try {
|
||||
TrustStore contactTrustStore = new IasTrustStore();
|
||||
|
||||
var keyStore = KeyStore.getInstance("BKS");
|
||||
keyStore.load(contactTrustStore.getKeyStoreInputStream(),
|
||||
contactTrustStore.getKeyStorePassword().toCharArray());
|
||||
|
||||
iasKeyStore = keyStore;
|
||||
} catch (KeyStoreException | CertificateException | IOException | NoSuchAlgorithmException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean isSignalClientAvailable() {
|
||||
try {
|
||||
org.signal.client.internal.Native.DisplayableFingerprint_Format(new byte[30], new byte[30]);
|
||||
return true;
|
||||
} catch (UnsatisfiedLinkError ignored) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static AccountAttributes.Capabilities getCapabilities() {
|
||||
return capabilities;
|
||||
}
|
||||
|
||||
public static KeyStore getIasKeyStore() {
|
||||
return iasKeyStore;
|
||||
}
|
||||
|
||||
public static ServiceEnvironmentConfig getServiceEnvironmentConfig(
|
||||
ServiceEnvironment serviceEnvironment, String userAgent
|
||||
) {
|
||||
final Interceptor userAgentInterceptor = chain -> chain.proceed(chain.request()
|
||||
.newBuilder()
|
||||
.header("User-Agent", userAgent)
|
||||
.build());
|
||||
|
||||
final var interceptors = List.of(userAgentInterceptor);
|
||||
|
||||
switch (serviceEnvironment) {
|
||||
case LIVE:
|
||||
return new ServiceEnvironmentConfig(LiveConfig.createDefaultServiceConfiguration(interceptors),
|
||||
LiveConfig.getUnidentifiedSenderTrustRoot(),
|
||||
LiveConfig.createKeyBackupConfig(),
|
||||
LiveConfig.getCdsMrenclave());
|
||||
case SANDBOX:
|
||||
return new ServiceEnvironmentConfig(SandboxConfig.createDefaultServiceConfiguration(interceptors),
|
||||
SandboxConfig.getUnidentifiedSenderTrustRoot(),
|
||||
SandboxConfig.createKeyBackupConfig(),
|
||||
SandboxConfig.getCdsMrenclave());
|
||||
default:
|
||||
throw new IllegalArgumentException("Unsupported environment");
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
package org.asamk.signal.manager.config;
|
||||
|
||||
public enum ServiceEnvironment {
|
||||
LIVE,
|
||||
SANDBOX,
|
||||
}
|
|
@ -0,0 +1,43 @@
|
|||
package org.asamk.signal.manager.config;
|
||||
|
||||
import org.whispersystems.libsignal.ecc.ECPublicKey;
|
||||
import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration;
|
||||
|
||||
public class ServiceEnvironmentConfig {
|
||||
|
||||
private final SignalServiceConfiguration signalServiceConfiguration;
|
||||
|
||||
private final ECPublicKey unidentifiedSenderTrustRoot;
|
||||
|
||||
private final KeyBackupConfig keyBackupConfig;
|
||||
|
||||
private final String cdsMrenclave;
|
||||
|
||||
public ServiceEnvironmentConfig(
|
||||
final SignalServiceConfiguration signalServiceConfiguration,
|
||||
final ECPublicKey unidentifiedSenderTrustRoot,
|
||||
final KeyBackupConfig keyBackupConfig,
|
||||
final String cdsMrenclave
|
||||
) {
|
||||
this.signalServiceConfiguration = signalServiceConfiguration;
|
||||
this.unidentifiedSenderTrustRoot = unidentifiedSenderTrustRoot;
|
||||
this.keyBackupConfig = keyBackupConfig;
|
||||
this.cdsMrenclave = cdsMrenclave;
|
||||
}
|
||||
|
||||
public SignalServiceConfiguration getSignalServiceConfiguration() {
|
||||
return signalServiceConfiguration;
|
||||
}
|
||||
|
||||
public ECPublicKey getUnidentifiedSenderTrustRoot() {
|
||||
return unidentifiedSenderTrustRoot;
|
||||
}
|
||||
|
||||
public KeyBackupConfig getKeyBackupConfig() {
|
||||
return keyBackupConfig;
|
||||
}
|
||||
|
||||
public String getCdsMrenclave() {
|
||||
return cdsMrenclave;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,18 @@
|
|||
package org.asamk.signal.manager.config;
|
||||
|
||||
import org.whispersystems.signalservice.api.push.TrustStore;
|
||||
|
||||
import java.io.InputStream;
|
||||
|
||||
class WhisperTrustStore implements TrustStore {
|
||||
|
||||
@Override
|
||||
public InputStream getKeyStoreInputStream() {
|
||||
return WhisperTrustStore.class.getResourceAsStream("whisper.store");
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getKeyStorePassword() {
|
||||
return "whisper";
|
||||
}
|
||||
}
|
|
@ -0,0 +1,62 @@
|
|||
package org.asamk.signal.manager.groups;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Base64;
|
||||
|
||||
public abstract class GroupId {
|
||||
|
||||
private final byte[] id;
|
||||
|
||||
public static GroupIdV1 v1(byte[] id) {
|
||||
return new GroupIdV1(id);
|
||||
}
|
||||
|
||||
public static GroupIdV2 v2(byte[] id) {
|
||||
return new GroupIdV2(id);
|
||||
}
|
||||
|
||||
public static GroupId unknownVersion(byte[] id) {
|
||||
if (id.length == 16) {
|
||||
return new GroupIdV1(id);
|
||||
} else if (id.length == 32) {
|
||||
return new GroupIdV2(id);
|
||||
}
|
||||
|
||||
throw new AssertionError("Invalid group id of size " + id.length);
|
||||
}
|
||||
|
||||
public static GroupId fromBase64(String id) throws GroupIdFormatException {
|
||||
try {
|
||||
return unknownVersion(java.util.Base64.getDecoder().decode(id));
|
||||
} catch (Throwable e) {
|
||||
throw new GroupIdFormatException(id, e);
|
||||
}
|
||||
}
|
||||
|
||||
protected GroupId(final byte[] id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public byte[] serialize() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public String toBase64() {
|
||||
return Base64.getEncoder().encodeToString(id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(final Object o) {
|
||||
if (this == o) return true;
|
||||
if (o == null || getClass() != o.getClass()) return false;
|
||||
|
||||
final var groupId = (GroupId) o;
|
||||
|
||||
return Arrays.equals(id, groupId.id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Arrays.hashCode(id);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
package org.asamk.signal.manager.groups;
|
||||
|
||||
public class GroupIdFormatException extends Exception {
|
||||
|
||||
public GroupIdFormatException(String groupId, Throwable e) {
|
||||
super("Failed to decode groupId (must be base64) \"" + groupId + "\": " + e.getMessage(), e);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
package org.asamk.signal.manager.groups;
|
||||
|
||||
import static org.asamk.signal.manager.util.KeyUtils.getSecretBytes;
|
||||
|
||||
public class GroupIdV1 extends GroupId {
|
||||
|
||||
public static GroupIdV1 createRandom() {
|
||||
return new GroupIdV1(getSecretBytes(16));
|
||||
}
|
||||
|
||||
public GroupIdV1(final byte[] id) {
|
||||
super(id);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
package org.asamk.signal.manager.groups;
|
||||
|
||||
import java.util.Base64;
|
||||
|
||||
public class GroupIdV2 extends GroupId {
|
||||
|
||||
public static GroupIdV2 fromBase64(String groupId) {
|
||||
return new GroupIdV2(Base64.getDecoder().decode(groupId));
|
||||
}
|
||||
|
||||
public GroupIdV2(final byte[] id) {
|
||||
super(id);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,140 @@
|
|||
package org.asamk.signal.manager.groups;
|
||||
|
||||
import com.google.protobuf.ByteString;
|
||||
|
||||
import org.signal.storageservice.protos.groups.GroupInviteLink;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedGroup;
|
||||
import org.signal.zkgroup.InvalidInputException;
|
||||
import org.signal.zkgroup.groups.GroupMasterKey;
|
||||
import org.whispersystems.util.Base64UrlSafe;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
|
||||
public final class GroupInviteLinkUrl {
|
||||
|
||||
private static final String GROUP_URL_HOST = "signal.group";
|
||||
private static final String GROUP_URL_PREFIX = "https://" + GROUP_URL_HOST + "/#";
|
||||
|
||||
private final GroupMasterKey groupMasterKey;
|
||||
private final GroupLinkPassword password;
|
||||
private final String url;
|
||||
|
||||
public static GroupInviteLinkUrl forGroup(GroupMasterKey groupMasterKey, DecryptedGroup group) {
|
||||
return new GroupInviteLinkUrl(groupMasterKey,
|
||||
GroupLinkPassword.fromBytes(group.getInviteLinkPassword().toByteArray()));
|
||||
}
|
||||
|
||||
public static boolean isGroupLink(String urlString) {
|
||||
return getGroupUrl(urlString) != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return null iff not a group url.
|
||||
* @throws InvalidGroupLinkException If group url, but cannot be parsed.
|
||||
*/
|
||||
public static GroupInviteLinkUrl fromUri(String urlString) throws InvalidGroupLinkException, UnknownGroupLinkVersionException {
|
||||
var uri = getGroupUrl(urlString);
|
||||
|
||||
if (uri == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!"/".equals(uri.getPath()) && uri.getPath().length() > 0) {
|
||||
throw new InvalidGroupLinkException("No path was expected in uri");
|
||||
}
|
||||
|
||||
var encoding = uri.getFragment();
|
||||
|
||||
if (encoding == null || encoding.length() == 0) {
|
||||
throw new InvalidGroupLinkException("No reference was in the uri");
|
||||
}
|
||||
|
||||
var bytes = Base64UrlSafe.decodePaddingAgnostic(encoding);
|
||||
var groupInviteLink = GroupInviteLink.parseFrom(bytes);
|
||||
|
||||
switch (groupInviteLink.getContentsCase()) {
|
||||
case V1CONTENTS: {
|
||||
var groupInviteLinkContentsV1 = groupInviteLink.getV1Contents();
|
||||
var groupMasterKey = new GroupMasterKey(groupInviteLinkContentsV1.getGroupMasterKey()
|
||||
.toByteArray());
|
||||
var password = GroupLinkPassword.fromBytes(groupInviteLinkContentsV1.getInviteLinkPassword()
|
||||
.toByteArray());
|
||||
|
||||
return new GroupInviteLinkUrl(groupMasterKey, password);
|
||||
}
|
||||
default:
|
||||
throw new UnknownGroupLinkVersionException("Url contains no known group link content");
|
||||
}
|
||||
} catch (InvalidInputException | IOException e) {
|
||||
throw new InvalidGroupLinkException(e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {@link URI} if the host name matches.
|
||||
*/
|
||||
private static URI getGroupUrl(String urlString) {
|
||||
try {
|
||||
var url = new URI(urlString);
|
||||
|
||||
if (!"https".equalsIgnoreCase(url.getScheme()) && !"sgnl".equalsIgnoreCase(url.getScheme())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return GROUP_URL_HOST.equalsIgnoreCase(url.getHost()) ? url : null;
|
||||
} catch (URISyntaxException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private GroupInviteLinkUrl(GroupMasterKey groupMasterKey, GroupLinkPassword password) {
|
||||
this.groupMasterKey = groupMasterKey;
|
||||
this.password = password;
|
||||
this.url = createUrl(groupMasterKey, password);
|
||||
}
|
||||
|
||||
protected static String createUrl(GroupMasterKey groupMasterKey, GroupLinkPassword password) {
|
||||
var groupInviteLink = GroupInviteLink.newBuilder()
|
||||
.setV1Contents(GroupInviteLink.GroupInviteLinkContentsV1.newBuilder()
|
||||
.setGroupMasterKey(ByteString.copyFrom(groupMasterKey.serialize()))
|
||||
.setInviteLinkPassword(ByteString.copyFrom(password.serialize())))
|
||||
.build();
|
||||
|
||||
var encoding = Base64UrlSafe.encodeBytesWithoutPadding(groupInviteLink.toByteArray());
|
||||
|
||||
return GROUP_URL_PREFIX + encoding;
|
||||
}
|
||||
|
||||
public String getUrl() {
|
||||
return url;
|
||||
}
|
||||
|
||||
public GroupMasterKey getGroupMasterKey() {
|
||||
return groupMasterKey;
|
||||
}
|
||||
|
||||
public GroupLinkPassword getPassword() {
|
||||
return password;
|
||||
}
|
||||
|
||||
public final static class InvalidGroupLinkException extends Exception {
|
||||
|
||||
public InvalidGroupLinkException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public InvalidGroupLinkException(Throwable cause) {
|
||||
super(cause);
|
||||
}
|
||||
}
|
||||
|
||||
public final static class UnknownGroupLinkVersionException extends Exception {
|
||||
|
||||
public UnknownGroupLinkVersionException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,42 @@
|
|||
package org.asamk.signal.manager.groups;
|
||||
|
||||
import org.asamk.signal.manager.util.KeyUtils;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
public final class GroupLinkPassword {
|
||||
|
||||
private static final int SIZE = 16;
|
||||
|
||||
private final byte[] bytes;
|
||||
|
||||
public static GroupLinkPassword createNew() {
|
||||
return new GroupLinkPassword(KeyUtils.getSecretBytes(SIZE));
|
||||
}
|
||||
|
||||
public static GroupLinkPassword fromBytes(byte[] bytes) {
|
||||
return new GroupLinkPassword(bytes);
|
||||
}
|
||||
|
||||
private GroupLinkPassword(byte[] bytes) {
|
||||
this.bytes = bytes;
|
||||
}
|
||||
|
||||
public byte[] serialize() {
|
||||
return bytes.clone();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object other) {
|
||||
if (!(other instanceof GroupLinkPassword)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Arrays.equals(bytes, ((GroupLinkPassword) other).bytes);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Arrays.hashCode(bytes);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
package org.asamk.signal.manager.groups;
|
||||
|
||||
public class GroupNotFoundException extends Exception {
|
||||
|
||||
public GroupNotFoundException(GroupId groupId) {
|
||||
super("Group not found: " + groupId.toBase64());
|
||||
}
|
||||
}
|
|
@ -0,0 +1,67 @@
|
|||
package org.asamk.signal.manager.groups;
|
||||
|
||||
import org.asamk.signal.manager.storage.groups.GroupInfo;
|
||||
import org.asamk.signal.manager.storage.groups.GroupInfoV1;
|
||||
import org.asamk.signal.manager.storage.groups.GroupInfoV2;
|
||||
import org.signal.zkgroup.InvalidInputException;
|
||||
import org.signal.zkgroup.groups.GroupMasterKey;
|
||||
import org.signal.zkgroup.groups.GroupSecretParams;
|
||||
import org.whispersystems.libsignal.kdf.HKDFv3;
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage;
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceGroup;
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceGroupContext;
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceGroupV2;
|
||||
|
||||
public class GroupUtils {
|
||||
|
||||
public static void setGroupContext(
|
||||
final SignalServiceDataMessage.Builder messageBuilder, final GroupInfo groupInfo
|
||||
) {
|
||||
if (groupInfo instanceof GroupInfoV1) {
|
||||
var group = SignalServiceGroup.newBuilder(SignalServiceGroup.Type.DELIVER)
|
||||
.withId(groupInfo.getGroupId().serialize())
|
||||
.build();
|
||||
messageBuilder.asGroupMessage(group);
|
||||
} else {
|
||||
final var groupInfoV2 = (GroupInfoV2) groupInfo;
|
||||
var group = SignalServiceGroupV2.newBuilder(groupInfoV2.getMasterKey())
|
||||
.withRevision(groupInfoV2.getGroup() == null ? 0 : groupInfoV2.getGroup().getRevision())
|
||||
.build();
|
||||
messageBuilder.asGroupMessage(group);
|
||||
}
|
||||
}
|
||||
|
||||
public static GroupId getGroupId(SignalServiceGroupContext context) {
|
||||
if (context.getGroupV1().isPresent()) {
|
||||
return GroupId.v1(context.getGroupV1().get().getGroupId());
|
||||
} else if (context.getGroupV2().isPresent()) {
|
||||
return getGroupIdV2(context.getGroupV2().get().getMasterKey());
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static GroupIdV2 getGroupIdV2(GroupSecretParams groupSecretParams) {
|
||||
return GroupId.v2(groupSecretParams.getPublicParams().getGroupIdentifier().serialize());
|
||||
}
|
||||
|
||||
public static GroupIdV2 getGroupIdV2(GroupMasterKey groupMasterKey) {
|
||||
final var groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupMasterKey);
|
||||
return getGroupIdV2(groupSecretParams);
|
||||
}
|
||||
|
||||
public static GroupIdV2 getGroupIdV2(GroupIdV1 groupIdV1) {
|
||||
final var groupSecretParams = GroupSecretParams.deriveFromMasterKey(deriveV2MigrationMasterKey(groupIdV1));
|
||||
return getGroupIdV2(groupSecretParams);
|
||||
}
|
||||
|
||||
private static GroupMasterKey deriveV2MigrationMasterKey(GroupIdV1 groupIdV1) {
|
||||
try {
|
||||
return new GroupMasterKey(new HKDFv3().deriveSecrets(groupIdV1.serialize(),
|
||||
"GV2 Migration".getBytes(),
|
||||
GroupMasterKey.SIZE));
|
||||
} catch (InvalidInputException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
package org.asamk.signal.manager.groups;
|
||||
|
||||
public class NotAGroupMemberException extends Exception {
|
||||
|
||||
public NotAGroupMemberException(GroupId groupId, String groupName) {
|
||||
super("User is not a member in group: " + groupName + " (" + groupId.toBase64() + ")");
|
||||
}
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
package org.asamk.signal.manager.helper;
|
||||
|
||||
import org.signal.zkgroup.groups.GroupSecretParams;
|
||||
import org.whispersystems.signalservice.api.groupsv2.GroupsV2AuthorizationString;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
public interface GroupAuthorizationProvider {
|
||||
|
||||
GroupsV2AuthorizationString getAuthorizationForToday(GroupSecretParams groupSecretParams) throws IOException;
|
||||
}
|
|
@ -0,0 +1,387 @@
|
|||
package org.asamk.signal.manager.helper;
|
||||
|
||||
import com.google.protobuf.InvalidProtocolBufferException;
|
||||
|
||||
import org.asamk.signal.manager.groups.GroupLinkPassword;
|
||||
import org.asamk.signal.manager.groups.GroupUtils;
|
||||
import org.asamk.signal.manager.storage.groups.GroupInfoV2;
|
||||
import org.asamk.signal.manager.storage.profiles.SignalProfile;
|
||||
import org.asamk.signal.manager.util.IOUtils;
|
||||
import org.signal.storageservice.protos.groups.AccessControl;
|
||||
import org.signal.storageservice.protos.groups.GroupChange;
|
||||
import org.signal.storageservice.protos.groups.Member;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedGroup;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedGroupChange;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedGroupJoinInfo;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedPendingMember;
|
||||
import org.signal.zkgroup.InvalidInputException;
|
||||
import org.signal.zkgroup.VerificationFailedException;
|
||||
import org.signal.zkgroup.groups.GroupMasterKey;
|
||||
import org.signal.zkgroup.groups.GroupSecretParams;
|
||||
import org.signal.zkgroup.groups.UuidCiphertext;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.whispersystems.libsignal.util.Pair;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
import org.whispersystems.signalservice.api.groupsv2.DecryptedGroupUtil;
|
||||
import org.whispersystems.signalservice.api.groupsv2.GroupCandidate;
|
||||
import org.whispersystems.signalservice.api.groupsv2.GroupLinkNotActiveException;
|
||||
import org.whispersystems.signalservice.api.groupsv2.GroupsV2Api;
|
||||
import org.whispersystems.signalservice.api.groupsv2.GroupsV2AuthorizationString;
|
||||
import org.whispersystems.signalservice.api.groupsv2.GroupsV2Operations;
|
||||
import org.whispersystems.signalservice.api.groupsv2.InvalidGroupStateException;
|
||||
import org.whispersystems.signalservice.api.groupsv2.NotAbleToApplyGroupV2ChangeException;
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
|
||||
import org.whispersystems.signalservice.api.util.UuidUtil;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.Collection;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public class GroupHelper {
|
||||
|
||||
private final static Logger logger = LoggerFactory.getLogger(GroupHelper.class);
|
||||
|
||||
private final ProfileKeyCredentialProvider profileKeyCredentialProvider;
|
||||
|
||||
private final ProfileProvider profileProvider;
|
||||
|
||||
private final SelfAddressProvider selfAddressProvider;
|
||||
|
||||
private final GroupsV2Operations groupsV2Operations;
|
||||
|
||||
private final GroupsV2Api groupsV2Api;
|
||||
|
||||
private final GroupAuthorizationProvider groupAuthorizationProvider;
|
||||
|
||||
public GroupHelper(
|
||||
final ProfileKeyCredentialProvider profileKeyCredentialProvider,
|
||||
final ProfileProvider profileProvider,
|
||||
final SelfAddressProvider selfAddressProvider,
|
||||
final GroupsV2Operations groupsV2Operations,
|
||||
final GroupsV2Api groupsV2Api,
|
||||
final GroupAuthorizationProvider groupAuthorizationProvider
|
||||
) {
|
||||
this.profileKeyCredentialProvider = profileKeyCredentialProvider;
|
||||
this.profileProvider = profileProvider;
|
||||
this.selfAddressProvider = selfAddressProvider;
|
||||
this.groupsV2Operations = groupsV2Operations;
|
||||
this.groupsV2Api = groupsV2Api;
|
||||
this.groupAuthorizationProvider = groupAuthorizationProvider;
|
||||
}
|
||||
|
||||
public DecryptedGroup getDecryptedGroup(final GroupSecretParams groupSecretParams) {
|
||||
try {
|
||||
final var groupsV2AuthorizationString = groupAuthorizationProvider.getAuthorizationForToday(
|
||||
groupSecretParams);
|
||||
return groupsV2Api.getGroup(groupSecretParams, groupsV2AuthorizationString);
|
||||
} catch (IOException | VerificationFailedException | InvalidGroupStateException e) {
|
||||
logger.warn("Failed to retrieve Group V2 info, ignoring: {}", e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public DecryptedGroupJoinInfo getDecryptedGroupJoinInfo(
|
||||
GroupMasterKey groupMasterKey, GroupLinkPassword password
|
||||
) throws IOException, GroupLinkNotActiveException {
|
||||
var groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupMasterKey);
|
||||
|
||||
return groupsV2Api.getGroupJoinInfo(groupSecretParams,
|
||||
Optional.fromNullable(password).transform(GroupLinkPassword::serialize),
|
||||
groupAuthorizationProvider.getAuthorizationForToday(groupSecretParams));
|
||||
}
|
||||
|
||||
public GroupInfoV2 createGroupV2(
|
||||
String name, Collection<SignalServiceAddress> members, File avatarFile
|
||||
) throws IOException {
|
||||
final var avatarBytes = readAvatarBytes(avatarFile);
|
||||
final var newGroup = buildNewGroupV2(name, members, avatarBytes);
|
||||
if (newGroup == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final var groupSecretParams = newGroup.getGroupSecretParams();
|
||||
|
||||
final GroupsV2AuthorizationString groupAuthForToday;
|
||||
final DecryptedGroup decryptedGroup;
|
||||
try {
|
||||
groupAuthForToday = groupAuthorizationProvider.getAuthorizationForToday(groupSecretParams);
|
||||
groupsV2Api.putNewGroup(newGroup, groupAuthForToday);
|
||||
decryptedGroup = groupsV2Api.getGroup(groupSecretParams, groupAuthForToday);
|
||||
} catch (IOException | VerificationFailedException | InvalidGroupStateException e) {
|
||||
logger.warn("Failed to create V2 group: {}", e.getMessage());
|
||||
return null;
|
||||
}
|
||||
if (decryptedGroup == null) {
|
||||
logger.warn("Failed to create V2 group, unknown error!");
|
||||
return null;
|
||||
}
|
||||
|
||||
final var groupId = GroupUtils.getGroupIdV2(groupSecretParams);
|
||||
final var masterKey = groupSecretParams.getMasterKey();
|
||||
var g = new GroupInfoV2(groupId, masterKey);
|
||||
g.setGroup(decryptedGroup);
|
||||
|
||||
return g;
|
||||
}
|
||||
|
||||
private byte[] readAvatarBytes(final File avatarFile) throws IOException {
|
||||
final byte[] avatarBytes;
|
||||
try (InputStream avatar = avatarFile == null ? null : new FileInputStream(avatarFile)) {
|
||||
avatarBytes = avatar == null ? null : IOUtils.readFully(avatar);
|
||||
}
|
||||
return avatarBytes;
|
||||
}
|
||||
|
||||
private GroupsV2Operations.NewGroup buildNewGroupV2(
|
||||
String name, Collection<SignalServiceAddress> members, byte[] avatar
|
||||
) {
|
||||
final var profileKeyCredential = profileKeyCredentialProvider.getProfileKeyCredential(selfAddressProvider.getSelfAddress());
|
||||
if (profileKeyCredential == null) {
|
||||
logger.warn("Cannot create a V2 group as self does not have a versioned profile");
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!areMembersValid(members)) return null;
|
||||
|
||||
var self = new GroupCandidate(selfAddressProvider.getSelfAddress().getUuid().orNull(),
|
||||
Optional.fromNullable(profileKeyCredential));
|
||||
var candidates = members.stream()
|
||||
.map(member -> new GroupCandidate(member.getUuid().get(),
|
||||
Optional.fromNullable(profileKeyCredentialProvider.getProfileKeyCredential(member))))
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
final var groupSecretParams = GroupSecretParams.generate();
|
||||
return groupsV2Operations.createNewGroup(groupSecretParams,
|
||||
name,
|
||||
Optional.fromNullable(avatar),
|
||||
self,
|
||||
candidates,
|
||||
Member.Role.DEFAULT,
|
||||
0);
|
||||
}
|
||||
|
||||
private boolean areMembersValid(final Collection<SignalServiceAddress> members) {
|
||||
final var noUuidCapability = members.stream()
|
||||
.filter(address -> !address.getUuid().isPresent())
|
||||
.map(SignalServiceAddress::getLegacyIdentifier)
|
||||
.collect(Collectors.toSet());
|
||||
if (noUuidCapability.size() > 0) {
|
||||
logger.warn("Cannot create a V2 group as some members don't have a UUID: {}",
|
||||
String.join(", ", noUuidCapability));
|
||||
return false;
|
||||
}
|
||||
|
||||
final var noGv2Capability = members.stream()
|
||||
.map(profileProvider::getProfile)
|
||||
.filter(profile -> profile != null && !profile.getCapabilities().gv2)
|
||||
.collect(Collectors.toSet());
|
||||
if (noGv2Capability.size() > 0) {
|
||||
logger.warn("Cannot create a V2 group as some members don't support Groups V2: {}",
|
||||
noGv2Capability.stream().map(SignalProfile::getDisplayName).collect(Collectors.joining(", ")));
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public Pair<DecryptedGroup, GroupChange> updateGroupV2(
|
||||
GroupInfoV2 groupInfoV2, String name, File avatarFile
|
||||
) throws IOException {
|
||||
final var groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupInfoV2.getMasterKey());
|
||||
var groupOperations = groupsV2Operations.forGroup(groupSecretParams);
|
||||
|
||||
var change = name != null ? groupOperations.createModifyGroupTitle(name) : GroupChange.Actions.newBuilder();
|
||||
|
||||
if (avatarFile != null) {
|
||||
final var avatarBytes = readAvatarBytes(avatarFile);
|
||||
var avatarCdnKey = groupsV2Api.uploadAvatar(avatarBytes,
|
||||
groupSecretParams,
|
||||
groupAuthorizationProvider.getAuthorizationForToday(groupSecretParams));
|
||||
change.setModifyAvatar(GroupChange.Actions.ModifyAvatarAction.newBuilder().setAvatar(avatarCdnKey));
|
||||
}
|
||||
|
||||
final var uuid = this.selfAddressProvider.getSelfAddress().getUuid();
|
||||
if (uuid.isPresent()) {
|
||||
change.setSourceUuid(UuidUtil.toByteString(uuid.get()));
|
||||
}
|
||||
|
||||
return commitChange(groupInfoV2, change);
|
||||
}
|
||||
|
||||
public Pair<DecryptedGroup, GroupChange> updateGroupV2(
|
||||
GroupInfoV2 groupInfoV2, Set<SignalServiceAddress> newMembers
|
||||
) throws IOException {
|
||||
final var groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupInfoV2.getMasterKey());
|
||||
var groupOperations = groupsV2Operations.forGroup(groupSecretParams);
|
||||
|
||||
if (!areMembersValid(newMembers)) {
|
||||
throw new IOException("Failed to update group");
|
||||
}
|
||||
|
||||
var candidates = newMembers.stream()
|
||||
.map(member -> new GroupCandidate(member.getUuid().get(),
|
||||
Optional.fromNullable(profileKeyCredentialProvider.getProfileKeyCredential(member))))
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
final var change = groupOperations.createModifyGroupMembershipChange(candidates,
|
||||
selfAddressProvider.getSelfAddress().getUuid().get());
|
||||
|
||||
final var uuid = this.selfAddressProvider.getSelfAddress().getUuid();
|
||||
if (uuid.isPresent()) {
|
||||
change.setSourceUuid(UuidUtil.toByteString(uuid.get()));
|
||||
}
|
||||
|
||||
return commitChange(groupInfoV2, change);
|
||||
}
|
||||
|
||||
public Pair<DecryptedGroup, GroupChange> leaveGroup(GroupInfoV2 groupInfoV2) throws IOException {
|
||||
var pendingMembersList = groupInfoV2.getGroup().getPendingMembersList();
|
||||
final var selfUuid = selfAddressProvider.getSelfAddress().getUuid().get();
|
||||
var selfPendingMember = DecryptedGroupUtil.findPendingByUuid(pendingMembersList, selfUuid);
|
||||
|
||||
if (selfPendingMember.isPresent()) {
|
||||
return revokeInvites(groupInfoV2, Set.of(selfPendingMember.get()));
|
||||
} else {
|
||||
return ejectMembers(groupInfoV2, Set.of(selfUuid));
|
||||
}
|
||||
}
|
||||
|
||||
public GroupChange joinGroup(
|
||||
GroupMasterKey groupMasterKey,
|
||||
GroupLinkPassword groupLinkPassword,
|
||||
DecryptedGroupJoinInfo decryptedGroupJoinInfo
|
||||
) throws IOException {
|
||||
final var groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupMasterKey);
|
||||
final var groupOperations = groupsV2Operations.forGroup(groupSecretParams);
|
||||
|
||||
final var selfAddress = this.selfAddressProvider.getSelfAddress();
|
||||
final var profileKeyCredential = profileKeyCredentialProvider.getProfileKeyCredential(selfAddress);
|
||||
if (profileKeyCredential == null) {
|
||||
throw new IOException("Cannot join a V2 group as self does not have a versioned profile");
|
||||
}
|
||||
|
||||
var requestToJoin = decryptedGroupJoinInfo.getAddFromInviteLink() == AccessControl.AccessRequired.ADMINISTRATOR;
|
||||
var change = requestToJoin
|
||||
? groupOperations.createGroupJoinRequest(profileKeyCredential)
|
||||
: groupOperations.createGroupJoinDirect(profileKeyCredential);
|
||||
|
||||
change.setSourceUuid(UuidUtil.toByteString(selfAddress.getUuid().get()));
|
||||
|
||||
return commitChange(groupSecretParams, decryptedGroupJoinInfo.getRevision(), change, groupLinkPassword);
|
||||
}
|
||||
|
||||
public Pair<DecryptedGroup, GroupChange> acceptInvite(GroupInfoV2 groupInfoV2) throws IOException {
|
||||
final var groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupInfoV2.getMasterKey());
|
||||
final var groupOperations = groupsV2Operations.forGroup(groupSecretParams);
|
||||
|
||||
final var selfAddress = this.selfAddressProvider.getSelfAddress();
|
||||
final var profileKeyCredential = profileKeyCredentialProvider.getProfileKeyCredential(selfAddress);
|
||||
if (profileKeyCredential == null) {
|
||||
throw new IOException("Cannot join a V2 group as self does not have a versioned profile");
|
||||
}
|
||||
|
||||
final var change = groupOperations.createAcceptInviteChange(profileKeyCredential);
|
||||
|
||||
final var uuid = selfAddress.getUuid();
|
||||
if (uuid.isPresent()) {
|
||||
change.setSourceUuid(UuidUtil.toByteString(uuid.get()));
|
||||
}
|
||||
|
||||
return commitChange(groupInfoV2, change);
|
||||
}
|
||||
|
||||
public Pair<DecryptedGroup, GroupChange> revokeInvites(
|
||||
GroupInfoV2 groupInfoV2, Set<DecryptedPendingMember> pendingMembers
|
||||
) throws IOException {
|
||||
final var groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupInfoV2.getMasterKey());
|
||||
final var groupOperations = groupsV2Operations.forGroup(groupSecretParams);
|
||||
final var uuidCipherTexts = pendingMembers.stream().map(member -> {
|
||||
try {
|
||||
return new UuidCiphertext(member.getUuidCipherText().toByteArray());
|
||||
} catch (InvalidInputException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}).collect(Collectors.toSet());
|
||||
return commitChange(groupInfoV2, groupOperations.createRemoveInvitationChange(uuidCipherTexts));
|
||||
}
|
||||
|
||||
public Pair<DecryptedGroup, GroupChange> ejectMembers(GroupInfoV2 groupInfoV2, Set<UUID> uuids) throws IOException {
|
||||
final var groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupInfoV2.getMasterKey());
|
||||
final var groupOperations = groupsV2Operations.forGroup(groupSecretParams);
|
||||
return commitChange(groupInfoV2, groupOperations.createRemoveMembersChange(uuids));
|
||||
}
|
||||
|
||||
private Pair<DecryptedGroup, GroupChange> commitChange(
|
||||
GroupInfoV2 groupInfoV2, GroupChange.Actions.Builder change
|
||||
) throws IOException {
|
||||
final var groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupInfoV2.getMasterKey());
|
||||
final var groupOperations = groupsV2Operations.forGroup(groupSecretParams);
|
||||
final var previousGroupState = groupInfoV2.getGroup();
|
||||
final var nextRevision = previousGroupState.getRevision() + 1;
|
||||
final var changeActions = change.setRevision(nextRevision).build();
|
||||
final DecryptedGroupChange decryptedChange;
|
||||
final DecryptedGroup decryptedGroupState;
|
||||
|
||||
try {
|
||||
decryptedChange = groupOperations.decryptChange(changeActions,
|
||||
selfAddressProvider.getSelfAddress().getUuid().get());
|
||||
decryptedGroupState = DecryptedGroupUtil.apply(previousGroupState, decryptedChange);
|
||||
} catch (VerificationFailedException | InvalidGroupStateException | NotAbleToApplyGroupV2ChangeException e) {
|
||||
throw new IOException(e);
|
||||
}
|
||||
|
||||
var signedGroupChange = groupsV2Api.patchGroup(changeActions,
|
||||
groupAuthorizationProvider.getAuthorizationForToday(groupSecretParams),
|
||||
Optional.absent());
|
||||
|
||||
return new Pair<>(decryptedGroupState, signedGroupChange);
|
||||
}
|
||||
|
||||
private GroupChange commitChange(
|
||||
GroupSecretParams groupSecretParams,
|
||||
int currentRevision,
|
||||
GroupChange.Actions.Builder change,
|
||||
GroupLinkPassword password
|
||||
) throws IOException {
|
||||
final var nextRevision = currentRevision + 1;
|
||||
final var changeActions = change.setRevision(nextRevision).build();
|
||||
|
||||
return groupsV2Api.patchGroup(changeActions,
|
||||
groupAuthorizationProvider.getAuthorizationForToday(groupSecretParams),
|
||||
Optional.fromNullable(password).transform(GroupLinkPassword::serialize));
|
||||
}
|
||||
|
||||
public DecryptedGroup getUpdatedDecryptedGroup(
|
||||
DecryptedGroup group, byte[] signedGroupChange, GroupMasterKey groupMasterKey
|
||||
) {
|
||||
try {
|
||||
final var decryptedGroupChange = getDecryptedGroupChange(signedGroupChange, groupMasterKey);
|
||||
if (decryptedGroupChange == null) {
|
||||
return null;
|
||||
}
|
||||
return DecryptedGroupUtil.apply(group, decryptedGroupChange);
|
||||
} catch (NotAbleToApplyGroupV2ChangeException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private DecryptedGroupChange getDecryptedGroupChange(byte[] signedGroupChange, GroupMasterKey groupMasterKey) {
|
||||
if (signedGroupChange != null) {
|
||||
var groupOperations = groupsV2Operations.forGroup(GroupSecretParams.deriveFromMasterKey(groupMasterKey));
|
||||
|
||||
try {
|
||||
return groupOperations.decryptChange(GroupChange.parseFrom(signedGroupChange), true).orNull();
|
||||
} catch (VerificationFailedException | InvalidGroupStateException | InvalidProtocolBufferException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
package org.asamk.signal.manager.helper;
|
||||
|
||||
import org.whispersystems.signalservice.api.SignalServiceMessagePipe;
|
||||
|
||||
public interface MessagePipeProvider {
|
||||
|
||||
SignalServiceMessagePipe getMessagePipe(boolean unidentified);
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
package org.asamk.signal.manager.helper;
|
||||
|
||||
import org.whispersystems.signalservice.api.SignalServiceMessageReceiver;
|
||||
|
||||
public interface MessageReceiverProvider {
|
||||
|
||||
SignalServiceMessageReceiver getMessageReceiver();
|
||||
}
|
|
@ -0,0 +1,88 @@
|
|||
package org.asamk.signal.manager.helper;
|
||||
|
||||
import org.asamk.signal.manager.util.PinHashing;
|
||||
import org.whispersystems.libsignal.InvalidKeyException;
|
||||
import org.whispersystems.signalservice.api.KbsPinData;
|
||||
import org.whispersystems.signalservice.api.KeyBackupService;
|
||||
import org.whispersystems.signalservice.api.KeyBackupServicePinException;
|
||||
import org.whispersystems.signalservice.api.KeyBackupSystemNoDataException;
|
||||
import org.whispersystems.signalservice.api.kbs.MasterKey;
|
||||
import org.whispersystems.signalservice.internal.contacts.crypto.UnauthenticatedResponseException;
|
||||
import org.whispersystems.signalservice.internal.contacts.entities.TokenResponse;
|
||||
import org.whispersystems.signalservice.internal.push.LockedException;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
public class PinHelper {
|
||||
|
||||
private final KeyBackupService keyBackupService;
|
||||
|
||||
public PinHelper(final KeyBackupService keyBackupService) {
|
||||
this.keyBackupService = keyBackupService;
|
||||
}
|
||||
|
||||
public void setRegistrationLockPin(
|
||||
String pin, MasterKey masterKey
|
||||
) throws IOException, UnauthenticatedResponseException {
|
||||
final var pinChangeSession = keyBackupService.newPinChangeSession();
|
||||
final var hashedPin = PinHashing.hashPin(pin, pinChangeSession);
|
||||
|
||||
pinChangeSession.setPin(hashedPin, masterKey);
|
||||
pinChangeSession.enableRegistrationLock(masterKey);
|
||||
}
|
||||
|
||||
public void removeRegistrationLockPin() throws IOException, UnauthenticatedResponseException {
|
||||
final var pinChangeSession = keyBackupService.newPinChangeSession();
|
||||
pinChangeSession.disableRegistrationLock();
|
||||
pinChangeSession.removePin();
|
||||
}
|
||||
|
||||
public KbsPinData getRegistrationLockData(
|
||||
String pin, LockedException e
|
||||
) throws IOException, KeyBackupSystemNoDataException, KeyBackupServicePinException {
|
||||
var basicStorageCredentials = e.getBasicStorageCredentials();
|
||||
if (basicStorageCredentials == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return getRegistrationLockData(pin, basicStorageCredentials);
|
||||
}
|
||||
|
||||
private KbsPinData getRegistrationLockData(
|
||||
String pin, String basicStorageCredentials
|
||||
) throws IOException, KeyBackupSystemNoDataException, KeyBackupServicePinException {
|
||||
var tokenResponse = keyBackupService.getToken(basicStorageCredentials);
|
||||
if (tokenResponse == null || tokenResponse.getTries() == 0) {
|
||||
throw new IOException("KBS Account locked");
|
||||
}
|
||||
|
||||
var registrationLockData = restoreMasterKey(pin, basicStorageCredentials, tokenResponse);
|
||||
if (registrationLockData == null) {
|
||||
throw new AssertionError("Failed to restore master key");
|
||||
}
|
||||
return registrationLockData;
|
||||
}
|
||||
|
||||
private KbsPinData restoreMasterKey(
|
||||
String pin, String basicStorageCredentials, TokenResponse tokenResponse
|
||||
) throws IOException, KeyBackupSystemNoDataException, KeyBackupServicePinException {
|
||||
if (pin == null) return null;
|
||||
|
||||
if (basicStorageCredentials == null) {
|
||||
throw new AssertionError("Cannot restore KBS key, no storage credentials supplied");
|
||||
}
|
||||
|
||||
var session = keyBackupService.newRegistrationSession(basicStorageCredentials, tokenResponse);
|
||||
|
||||
try {
|
||||
var hashedPin = PinHashing.hashPin(pin, session);
|
||||
var kbsData = session.restorePin(hashedPin);
|
||||
if (kbsData == null) {
|
||||
throw new AssertionError("Null not expected");
|
||||
}
|
||||
return kbsData;
|
||||
} catch (UnauthenticatedResponseException | InvalidKeyException e) {
|
||||
throw new IOException(e);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,138 @@
|
|||
package org.asamk.signal.manager.helper;
|
||||
|
||||
import org.signal.zkgroup.profiles.ProfileKey;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
import org.whispersystems.signalservice.api.crypto.UnidentifiedAccess;
|
||||
import org.whispersystems.signalservice.api.profiles.ProfileAndCredential;
|
||||
import org.whispersystems.signalservice.api.profiles.SignalServiceProfile;
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
|
||||
import org.whispersystems.signalservice.api.push.exceptions.NotFoundException;
|
||||
import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException;
|
||||
import org.whispersystems.signalservice.internal.util.concurrent.CascadingFuture;
|
||||
import org.whispersystems.signalservice.internal.util.concurrent.ListenableFuture;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Arrays;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
|
||||
public final class ProfileHelper {
|
||||
|
||||
private final ProfileKeyProvider profileKeyProvider;
|
||||
|
||||
private final UnidentifiedAccessProvider unidentifiedAccessProvider;
|
||||
|
||||
private final MessagePipeProvider messagePipeProvider;
|
||||
|
||||
private final MessageReceiverProvider messageReceiverProvider;
|
||||
|
||||
public ProfileHelper(
|
||||
final ProfileKeyProvider profileKeyProvider,
|
||||
final UnidentifiedAccessProvider unidentifiedAccessProvider,
|
||||
final MessagePipeProvider messagePipeProvider,
|
||||
final MessageReceiverProvider messageReceiverProvider
|
||||
) {
|
||||
this.profileKeyProvider = profileKeyProvider;
|
||||
this.unidentifiedAccessProvider = unidentifiedAccessProvider;
|
||||
this.messagePipeProvider = messagePipeProvider;
|
||||
this.messageReceiverProvider = messageReceiverProvider;
|
||||
}
|
||||
|
||||
public ProfileAndCredential retrieveProfileSync(
|
||||
SignalServiceAddress recipient, SignalServiceProfile.RequestType requestType
|
||||
) throws IOException {
|
||||
try {
|
||||
return retrieveProfile(recipient, requestType).get(10, TimeUnit.SECONDS);
|
||||
} catch (ExecutionException e) {
|
||||
if (e.getCause() instanceof PushNetworkException) {
|
||||
throw (PushNetworkException) e.getCause();
|
||||
} else if (e.getCause() instanceof NotFoundException) {
|
||||
throw (NotFoundException) e.getCause();
|
||||
} else {
|
||||
throw new IOException(e);
|
||||
}
|
||||
} catch (InterruptedException | TimeoutException e) {
|
||||
throw new PushNetworkException(e);
|
||||
}
|
||||
}
|
||||
|
||||
public ListenableFuture<ProfileAndCredential> retrieveProfile(
|
||||
SignalServiceAddress address, SignalServiceProfile.RequestType requestType
|
||||
) {
|
||||
var unidentifiedAccess = getUnidentifiedAccess(address);
|
||||
var profileKey = Optional.fromNullable(profileKeyProvider.getProfileKey(address));
|
||||
|
||||
if (unidentifiedAccess.isPresent()) {
|
||||
return new CascadingFuture<>(Arrays.asList(() -> getPipeRetrievalFuture(address,
|
||||
profileKey,
|
||||
unidentifiedAccess,
|
||||
requestType),
|
||||
() -> getSocketRetrievalFuture(address, profileKey, unidentifiedAccess, requestType),
|
||||
() -> getPipeRetrievalFuture(address, profileKey, Optional.absent(), requestType),
|
||||
() -> getSocketRetrievalFuture(address, profileKey, Optional.absent(), requestType)),
|
||||
e -> !(e instanceof NotFoundException));
|
||||
} else {
|
||||
return new CascadingFuture<>(Arrays.asList(() -> getPipeRetrievalFuture(address,
|
||||
profileKey,
|
||||
Optional.absent(),
|
||||
requestType), () -> getSocketRetrievalFuture(address, profileKey, Optional.absent(), requestType)),
|
||||
e -> !(e instanceof NotFoundException));
|
||||
}
|
||||
}
|
||||
|
||||
private ListenableFuture<ProfileAndCredential> getPipeRetrievalFuture(
|
||||
SignalServiceAddress address,
|
||||
Optional<ProfileKey> profileKey,
|
||||
Optional<UnidentifiedAccess> unidentifiedAccess,
|
||||
SignalServiceProfile.RequestType requestType
|
||||
) throws IOException {
|
||||
var unidentifiedPipe = messagePipeProvider.getMessagePipe(true);
|
||||
var pipe = unidentifiedPipe != null && unidentifiedAccess.isPresent()
|
||||
? unidentifiedPipe
|
||||
: messagePipeProvider.getMessagePipe(false);
|
||||
if (pipe != null) {
|
||||
try {
|
||||
return pipe.getProfile(address, profileKey, unidentifiedAccess, requestType);
|
||||
} catch (NoClassDefFoundError e) {
|
||||
// Native zkgroup lib not available for ProfileKey
|
||||
if (!address.getNumber().isPresent()) {
|
||||
throw new NotFoundException("Can't request profile without number");
|
||||
}
|
||||
var addressWithoutUuid = new SignalServiceAddress(Optional.absent(), address.getNumber());
|
||||
return pipe.getProfile(addressWithoutUuid, profileKey, unidentifiedAccess, requestType);
|
||||
}
|
||||
}
|
||||
|
||||
throw new IOException("No pipe available!");
|
||||
}
|
||||
|
||||
private ListenableFuture<ProfileAndCredential> getSocketRetrievalFuture(
|
||||
SignalServiceAddress address,
|
||||
Optional<ProfileKey> profileKey,
|
||||
Optional<UnidentifiedAccess> unidentifiedAccess,
|
||||
SignalServiceProfile.RequestType requestType
|
||||
) throws NotFoundException {
|
||||
var receiver = messageReceiverProvider.getMessageReceiver();
|
||||
try {
|
||||
return receiver.retrieveProfile(address, profileKey, unidentifiedAccess, requestType);
|
||||
} catch (NoClassDefFoundError e) {
|
||||
// Native zkgroup lib not available for ProfileKey
|
||||
if (!address.getNumber().isPresent()) {
|
||||
throw new NotFoundException("Can't request profile without number");
|
||||
}
|
||||
var addressWithoutUuid = new SignalServiceAddress(Optional.absent(), address.getNumber());
|
||||
return receiver.retrieveProfile(addressWithoutUuid, profileKey, unidentifiedAccess, requestType);
|
||||
}
|
||||
}
|
||||
|
||||
private Optional<UnidentifiedAccess> getUnidentifiedAccess(SignalServiceAddress recipient) {
|
||||
var unidentifiedAccess = unidentifiedAccessProvider.getAccessFor(recipient);
|
||||
|
||||
if (unidentifiedAccess.isPresent()) {
|
||||
return unidentifiedAccess.get().getTargetUnidentifiedAccess();
|
||||
}
|
||||
|
||||
return Optional.absent();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
package org.asamk.signal.manager.helper;
|
||||
|
||||
import org.signal.zkgroup.profiles.ProfileKeyCredential;
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
|
||||
|
||||
public interface ProfileKeyCredentialProvider {
|
||||
|
||||
ProfileKeyCredential getProfileKeyCredential(SignalServiceAddress address);
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
package org.asamk.signal.manager.helper;
|
||||
|
||||
import org.signal.zkgroup.profiles.ProfileKey;
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
|
||||
|
||||
public interface ProfileKeyProvider {
|
||||
|
||||
ProfileKey getProfileKey(SignalServiceAddress address);
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
package org.asamk.signal.manager.helper;
|
||||
|
||||
import org.asamk.signal.manager.storage.profiles.SignalProfile;
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
|
||||
|
||||
public interface ProfileProvider {
|
||||
|
||||
SignalProfile getProfile(SignalServiceAddress address);
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
package org.asamk.signal.manager.helper;
|
||||
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
|
||||
|
||||
public interface SelfAddressProvider {
|
||||
|
||||
SignalServiceAddress getSelfAddress();
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
package org.asamk.signal.manager.helper;
|
||||
|
||||
import org.signal.zkgroup.profiles.ProfileKey;
|
||||
|
||||
public interface SelfProfileKeyProvider {
|
||||
|
||||
ProfileKey getProfileKey();
|
||||
}
|
|
@ -0,0 +1,103 @@
|
|||
package org.asamk.signal.manager.helper;
|
||||
|
||||
import org.signal.libsignal.metadata.certificate.InvalidCertificateException;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
import org.whispersystems.signalservice.api.crypto.UnidentifiedAccess;
|
||||
import org.whispersystems.signalservice.api.crypto.UnidentifiedAccessPair;
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static org.whispersystems.signalservice.internal.util.Util.getSecretBytes;
|
||||
|
||||
public class UnidentifiedAccessHelper {
|
||||
|
||||
private final SelfProfileKeyProvider selfProfileKeyProvider;
|
||||
|
||||
private final ProfileKeyProvider profileKeyProvider;
|
||||
|
||||
private final ProfileProvider profileProvider;
|
||||
|
||||
private final UnidentifiedAccessSenderCertificateProvider senderCertificateProvider;
|
||||
|
||||
public UnidentifiedAccessHelper(
|
||||
final SelfProfileKeyProvider selfProfileKeyProvider,
|
||||
final ProfileKeyProvider profileKeyProvider,
|
||||
final ProfileProvider profileProvider,
|
||||
final UnidentifiedAccessSenderCertificateProvider senderCertificateProvider
|
||||
) {
|
||||
this.selfProfileKeyProvider = selfProfileKeyProvider;
|
||||
this.profileKeyProvider = profileKeyProvider;
|
||||
this.profileProvider = profileProvider;
|
||||
this.senderCertificateProvider = senderCertificateProvider;
|
||||
}
|
||||
|
||||
private byte[] getSelfUnidentifiedAccessKey() {
|
||||
return UnidentifiedAccess.deriveAccessKeyFrom(selfProfileKeyProvider.getProfileKey());
|
||||
}
|
||||
|
||||
public byte[] getTargetUnidentifiedAccessKey(SignalServiceAddress recipient) {
|
||||
var theirProfileKey = profileKeyProvider.getProfileKey(recipient);
|
||||
if (theirProfileKey == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
var targetProfile = profileProvider.getProfile(recipient);
|
||||
if (targetProfile == null || targetProfile.getUnidentifiedAccess() == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (targetProfile.isUnrestrictedUnidentifiedAccess()) {
|
||||
return createUnrestrictedUnidentifiedAccess();
|
||||
}
|
||||
|
||||
return UnidentifiedAccess.deriveAccessKeyFrom(theirProfileKey);
|
||||
}
|
||||
|
||||
public Optional<UnidentifiedAccessPair> getAccessForSync() {
|
||||
var selfUnidentifiedAccessKey = getSelfUnidentifiedAccessKey();
|
||||
var selfUnidentifiedAccessCertificate = senderCertificateProvider.getSenderCertificate();
|
||||
|
||||
if (selfUnidentifiedAccessKey == null || selfUnidentifiedAccessCertificate == null) {
|
||||
return Optional.absent();
|
||||
}
|
||||
|
||||
try {
|
||||
return Optional.of(new UnidentifiedAccessPair(new UnidentifiedAccess(selfUnidentifiedAccessKey,
|
||||
selfUnidentifiedAccessCertificate),
|
||||
new UnidentifiedAccess(selfUnidentifiedAccessKey, selfUnidentifiedAccessCertificate)));
|
||||
} catch (InvalidCertificateException e) {
|
||||
return Optional.absent();
|
||||
}
|
||||
}
|
||||
|
||||
public List<Optional<UnidentifiedAccessPair>> getAccessFor(Collection<SignalServiceAddress> recipients) {
|
||||
return recipients.stream().map(this::getAccessFor).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
public Optional<UnidentifiedAccessPair> getAccessFor(SignalServiceAddress recipient) {
|
||||
var recipientUnidentifiedAccessKey = getTargetUnidentifiedAccessKey(recipient);
|
||||
var selfUnidentifiedAccessKey = getSelfUnidentifiedAccessKey();
|
||||
var selfUnidentifiedAccessCertificate = senderCertificateProvider.getSenderCertificate();
|
||||
|
||||
if (recipientUnidentifiedAccessKey == null
|
||||
|| selfUnidentifiedAccessKey == null
|
||||
|| selfUnidentifiedAccessCertificate == null) {
|
||||
return Optional.absent();
|
||||
}
|
||||
|
||||
try {
|
||||
return Optional.of(new UnidentifiedAccessPair(new UnidentifiedAccess(recipientUnidentifiedAccessKey,
|
||||
selfUnidentifiedAccessCertificate),
|
||||
new UnidentifiedAccess(selfUnidentifiedAccessKey, selfUnidentifiedAccessCertificate)));
|
||||
} catch (InvalidCertificateException e) {
|
||||
return Optional.absent();
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] createUnrestrictedUnidentifiedAccess() {
|
||||
return getSecretBytes(16);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,10 @@
|
|||
package org.asamk.signal.manager.helper;
|
||||
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
import org.whispersystems.signalservice.api.crypto.UnidentifiedAccessPair;
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
|
||||
|
||||
public interface UnidentifiedAccessProvider {
|
||||
|
||||
Optional<UnidentifiedAccessPair> getAccessFor(SignalServiceAddress address);
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
package org.asamk.signal.manager.helper;
|
||||
|
||||
public interface UnidentifiedAccessSenderCertificateProvider {
|
||||
|
||||
byte[] getSenderCertificate();
|
||||
}
|
|
@ -0,0 +1,596 @@
|
|||
package org.asamk.signal.manager.storage;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonAutoDetect;
|
||||
import com.fasterxml.jackson.annotation.PropertyAccessor;
|
||||
import com.fasterxml.jackson.core.JsonGenerator;
|
||||
import com.fasterxml.jackson.core.JsonParser;
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||
|
||||
import org.asamk.signal.manager.groups.GroupId;
|
||||
import org.asamk.signal.manager.storage.contacts.JsonContactsStore;
|
||||
import org.asamk.signal.manager.storage.groups.GroupInfoV1;
|
||||
import org.asamk.signal.manager.storage.groups.JsonGroupStore;
|
||||
import org.asamk.signal.manager.storage.messageCache.MessageCache;
|
||||
import org.asamk.signal.manager.storage.profiles.ProfileStore;
|
||||
import org.asamk.signal.manager.storage.protocol.JsonSignalProtocolStore;
|
||||
import org.asamk.signal.manager.storage.protocol.RecipientStore;
|
||||
import org.asamk.signal.manager.storage.protocol.SignalServiceAddressResolver;
|
||||
import org.asamk.signal.manager.storage.stickers.StickerStore;
|
||||
import org.asamk.signal.manager.storage.threads.LegacyJsonThreadStore;
|
||||
import org.asamk.signal.manager.util.IOUtils;
|
||||
import org.asamk.signal.manager.util.KeyUtils;
|
||||
import org.asamk.signal.manager.util.Utils;
|
||||
import org.signal.zkgroup.InvalidInputException;
|
||||
import org.signal.zkgroup.profiles.ProfileKey;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.whispersystems.libsignal.IdentityKeyPair;
|
||||
import org.whispersystems.libsignal.state.PreKeyRecord;
|
||||
import org.whispersystems.libsignal.state.SignedPreKeyRecord;
|
||||
import org.whispersystems.libsignal.util.Medium;
|
||||
import org.whispersystems.libsignal.util.Pair;
|
||||
import org.whispersystems.signalservice.api.crypto.UnidentifiedAccess;
|
||||
import org.whispersystems.signalservice.api.kbs.MasterKey;
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
|
||||
import org.whispersystems.signalservice.api.storage.StorageKey;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.Closeable;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.RandomAccessFile;
|
||||
import java.nio.channels.Channels;
|
||||
import java.nio.channels.ClosedChannelException;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.channels.FileLock;
|
||||
import java.util.Base64;
|
||||
import java.util.Collection;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public class SignalAccount implements Closeable {
|
||||
|
||||
private final static Logger logger = LoggerFactory.getLogger(SignalAccount.class);
|
||||
|
||||
private final ObjectMapper jsonProcessor = new ObjectMapper();
|
||||
private final FileChannel fileChannel;
|
||||
private final FileLock lock;
|
||||
private String username;
|
||||
private UUID uuid;
|
||||
private int deviceId = SignalServiceAddress.DEFAULT_DEVICE_ID;
|
||||
private boolean isMultiDevice = false;
|
||||
private String password;
|
||||
private String registrationLockPin;
|
||||
private MasterKey pinMasterKey;
|
||||
private StorageKey storageKey;
|
||||
private ProfileKey profileKey;
|
||||
private int preKeyIdOffset;
|
||||
private int nextSignedPreKeyId;
|
||||
|
||||
private boolean registered = false;
|
||||
|
||||
private JsonSignalProtocolStore signalProtocolStore;
|
||||
private JsonGroupStore groupStore;
|
||||
private JsonContactsStore contactStore;
|
||||
private RecipientStore recipientStore;
|
||||
private ProfileStore profileStore;
|
||||
private StickerStore stickerStore;
|
||||
|
||||
private MessageCache messageCache;
|
||||
|
||||
private SignalAccount(final FileChannel fileChannel, final FileLock lock) {
|
||||
this.fileChannel = fileChannel;
|
||||
this.lock = lock;
|
||||
jsonProcessor.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.NONE); // disable autodetect
|
||||
jsonProcessor.enable(SerializationFeature.INDENT_OUTPUT); // for pretty print
|
||||
jsonProcessor.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);
|
||||
jsonProcessor.disable(JsonParser.Feature.AUTO_CLOSE_SOURCE);
|
||||
jsonProcessor.disable(JsonGenerator.Feature.AUTO_CLOSE_TARGET);
|
||||
}
|
||||
|
||||
public static SignalAccount load(File dataPath, String username) throws IOException {
|
||||
final var fileName = getFileName(dataPath, username);
|
||||
final var pair = openFileChannel(fileName);
|
||||
try {
|
||||
var account = new SignalAccount(pair.first(), pair.second());
|
||||
account.load(dataPath);
|
||||
account.migrateLegacyConfigs();
|
||||
|
||||
return account;
|
||||
} catch (Throwable e) {
|
||||
pair.second().close();
|
||||
pair.first().close();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
public static SignalAccount create(
|
||||
File dataPath, String username, IdentityKeyPair identityKey, int registrationId, ProfileKey profileKey
|
||||
) throws IOException {
|
||||
IOUtils.createPrivateDirectories(dataPath);
|
||||
var fileName = getFileName(dataPath, username);
|
||||
if (!fileName.exists()) {
|
||||
IOUtils.createPrivateFile(fileName);
|
||||
}
|
||||
|
||||
final var pair = openFileChannel(fileName);
|
||||
var account = new SignalAccount(pair.first(), pair.second());
|
||||
|
||||
account.username = username;
|
||||
account.profileKey = profileKey;
|
||||
account.signalProtocolStore = new JsonSignalProtocolStore(identityKey, registrationId);
|
||||
account.groupStore = new JsonGroupStore(getGroupCachePath(dataPath, username));
|
||||
account.contactStore = new JsonContactsStore();
|
||||
account.recipientStore = new RecipientStore();
|
||||
account.profileStore = new ProfileStore();
|
||||
account.stickerStore = new StickerStore();
|
||||
|
||||
account.messageCache = new MessageCache(getMessageCachePath(dataPath, username));
|
||||
|
||||
account.registered = false;
|
||||
|
||||
account.migrateLegacyConfigs();
|
||||
|
||||
return account;
|
||||
}
|
||||
|
||||
public static SignalAccount createLinkedAccount(
|
||||
File dataPath,
|
||||
String username,
|
||||
UUID uuid,
|
||||
String password,
|
||||
int deviceId,
|
||||
IdentityKeyPair identityKey,
|
||||
int registrationId,
|
||||
ProfileKey profileKey
|
||||
) throws IOException {
|
||||
IOUtils.createPrivateDirectories(dataPath);
|
||||
var fileName = getFileName(dataPath, username);
|
||||
if (!fileName.exists()) {
|
||||
IOUtils.createPrivateFile(fileName);
|
||||
}
|
||||
|
||||
final var pair = openFileChannel(fileName);
|
||||
var account = new SignalAccount(pair.first(), pair.second());
|
||||
|
||||
account.username = username;
|
||||
account.uuid = uuid;
|
||||
account.password = password;
|
||||
account.profileKey = profileKey;
|
||||
account.deviceId = deviceId;
|
||||
account.signalProtocolStore = new JsonSignalProtocolStore(identityKey, registrationId);
|
||||
account.groupStore = new JsonGroupStore(getGroupCachePath(dataPath, username));
|
||||
account.contactStore = new JsonContactsStore();
|
||||
account.recipientStore = new RecipientStore();
|
||||
account.profileStore = new ProfileStore();
|
||||
account.stickerStore = new StickerStore();
|
||||
|
||||
account.messageCache = new MessageCache(getMessageCachePath(dataPath, username));
|
||||
|
||||
account.registered = true;
|
||||
account.isMultiDevice = true;
|
||||
|
||||
account.migrateLegacyConfigs();
|
||||
|
||||
return account;
|
||||
}
|
||||
|
||||
public void migrateLegacyConfigs() {
|
||||
if (getProfileKey() == null && isRegistered()) {
|
||||
// Old config file, creating new profile key
|
||||
setProfileKey(KeyUtils.createProfileKey());
|
||||
save();
|
||||
}
|
||||
// Store profile keys only in profile store
|
||||
for (var contact : getContactStore().getContacts()) {
|
||||
var profileKeyString = contact.profileKey;
|
||||
if (profileKeyString == null) {
|
||||
continue;
|
||||
}
|
||||
final ProfileKey profileKey;
|
||||
try {
|
||||
profileKey = new ProfileKey(Base64.getDecoder().decode(profileKeyString));
|
||||
} catch (InvalidInputException ignored) {
|
||||
continue;
|
||||
}
|
||||
contact.profileKey = null;
|
||||
getProfileStore().storeProfileKey(contact.getAddress(), profileKey);
|
||||
}
|
||||
// Ensure our profile key is stored in profile store
|
||||
getProfileStore().storeProfileKey(getSelfAddress(), getProfileKey());
|
||||
}
|
||||
|
||||
public static File getFileName(File dataPath, String username) {
|
||||
return new File(dataPath, username);
|
||||
}
|
||||
|
||||
private static File getUserPath(final File dataPath, final String username) {
|
||||
return new File(dataPath, username + ".d");
|
||||
}
|
||||
|
||||
public static File getMessageCachePath(File dataPath, String username) {
|
||||
return new File(getUserPath(dataPath, username), "msg-cache");
|
||||
}
|
||||
|
||||
private static File getGroupCachePath(File dataPath, String username) {
|
||||
return new File(getUserPath(dataPath, username), "group-cache");
|
||||
}
|
||||
|
||||
public static boolean userExists(File dataPath, String username) {
|
||||
if (username == null) {
|
||||
return false;
|
||||
}
|
||||
var f = getFileName(dataPath, username);
|
||||
return !(!f.exists() || f.isDirectory());
|
||||
}
|
||||
|
||||
private void load(File dataPath) throws IOException {
|
||||
JsonNode rootNode;
|
||||
synchronized (fileChannel) {
|
||||
fileChannel.position(0);
|
||||
rootNode = jsonProcessor.readTree(Channels.newInputStream(fileChannel));
|
||||
}
|
||||
|
||||
if (rootNode.hasNonNull("uuid")) {
|
||||
try {
|
||||
uuid = UUID.fromString(rootNode.get("uuid").asText());
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw new IOException("Config file contains an invalid uuid, needs to be a valid UUID", e);
|
||||
}
|
||||
}
|
||||
if (rootNode.hasNonNull("deviceId")) {
|
||||
deviceId = rootNode.get("deviceId").asInt();
|
||||
}
|
||||
if (rootNode.hasNonNull("isMultiDevice")) {
|
||||
isMultiDevice = rootNode.get("isMultiDevice").asBoolean();
|
||||
}
|
||||
username = Utils.getNotNullNode(rootNode, "username").asText();
|
||||
password = Utils.getNotNullNode(rootNode, "password").asText();
|
||||
if (rootNode.hasNonNull("registrationLockPin")) {
|
||||
registrationLockPin = rootNode.get("registrationLockPin").asText();
|
||||
}
|
||||
if (rootNode.hasNonNull("pinMasterKey")) {
|
||||
pinMasterKey = new MasterKey(Base64.getDecoder().decode(rootNode.get("pinMasterKey").asText()));
|
||||
}
|
||||
if (rootNode.hasNonNull("storageKey")) {
|
||||
storageKey = new StorageKey(Base64.getDecoder().decode(rootNode.get("storageKey").asText()));
|
||||
}
|
||||
if (rootNode.hasNonNull("preKeyIdOffset")) {
|
||||
preKeyIdOffset = rootNode.get("preKeyIdOffset").asInt(0);
|
||||
} else {
|
||||
preKeyIdOffset = 0;
|
||||
}
|
||||
if (rootNode.hasNonNull("nextSignedPreKeyId")) {
|
||||
nextSignedPreKeyId = rootNode.get("nextSignedPreKeyId").asInt();
|
||||
} else {
|
||||
nextSignedPreKeyId = 0;
|
||||
}
|
||||
if (rootNode.hasNonNull("profileKey")) {
|
||||
try {
|
||||
profileKey = new ProfileKey(Base64.getDecoder().decode(rootNode.get("profileKey").asText()));
|
||||
} catch (InvalidInputException e) {
|
||||
throw new IOException(
|
||||
"Config file contains an invalid profileKey, needs to be base64 encoded array of 32 bytes",
|
||||
e);
|
||||
}
|
||||
}
|
||||
|
||||
signalProtocolStore = jsonProcessor.convertValue(Utils.getNotNullNode(rootNode, "axolotlStore"),
|
||||
JsonSignalProtocolStore.class);
|
||||
registered = Utils.getNotNullNode(rootNode, "registered").asBoolean();
|
||||
var groupStoreNode = rootNode.get("groupStore");
|
||||
if (groupStoreNode != null) {
|
||||
groupStore = jsonProcessor.convertValue(groupStoreNode, JsonGroupStore.class);
|
||||
groupStore.groupCachePath = getGroupCachePath(dataPath, username);
|
||||
}
|
||||
if (groupStore == null) {
|
||||
groupStore = new JsonGroupStore(getGroupCachePath(dataPath, username));
|
||||
}
|
||||
|
||||
var contactStoreNode = rootNode.get("contactStore");
|
||||
if (contactStoreNode != null) {
|
||||
contactStore = jsonProcessor.convertValue(contactStoreNode, JsonContactsStore.class);
|
||||
}
|
||||
if (contactStore == null) {
|
||||
contactStore = new JsonContactsStore();
|
||||
}
|
||||
|
||||
var recipientStoreNode = rootNode.get("recipientStore");
|
||||
if (recipientStoreNode != null) {
|
||||
recipientStore = jsonProcessor.convertValue(recipientStoreNode, RecipientStore.class);
|
||||
}
|
||||
if (recipientStore == null) {
|
||||
recipientStore = new RecipientStore();
|
||||
|
||||
recipientStore.resolveServiceAddress(getSelfAddress());
|
||||
|
||||
for (var contact : contactStore.getContacts()) {
|
||||
recipientStore.resolveServiceAddress(contact.getAddress());
|
||||
}
|
||||
|
||||
for (var group : groupStore.getGroups()) {
|
||||
if (group instanceof GroupInfoV1) {
|
||||
var groupInfoV1 = (GroupInfoV1) group;
|
||||
groupInfoV1.members = groupInfoV1.members.stream()
|
||||
.map(m -> recipientStore.resolveServiceAddress(m))
|
||||
.collect(Collectors.toSet());
|
||||
}
|
||||
}
|
||||
|
||||
for (var session : signalProtocolStore.getSessions()) {
|
||||
session.address = recipientStore.resolveServiceAddress(session.address);
|
||||
}
|
||||
|
||||
for (var identity : signalProtocolStore.getIdentities()) {
|
||||
identity.setAddress(recipientStore.resolveServiceAddress(identity.getAddress()));
|
||||
}
|
||||
}
|
||||
|
||||
var profileStoreNode = rootNode.get("profileStore");
|
||||
if (profileStoreNode != null) {
|
||||
profileStore = jsonProcessor.convertValue(profileStoreNode, ProfileStore.class);
|
||||
}
|
||||
if (profileStore == null) {
|
||||
profileStore = new ProfileStore();
|
||||
}
|
||||
|
||||
var stickerStoreNode = rootNode.get("stickerStore");
|
||||
if (stickerStoreNode != null) {
|
||||
stickerStore = jsonProcessor.convertValue(stickerStoreNode, StickerStore.class);
|
||||
}
|
||||
if (stickerStore == null) {
|
||||
stickerStore = new StickerStore();
|
||||
}
|
||||
|
||||
messageCache = new MessageCache(getMessageCachePath(dataPath, username));
|
||||
|
||||
var threadStoreNode = rootNode.get("threadStore");
|
||||
if (threadStoreNode != null && !threadStoreNode.isNull()) {
|
||||
var threadStore = jsonProcessor.convertValue(threadStoreNode, LegacyJsonThreadStore.class);
|
||||
// Migrate thread info to group and contact store
|
||||
for (var thread : threadStore.getThreads()) {
|
||||
if (thread.id == null || thread.id.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
var contactInfo = contactStore.getContact(new SignalServiceAddress(null, thread.id));
|
||||
if (contactInfo != null) {
|
||||
contactInfo.messageExpirationTime = thread.messageExpirationTime;
|
||||
contactStore.updateContact(contactInfo);
|
||||
} else {
|
||||
var groupInfo = groupStore.getGroup(GroupId.fromBase64(thread.id));
|
||||
if (groupInfo instanceof GroupInfoV1) {
|
||||
((GroupInfoV1) groupInfo).messageExpirationTime = thread.messageExpirationTime;
|
||||
groupStore.updateGroup(groupInfo);
|
||||
}
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void save() {
|
||||
if (fileChannel == null) {
|
||||
return;
|
||||
}
|
||||
var rootNode = jsonProcessor.createObjectNode();
|
||||
rootNode.put("username", username)
|
||||
.put("uuid", uuid == null ? null : uuid.toString())
|
||||
.put("deviceId", deviceId)
|
||||
.put("isMultiDevice", isMultiDevice)
|
||||
.put("password", password)
|
||||
.put("registrationLockPin", registrationLockPin)
|
||||
.put("pinMasterKey",
|
||||
pinMasterKey == null ? null : Base64.getEncoder().encodeToString(pinMasterKey.serialize()))
|
||||
.put("storageKey",
|
||||
storageKey == null ? null : Base64.getEncoder().encodeToString(storageKey.serialize()))
|
||||
.put("preKeyIdOffset", preKeyIdOffset)
|
||||
.put("nextSignedPreKeyId", nextSignedPreKeyId)
|
||||
.put("profileKey", Base64.getEncoder().encodeToString(profileKey.serialize()))
|
||||
.put("registered", registered)
|
||||
.putPOJO("axolotlStore", signalProtocolStore)
|
||||
.putPOJO("groupStore", groupStore)
|
||||
.putPOJO("contactStore", contactStore)
|
||||
.putPOJO("recipientStore", recipientStore)
|
||||
.putPOJO("profileStore", profileStore)
|
||||
.putPOJO("stickerStore", stickerStore);
|
||||
try {
|
||||
try (var output = new ByteArrayOutputStream()) {
|
||||
// Write to memory first to prevent corrupting the file in case of serialization errors
|
||||
jsonProcessor.writeValue(output, rootNode);
|
||||
var input = new ByteArrayInputStream(output.toByteArray());
|
||||
synchronized (fileChannel) {
|
||||
fileChannel.position(0);
|
||||
input.transferTo(Channels.newOutputStream(fileChannel));
|
||||
fileChannel.truncate(fileChannel.position());
|
||||
fileChannel.force(false);
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
logger.error("Error saving file: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private static Pair<FileChannel, FileLock> openFileChannel(File fileName) throws IOException {
|
||||
var fileChannel = new RandomAccessFile(fileName, "rw").getChannel();
|
||||
var lock = fileChannel.tryLock();
|
||||
if (lock == null) {
|
||||
logger.info("Config file is in use by another instance, waiting…");
|
||||
lock = fileChannel.lock();
|
||||
logger.info("Config file lock acquired.");
|
||||
}
|
||||
return new Pair<>(fileChannel, lock);
|
||||
}
|
||||
|
||||
public void setResolver(final SignalServiceAddressResolver resolver) {
|
||||
signalProtocolStore.setResolver(resolver);
|
||||
}
|
||||
|
||||
public void addPreKeys(Collection<PreKeyRecord> records) {
|
||||
for (var record : records) {
|
||||
signalProtocolStore.storePreKey(record.getId(), record);
|
||||
}
|
||||
preKeyIdOffset = (preKeyIdOffset + records.size()) % Medium.MAX_VALUE;
|
||||
}
|
||||
|
||||
public void addSignedPreKey(SignedPreKeyRecord record) {
|
||||
signalProtocolStore.storeSignedPreKey(record.getId(), record);
|
||||
nextSignedPreKeyId = (nextSignedPreKeyId + 1) % Medium.MAX_VALUE;
|
||||
}
|
||||
|
||||
public JsonSignalProtocolStore getSignalProtocolStore() {
|
||||
return signalProtocolStore;
|
||||
}
|
||||
|
||||
public JsonGroupStore getGroupStore() {
|
||||
return groupStore;
|
||||
}
|
||||
|
||||
public JsonContactsStore getContactStore() {
|
||||
return contactStore;
|
||||
}
|
||||
|
||||
public RecipientStore getRecipientStore() {
|
||||
return recipientStore;
|
||||
}
|
||||
|
||||
public ProfileStore getProfileStore() {
|
||||
return profileStore;
|
||||
}
|
||||
|
||||
public StickerStore getStickerStore() {
|
||||
return stickerStore;
|
||||
}
|
||||
|
||||
public MessageCache getMessageCache() {
|
||||
return messageCache;
|
||||
}
|
||||
|
||||
public String getUsername() {
|
||||
return username;
|
||||
}
|
||||
|
||||
public UUID getUuid() {
|
||||
return uuid;
|
||||
}
|
||||
|
||||
public void setUuid(final UUID uuid) {
|
||||
this.uuid = uuid;
|
||||
}
|
||||
|
||||
public SignalServiceAddress getSelfAddress() {
|
||||
return new SignalServiceAddress(uuid, username);
|
||||
}
|
||||
|
||||
public int getDeviceId() {
|
||||
return deviceId;
|
||||
}
|
||||
|
||||
public void setDeviceId(final int deviceId) {
|
||||
this.deviceId = deviceId;
|
||||
}
|
||||
|
||||
public boolean isMasterDevice() {
|
||||
return deviceId == SignalServiceAddress.DEFAULT_DEVICE_ID;
|
||||
}
|
||||
|
||||
public String getPassword() {
|
||||
return password;
|
||||
}
|
||||
|
||||
public void setPassword(final String password) {
|
||||
this.password = password;
|
||||
}
|
||||
|
||||
public String getRegistrationLockPin() {
|
||||
return registrationLockPin;
|
||||
}
|
||||
|
||||
public void setRegistrationLockPin(final String registrationLockPin) {
|
||||
this.registrationLockPin = registrationLockPin;
|
||||
}
|
||||
|
||||
public MasterKey getPinMasterKey() {
|
||||
return pinMasterKey;
|
||||
}
|
||||
|
||||
public void setPinMasterKey(final MasterKey pinMasterKey) {
|
||||
this.pinMasterKey = pinMasterKey;
|
||||
}
|
||||
|
||||
public StorageKey getStorageKey() {
|
||||
if (pinMasterKey != null) {
|
||||
return pinMasterKey.deriveStorageServiceKey();
|
||||
}
|
||||
return storageKey;
|
||||
}
|
||||
|
||||
public void setStorageKey(final StorageKey storageKey) {
|
||||
this.storageKey = storageKey;
|
||||
}
|
||||
|
||||
public ProfileKey getProfileKey() {
|
||||
return profileKey;
|
||||
}
|
||||
|
||||
public void setProfileKey(final ProfileKey profileKey) {
|
||||
this.profileKey = profileKey;
|
||||
}
|
||||
|
||||
public byte[] getSelfUnidentifiedAccessKey() {
|
||||
return UnidentifiedAccess.deriveAccessKeyFrom(getProfileKey());
|
||||
}
|
||||
|
||||
public int getPreKeyIdOffset() {
|
||||
return preKeyIdOffset;
|
||||
}
|
||||
|
||||
public int getNextSignedPreKeyId() {
|
||||
return nextSignedPreKeyId;
|
||||
}
|
||||
|
||||
public boolean isRegistered() {
|
||||
return registered;
|
||||
}
|
||||
|
||||
public void setRegistered(final boolean registered) {
|
||||
this.registered = registered;
|
||||
}
|
||||
|
||||
public boolean isMultiDevice() {
|
||||
return isMultiDevice;
|
||||
}
|
||||
|
||||
public void setMultiDevice(final boolean multiDevice) {
|
||||
isMultiDevice = multiDevice;
|
||||
}
|
||||
|
||||
public boolean isUnrestrictedUnidentifiedAccess() {
|
||||
// TODO make configurable
|
||||
return false;
|
||||
}
|
||||
|
||||
public boolean isDiscoverableByPhoneNumber() {
|
||||
// TODO make configurable
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws IOException {
|
||||
if (fileChannel.isOpen()) {
|
||||
save();
|
||||
}
|
||||
synchronized (fileChannel) {
|
||||
try {
|
||||
lock.close();
|
||||
} catch (ClosedChannelException ignored) {
|
||||
}
|
||||
fileChannel.close();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,53 @@
|
|||
package org.asamk.signal.manager.storage.contacts;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
import static com.fasterxml.jackson.annotation.JsonProperty.Access.WRITE_ONLY;
|
||||
|
||||
public class ContactInfo {
|
||||
|
||||
@JsonProperty
|
||||
public String name;
|
||||
|
||||
@JsonProperty
|
||||
public String number;
|
||||
|
||||
@JsonProperty
|
||||
public UUID uuid;
|
||||
|
||||
@JsonProperty
|
||||
public String color;
|
||||
|
||||
@JsonProperty(defaultValue = "0")
|
||||
public int messageExpirationTime;
|
||||
|
||||
@JsonProperty(access = WRITE_ONLY)
|
||||
public String profileKey;
|
||||
|
||||
@JsonProperty(defaultValue = "false")
|
||||
public boolean blocked;
|
||||
|
||||
@JsonProperty
|
||||
public Integer inboxPosition;
|
||||
|
||||
@JsonProperty(defaultValue = "false")
|
||||
public boolean archived;
|
||||
|
||||
public ContactInfo() {
|
||||
}
|
||||
|
||||
public ContactInfo(SignalServiceAddress address) {
|
||||
this.number = address.getNumber().orNull();
|
||||
this.uuid = address.getUuid().orNull();
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
public SignalServiceAddress getAddress() {
|
||||
return new SignalServiceAddress(uuid, number);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,52 @@
|
|||
package org.asamk.signal.manager.storage.contacts;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public class JsonContactsStore {
|
||||
|
||||
@JsonProperty("contacts")
|
||||
private List<ContactInfo> contacts = new ArrayList<>();
|
||||
|
||||
public void updateContact(ContactInfo contact) {
|
||||
final var contactAddress = contact.getAddress();
|
||||
for (var i = 0; i < contacts.size(); i++) {
|
||||
if (contacts.get(i).getAddress().matches(contactAddress)) {
|
||||
contacts.set(i, contact);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
contacts.add(contact);
|
||||
}
|
||||
|
||||
public ContactInfo getContact(SignalServiceAddress address) {
|
||||
for (var contact : contacts) {
|
||||
if (contact.getAddress().matches(address)) {
|
||||
if (contact.uuid == null) {
|
||||
contact.uuid = address.getUuid().orNull();
|
||||
} else if (contact.number == null) {
|
||||
contact.number = address.getNumber().orNull();
|
||||
}
|
||||
|
||||
return contact;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public List<ContactInfo> getContacts() {
|
||||
return new ArrayList<>(contacts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all contacts from the store
|
||||
*/
|
||||
public void clear() {
|
||||
contacts.clear();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,77 @@
|
|||
package org.asamk.signal.manager.storage.groups;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
|
||||
import org.asamk.signal.manager.groups.GroupId;
|
||||
import org.asamk.signal.manager.groups.GroupInviteLinkUrl;
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
|
||||
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
public abstract class GroupInfo {
|
||||
|
||||
@JsonIgnore
|
||||
public abstract GroupId getGroupId();
|
||||
|
||||
@JsonIgnore
|
||||
public abstract String getTitle();
|
||||
|
||||
@JsonIgnore
|
||||
public abstract GroupInviteLinkUrl getGroupInviteLink();
|
||||
|
||||
@JsonIgnore
|
||||
public abstract Set<SignalServiceAddress> getMembers();
|
||||
|
||||
@JsonIgnore
|
||||
public Set<SignalServiceAddress> getPendingMembers() {
|
||||
return Set.of();
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
public Set<SignalServiceAddress> getRequestingMembers() {
|
||||
return Set.of();
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
public abstract boolean isBlocked();
|
||||
|
||||
@JsonIgnore
|
||||
public abstract void setBlocked(boolean blocked);
|
||||
|
||||
@JsonIgnore
|
||||
public abstract int getMessageExpirationTime();
|
||||
|
||||
@JsonIgnore
|
||||
public Set<SignalServiceAddress> getMembersWithout(SignalServiceAddress address) {
|
||||
return getMembers().stream().filter(member -> !member.matches(address)).collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
public Set<SignalServiceAddress> getMembersIncludingPendingWithout(SignalServiceAddress address) {
|
||||
return Stream.concat(getMembers().stream(), getPendingMembers().stream())
|
||||
.filter(member -> !member.matches(address))
|
||||
.collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
public boolean isMember(SignalServiceAddress address) {
|
||||
for (var member : getMembers()) {
|
||||
if (member.matches(address)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
public boolean isPendingMember(SignalServiceAddress address) {
|
||||
for (var member : getPendingMembers()) {
|
||||
if (member.matches(address)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,212 @@
|
|||
package org.asamk.signal.manager.storage.groups;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import com.fasterxml.jackson.core.JsonGenerator;
|
||||
import com.fasterxml.jackson.core.JsonParser;
|
||||
import com.fasterxml.jackson.databind.DeserializationContext;
|
||||
import com.fasterxml.jackson.databind.JsonDeserializer;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.JsonSerializer;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.SerializerProvider;
|
||||
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
|
||||
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
|
||||
|
||||
import org.asamk.signal.manager.groups.GroupId;
|
||||
import org.asamk.signal.manager.groups.GroupIdV1;
|
||||
import org.asamk.signal.manager.groups.GroupIdV2;
|
||||
import org.asamk.signal.manager.groups.GroupInviteLinkUrl;
|
||||
import org.asamk.signal.manager.groups.GroupUtils;
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Collection;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
public class GroupInfoV1 extends GroupInfo {
|
||||
|
||||
private static final ObjectMapper jsonProcessor = new ObjectMapper();
|
||||
|
||||
private final GroupIdV1 groupId;
|
||||
|
||||
private GroupIdV2 expectedV2Id;
|
||||
|
||||
@JsonProperty
|
||||
public String name;
|
||||
|
||||
@JsonProperty
|
||||
@JsonDeserialize(using = MembersDeserializer.class)
|
||||
@JsonSerialize(using = MembersSerializer.class)
|
||||
public Set<SignalServiceAddress> members = new HashSet<>();
|
||||
@JsonProperty
|
||||
public String color;
|
||||
@JsonProperty(defaultValue = "0")
|
||||
public int messageExpirationTime;
|
||||
@JsonProperty(defaultValue = "false")
|
||||
public boolean blocked;
|
||||
@JsonProperty
|
||||
public Integer inboxPosition;
|
||||
@JsonProperty(defaultValue = "false")
|
||||
public boolean archived;
|
||||
|
||||
public GroupInfoV1(GroupIdV1 groupId) {
|
||||
this.groupId = groupId;
|
||||
}
|
||||
|
||||
public GroupInfoV1(
|
||||
@JsonProperty("groupId") byte[] groupId,
|
||||
@JsonProperty("expectedV2Id") byte[] expectedV2Id,
|
||||
@JsonProperty("name") String name,
|
||||
@JsonProperty("members") Collection<SignalServiceAddress> members,
|
||||
@JsonProperty("avatarId") long _ignored_avatarId,
|
||||
@JsonProperty("color") String color,
|
||||
@JsonProperty("blocked") boolean blocked,
|
||||
@JsonProperty("inboxPosition") Integer inboxPosition,
|
||||
@JsonProperty("archived") boolean archived,
|
||||
@JsonProperty("messageExpirationTime") int messageExpirationTime,
|
||||
@JsonProperty("active") boolean _ignored_active
|
||||
) {
|
||||
this.groupId = GroupId.v1(groupId);
|
||||
this.expectedV2Id = GroupId.v2(expectedV2Id);
|
||||
this.name = name;
|
||||
this.members.addAll(members);
|
||||
this.color = color;
|
||||
this.blocked = blocked;
|
||||
this.inboxPosition = inboxPosition;
|
||||
this.archived = archived;
|
||||
this.messageExpirationTime = messageExpirationTime;
|
||||
}
|
||||
|
||||
@Override
|
||||
@JsonIgnore
|
||||
public GroupIdV1 getGroupId() {
|
||||
return groupId;
|
||||
}
|
||||
|
||||
@JsonProperty("groupId")
|
||||
private byte[] getGroupIdJackson() {
|
||||
return groupId.serialize();
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
public GroupIdV2 getExpectedV2Id() {
|
||||
if (expectedV2Id == null) {
|
||||
expectedV2Id = GroupUtils.getGroupIdV2(groupId);
|
||||
}
|
||||
return expectedV2Id;
|
||||
}
|
||||
|
||||
@JsonProperty("expectedV2Id")
|
||||
private byte[] getExpectedV2IdJackson() {
|
||||
return getExpectedV2Id().serialize();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getTitle() {
|
||||
return name;
|
||||
}
|
||||
|
||||
@Override
|
||||
public GroupInviteLinkUrl getGroupInviteLink() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
public Set<SignalServiceAddress> getMembers() {
|
||||
return members;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isBlocked() {
|
||||
return blocked;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setBlocked(final boolean blocked) {
|
||||
this.blocked = blocked;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getMessageExpirationTime() {
|
||||
return messageExpirationTime;
|
||||
}
|
||||
|
||||
public void addMembers(Collection<SignalServiceAddress> addresses) {
|
||||
for (var address : addresses) {
|
||||
if (this.members.contains(address)) {
|
||||
continue;
|
||||
}
|
||||
removeMember(address);
|
||||
this.members.add(address);
|
||||
}
|
||||
}
|
||||
|
||||
public void removeMember(SignalServiceAddress address) {
|
||||
this.members.removeIf(member -> member.matches(address));
|
||||
}
|
||||
|
||||
private static final class JsonSignalServiceAddress {
|
||||
|
||||
@JsonProperty
|
||||
private UUID uuid;
|
||||
|
||||
@JsonProperty
|
||||
private String number;
|
||||
|
||||
JsonSignalServiceAddress(@JsonProperty("uuid") final UUID uuid, @JsonProperty("number") final String number) {
|
||||
this.uuid = uuid;
|
||||
this.number = number;
|
||||
}
|
||||
|
||||
JsonSignalServiceAddress(SignalServiceAddress address) {
|
||||
this.uuid = address.getUuid().orNull();
|
||||
this.number = address.getNumber().orNull();
|
||||
}
|
||||
|
||||
SignalServiceAddress toSignalServiceAddress() {
|
||||
return new SignalServiceAddress(uuid, number);
|
||||
}
|
||||
}
|
||||
|
||||
private static class MembersSerializer extends JsonSerializer<Set<SignalServiceAddress>> {
|
||||
|
||||
@Override
|
||||
public void serialize(
|
||||
final Set<SignalServiceAddress> value, final JsonGenerator jgen, final SerializerProvider provider
|
||||
) throws IOException {
|
||||
jgen.writeStartArray(value.size());
|
||||
for (var address : value) {
|
||||
if (address.getUuid().isPresent()) {
|
||||
jgen.writeObject(new JsonSignalServiceAddress(address));
|
||||
} else {
|
||||
jgen.writeString(address.getNumber().get());
|
||||
}
|
||||
}
|
||||
jgen.writeEndArray();
|
||||
}
|
||||
}
|
||||
|
||||
private static class MembersDeserializer extends JsonDeserializer<Set<SignalServiceAddress>> {
|
||||
|
||||
@Override
|
||||
public Set<SignalServiceAddress> deserialize(
|
||||
JsonParser jsonParser, DeserializationContext deserializationContext
|
||||
) throws IOException {
|
||||
var addresses = new HashSet<SignalServiceAddress>();
|
||||
JsonNode node = jsonParser.getCodec().readTree(jsonParser);
|
||||
for (var n : node) {
|
||||
if (n.isTextual()) {
|
||||
addresses.add(new SignalServiceAddress(null, n.textValue()));
|
||||
} else {
|
||||
var address = jsonProcessor.treeToValue(n, JsonSignalServiceAddress.class);
|
||||
addresses.add(address.toSignalServiceAddress());
|
||||
}
|
||||
}
|
||||
|
||||
return addresses;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,114 @@
|
|||
package org.asamk.signal.manager.storage.groups;
|
||||
|
||||
import org.asamk.signal.manager.groups.GroupIdV2;
|
||||
import org.asamk.signal.manager.groups.GroupInviteLinkUrl;
|
||||
import org.signal.storageservice.protos.groups.AccessControl;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedGroup;
|
||||
import org.signal.zkgroup.groups.GroupMasterKey;
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
|
||||
import org.whispersystems.signalservice.api.util.UuidUtil;
|
||||
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public class GroupInfoV2 extends GroupInfo {
|
||||
|
||||
private final GroupIdV2 groupId;
|
||||
private final GroupMasterKey masterKey;
|
||||
|
||||
private boolean blocked;
|
||||
private DecryptedGroup group; // stored as a file with hexadecimal groupId as name
|
||||
|
||||
public GroupInfoV2(final GroupIdV2 groupId, final GroupMasterKey masterKey) {
|
||||
this.groupId = groupId;
|
||||
this.masterKey = masterKey;
|
||||
}
|
||||
|
||||
@Override
|
||||
public GroupIdV2 getGroupId() {
|
||||
return groupId;
|
||||
}
|
||||
|
||||
public GroupMasterKey getMasterKey() {
|
||||
return masterKey;
|
||||
}
|
||||
|
||||
public void setGroup(final DecryptedGroup group) {
|
||||
this.group = group;
|
||||
}
|
||||
|
||||
public DecryptedGroup getGroup() {
|
||||
return group;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getTitle() {
|
||||
if (this.group == null) {
|
||||
return null;
|
||||
}
|
||||
return this.group.getTitle();
|
||||
}
|
||||
|
||||
@Override
|
||||
public GroupInviteLinkUrl getGroupInviteLink() {
|
||||
if (this.group == null || this.group.getInviteLinkPassword() == null || (
|
||||
this.group.getAccessControl().getAddFromInviteLink() != AccessControl.AccessRequired.ANY
|
||||
&& this.group.getAccessControl().getAddFromInviteLink()
|
||||
!= AccessControl.AccessRequired.ADMINISTRATOR
|
||||
)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return GroupInviteLinkUrl.forGroup(masterKey, group);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Set<SignalServiceAddress> getMembers() {
|
||||
if (this.group == null) {
|
||||
return Set.of();
|
||||
}
|
||||
return group.getMembersList()
|
||||
.stream()
|
||||
.map(m -> new SignalServiceAddress(UuidUtil.parseOrThrow(m.getUuid().toByteArray()), null))
|
||||
.collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Set<SignalServiceAddress> getPendingMembers() {
|
||||
if (this.group == null) {
|
||||
return Set.of();
|
||||
}
|
||||
return group.getPendingMembersList()
|
||||
.stream()
|
||||
.map(m -> new SignalServiceAddress(UuidUtil.parseOrThrow(m.getUuid().toByteArray()), null))
|
||||
.collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Set<SignalServiceAddress> getRequestingMembers() {
|
||||
if (this.group == null) {
|
||||
return Set.of();
|
||||
}
|
||||
return group.getRequestingMembersList()
|
||||
.stream()
|
||||
.map(m -> new SignalServiceAddress(UuidUtil.parseOrThrow(m.getUuid().toByteArray()), null))
|
||||
.collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isBlocked() {
|
||||
return blocked;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setBlocked(final boolean blocked) {
|
||||
this.blocked = blocked;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getMessageExpirationTime() {
|
||||
return this.group != null && this.group.hasDisappearingMessagesTimer()
|
||||
? this.group.getDisappearingMessagesTimer().getDuration()
|
||||
: 0;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,204 @@
|
|||
package org.asamk.signal.manager.storage.groups;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import com.fasterxml.jackson.core.JsonGenerator;
|
||||
import com.fasterxml.jackson.core.JsonParser;
|
||||
import com.fasterxml.jackson.databind.DeserializationContext;
|
||||
import com.fasterxml.jackson.databind.JsonDeserializer;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.JsonSerializer;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.SerializerProvider;
|
||||
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
|
||||
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
|
||||
|
||||
import org.asamk.signal.manager.groups.GroupId;
|
||||
import org.asamk.signal.manager.groups.GroupIdV1;
|
||||
import org.asamk.signal.manager.groups.GroupIdV2;
|
||||
import org.asamk.signal.manager.groups.GroupUtils;
|
||||
import org.asamk.signal.manager.util.IOUtils;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedGroup;
|
||||
import org.signal.zkgroup.InvalidInputException;
|
||||
import org.signal.zkgroup.groups.GroupMasterKey;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.whispersystems.libsignal.util.Hex;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
public class JsonGroupStore {
|
||||
|
||||
private final static Logger logger = LoggerFactory.getLogger(JsonGroupStore.class);
|
||||
|
||||
private static final ObjectMapper jsonProcessor = new ObjectMapper();
|
||||
public File groupCachePath;
|
||||
|
||||
@JsonProperty("groups")
|
||||
@JsonSerialize(using = GroupsSerializer.class)
|
||||
@JsonDeserialize(using = GroupsDeserializer.class)
|
||||
private final Map<GroupId, GroupInfo> groups = new HashMap<>();
|
||||
|
||||
private JsonGroupStore() {
|
||||
}
|
||||
|
||||
public JsonGroupStore(final File groupCachePath) {
|
||||
this.groupCachePath = groupCachePath;
|
||||
}
|
||||
|
||||
public void updateGroup(GroupInfo group) {
|
||||
groups.put(group.getGroupId(), group);
|
||||
if (group instanceof GroupInfoV2 && ((GroupInfoV2) group).getGroup() != null) {
|
||||
try {
|
||||
IOUtils.createPrivateDirectories(groupCachePath);
|
||||
try (var stream = new FileOutputStream(getGroupFile(group.getGroupId()))) {
|
||||
((GroupInfoV2) group).getGroup().writeTo(stream);
|
||||
}
|
||||
final var groupFileLegacy = getGroupFileLegacy(group.getGroupId());
|
||||
if (groupFileLegacy.exists()) {
|
||||
groupFileLegacy.delete();
|
||||
}
|
||||
} catch (IOException e) {
|
||||
logger.warn("Failed to cache group, ignoring: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void deleteGroup(GroupId groupId) {
|
||||
groups.remove(groupId);
|
||||
}
|
||||
|
||||
public GroupInfo getGroup(GroupId groupId) {
|
||||
var group = groups.get(groupId);
|
||||
if (group == null) {
|
||||
if (groupId instanceof GroupIdV1) {
|
||||
group = groups.get(GroupUtils.getGroupIdV2((GroupIdV1) groupId));
|
||||
} else if (groupId instanceof GroupIdV2) {
|
||||
group = getGroupV1ByV2Id((GroupIdV2) groupId);
|
||||
}
|
||||
}
|
||||
loadDecryptedGroup(group);
|
||||
return group;
|
||||
}
|
||||
|
||||
private GroupInfoV1 getGroupV1ByV2Id(GroupIdV2 groupIdV2) {
|
||||
for (var g : groups.values()) {
|
||||
if (g instanceof GroupInfoV1) {
|
||||
final var gv1 = (GroupInfoV1) g;
|
||||
if (groupIdV2.equals(gv1.getExpectedV2Id())) {
|
||||
return gv1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private void loadDecryptedGroup(final GroupInfo group) {
|
||||
if (group instanceof GroupInfoV2 && ((GroupInfoV2) group).getGroup() == null) {
|
||||
var groupFile = getGroupFile(group.getGroupId());
|
||||
if (!groupFile.exists()) {
|
||||
groupFile = getGroupFileLegacy(group.getGroupId());
|
||||
}
|
||||
if (!groupFile.exists()) {
|
||||
return;
|
||||
}
|
||||
try (var stream = new FileInputStream(groupFile)) {
|
||||
((GroupInfoV2) group).setGroup(DecryptedGroup.parseFrom(stream));
|
||||
} catch (IOException ignored) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private File getGroupFileLegacy(final GroupId groupId) {
|
||||
return new File(groupCachePath, Hex.toStringCondensed(groupId.serialize()));
|
||||
}
|
||||
|
||||
private File getGroupFile(final GroupId groupId) {
|
||||
return new File(groupCachePath, groupId.toBase64().replace("/", "_"));
|
||||
}
|
||||
|
||||
public GroupInfoV1 getOrCreateGroupV1(GroupIdV1 groupId) {
|
||||
var group = getGroup(groupId);
|
||||
if (group instanceof GroupInfoV1) {
|
||||
return (GroupInfoV1) group;
|
||||
}
|
||||
|
||||
if (group == null) {
|
||||
return new GroupInfoV1(groupId);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public List<GroupInfo> getGroups() {
|
||||
final var groups = this.groups.values();
|
||||
for (var group : groups) {
|
||||
loadDecryptedGroup(group);
|
||||
}
|
||||
return new ArrayList<>(groups);
|
||||
}
|
||||
|
||||
private static class GroupsSerializer extends JsonSerializer<Map<String, GroupInfo>> {
|
||||
|
||||
@Override
|
||||
public void serialize(
|
||||
final Map<String, GroupInfo> value, final JsonGenerator jgen, final SerializerProvider provider
|
||||
) throws IOException {
|
||||
final var groups = value.values();
|
||||
jgen.writeStartArray(groups.size());
|
||||
for (var group : groups) {
|
||||
if (group instanceof GroupInfoV1) {
|
||||
jgen.writeObject(group);
|
||||
} else if (group instanceof GroupInfoV2) {
|
||||
final var groupV2 = (GroupInfoV2) group;
|
||||
jgen.writeStartObject();
|
||||
jgen.writeStringField("groupId", groupV2.getGroupId().toBase64());
|
||||
jgen.writeStringField("masterKey",
|
||||
Base64.getEncoder().encodeToString(groupV2.getMasterKey().serialize()));
|
||||
jgen.writeBooleanField("blocked", groupV2.isBlocked());
|
||||
jgen.writeEndObject();
|
||||
} else {
|
||||
throw new AssertionError("Unknown group version");
|
||||
}
|
||||
}
|
||||
jgen.writeEndArray();
|
||||
}
|
||||
}
|
||||
|
||||
private static class GroupsDeserializer extends JsonDeserializer<Map<GroupId, GroupInfo>> {
|
||||
|
||||
@Override
|
||||
public Map<GroupId, GroupInfo> deserialize(
|
||||
JsonParser jsonParser, DeserializationContext deserializationContext
|
||||
) throws IOException {
|
||||
var groups = new HashMap<GroupId, GroupInfo>();
|
||||
JsonNode node = jsonParser.getCodec().readTree(jsonParser);
|
||||
for (var n : node) {
|
||||
GroupInfo g;
|
||||
if (n.hasNonNull("masterKey")) {
|
||||
// a v2 group
|
||||
var groupId = GroupIdV2.fromBase64(n.get("groupId").asText());
|
||||
try {
|
||||
var masterKey = new GroupMasterKey(Base64.getDecoder().decode(n.get("masterKey").asText()));
|
||||
g = new GroupInfoV2(groupId, masterKey);
|
||||
} catch (InvalidInputException | IllegalArgumentException e) {
|
||||
throw new AssertionError("Invalid master key for group " + groupId.toBase64());
|
||||
}
|
||||
g.setBlocked(n.get("blocked").asBoolean(false));
|
||||
} else {
|
||||
g = jsonProcessor.treeToValue(n, GroupInfoV1.class);
|
||||
}
|
||||
groups.put(g.getGroupId(), g);
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,38 @@
|
|||
package org.asamk.signal.manager.storage.messageCache;
|
||||
|
||||
import org.asamk.signal.manager.util.MessageCacheUtils;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceEnvelope;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
|
||||
public final class CachedMessage {
|
||||
|
||||
private final static Logger logger = LoggerFactory.getLogger(CachedMessage.class);
|
||||
|
||||
private final File file;
|
||||
|
||||
CachedMessage(final File file) {
|
||||
this.file = file;
|
||||
}
|
||||
|
||||
public SignalServiceEnvelope loadEnvelope() {
|
||||
try {
|
||||
return MessageCacheUtils.loadEnvelope(file);
|
||||
} catch (Exception e) {
|
||||
logger.error("Failed to load cached message envelope “{}”: {}", file, e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public void delete() {
|
||||
try {
|
||||
Files.delete(file.toPath());
|
||||
} catch (IOException e) {
|
||||
logger.warn("Failed to delete cached message file “{}”, ignoring: {}", file, e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,79 @@
|
|||
package org.asamk.signal.manager.storage.messageCache;
|
||||
|
||||
import org.asamk.signal.manager.util.IOUtils;
|
||||
import org.asamk.signal.manager.util.MessageCacheUtils;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceEnvelope;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.Date;
|
||||
import java.util.Objects;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
public class MessageCache {
|
||||
|
||||
private final static Logger logger = LoggerFactory.getLogger(MessageCache.class);
|
||||
|
||||
private final File messageCachePath;
|
||||
|
||||
public MessageCache(final File messageCachePath) {
|
||||
this.messageCachePath = messageCachePath;
|
||||
}
|
||||
|
||||
public Iterable<CachedMessage> getCachedMessages() {
|
||||
if (!messageCachePath.exists()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
return Arrays.stream(Objects.requireNonNull(messageCachePath.listFiles())).flatMap(dir -> {
|
||||
if (dir.isFile()) {
|
||||
return Stream.of(dir);
|
||||
}
|
||||
|
||||
final var files = Objects.requireNonNull(dir.listFiles());
|
||||
if (files.length == 0) {
|
||||
try {
|
||||
Files.delete(dir.toPath());
|
||||
} catch (IOException e) {
|
||||
logger.warn("Failed to delete cache dir “{}”, ignoring: {}", dir, e.getMessage());
|
||||
}
|
||||
return Stream.empty();
|
||||
}
|
||||
return Arrays.stream(files).filter(File::isFile);
|
||||
}).map(CachedMessage::new).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
public CachedMessage cacheMessage(SignalServiceEnvelope envelope) {
|
||||
final var now = new Date().getTime();
|
||||
final var source = envelope.hasSource() ? envelope.getSourceAddress().getLegacyIdentifier() : "";
|
||||
|
||||
try {
|
||||
var cacheFile = getMessageCacheFile(source, now, envelope.getTimestamp());
|
||||
MessageCacheUtils.storeEnvelope(envelope, cacheFile);
|
||||
return new CachedMessage(cacheFile);
|
||||
} catch (IOException e) {
|
||||
logger.warn("Failed to store encrypted message in disk cache, ignoring: {}", e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private File getMessageCachePath(String sender) {
|
||||
if (sender == null || sender.isEmpty()) {
|
||||
return messageCachePath;
|
||||
}
|
||||
|
||||
return new File(messageCachePath, sender.replace("/", "_"));
|
||||
}
|
||||
|
||||
private File getMessageCacheFile(String sender, long now, long timestamp) throws IOException {
|
||||
var cachePath = getMessageCachePath(sender);
|
||||
IOUtils.createPrivateDirectories(cachePath);
|
||||
return new File(cachePath, now + "_" + timestamp);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,156 @@
|
|||
package org.asamk.signal.manager.storage.profiles;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import com.fasterxml.jackson.core.JsonGenerator;
|
||||
import com.fasterxml.jackson.core.JsonParser;
|
||||
import com.fasterxml.jackson.databind.DeserializationContext;
|
||||
import com.fasterxml.jackson.databind.JsonDeserializer;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.JsonSerializer;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.SerializerProvider;
|
||||
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
|
||||
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
|
||||
|
||||
import org.signal.zkgroup.InvalidInputException;
|
||||
import org.signal.zkgroup.profiles.ProfileKey;
|
||||
import org.signal.zkgroup.profiles.ProfileKeyCredential;
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
|
||||
import org.whispersystems.signalservice.api.util.UuidUtil;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
|
||||
public class ProfileStore {
|
||||
|
||||
private static final ObjectMapper jsonProcessor = new ObjectMapper();
|
||||
|
||||
@JsonProperty("profiles")
|
||||
@JsonDeserialize(using = ProfileStoreDeserializer.class)
|
||||
@JsonSerialize(using = ProfileStoreSerializer.class)
|
||||
private final List<SignalProfileEntry> profiles = new ArrayList<>();
|
||||
|
||||
public SignalProfileEntry getProfileEntry(SignalServiceAddress serviceAddress) {
|
||||
for (var entry : profiles) {
|
||||
if (entry.getServiceAddress().matches(serviceAddress)) {
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public ProfileKey getProfileKey(SignalServiceAddress serviceAddress) {
|
||||
for (var entry : profiles) {
|
||||
if (entry.getServiceAddress().matches(serviceAddress)) {
|
||||
return entry.getProfileKey();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public void updateProfile(
|
||||
SignalServiceAddress serviceAddress,
|
||||
ProfileKey profileKey,
|
||||
long now,
|
||||
SignalProfile profile,
|
||||
ProfileKeyCredential profileKeyCredential
|
||||
) {
|
||||
var newEntry = new SignalProfileEntry(serviceAddress, profileKey, now, profile, profileKeyCredential);
|
||||
for (var i = 0; i < profiles.size(); i++) {
|
||||
if (profiles.get(i).getServiceAddress().matches(serviceAddress)) {
|
||||
profiles.set(i, newEntry);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
profiles.add(newEntry);
|
||||
}
|
||||
|
||||
public void storeProfileKey(SignalServiceAddress serviceAddress, ProfileKey profileKey) {
|
||||
var newEntry = new SignalProfileEntry(serviceAddress, profileKey, 0, null, null);
|
||||
for (var i = 0; i < profiles.size(); i++) {
|
||||
if (profiles.get(i).getServiceAddress().matches(serviceAddress)) {
|
||||
if (!profiles.get(i).getProfileKey().equals(profileKey)) {
|
||||
profiles.set(i, newEntry);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
profiles.add(newEntry);
|
||||
}
|
||||
|
||||
public static class ProfileStoreDeserializer extends JsonDeserializer<List<SignalProfileEntry>> {
|
||||
|
||||
@Override
|
||||
public List<SignalProfileEntry> deserialize(
|
||||
JsonParser jsonParser, DeserializationContext deserializationContext
|
||||
) throws IOException {
|
||||
JsonNode node = jsonParser.getCodec().readTree(jsonParser);
|
||||
|
||||
var addresses = new ArrayList<SignalProfileEntry>();
|
||||
|
||||
if (node.isArray()) {
|
||||
for (var entry : node) {
|
||||
var name = entry.hasNonNull("name") ? entry.get("name").asText() : null;
|
||||
var uuid = entry.hasNonNull("uuid") ? UuidUtil.parseOrNull(entry.get("uuid").asText()) : null;
|
||||
final var serviceAddress = new SignalServiceAddress(uuid, name);
|
||||
ProfileKey profileKey = null;
|
||||
try {
|
||||
profileKey = new ProfileKey(Base64.getDecoder().decode(entry.get("profileKey").asText()));
|
||||
} catch (InvalidInputException ignored) {
|
||||
}
|
||||
ProfileKeyCredential profileKeyCredential = null;
|
||||
if (entry.hasNonNull("profileKeyCredential")) {
|
||||
try {
|
||||
profileKeyCredential = new ProfileKeyCredential(Base64.getDecoder()
|
||||
.decode(entry.get("profileKeyCredential").asText()));
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
}
|
||||
var lastUpdateTimestamp = entry.get("lastUpdateTimestamp").asLong();
|
||||
var profile = jsonProcessor.treeToValue(entry.get("profile"), SignalProfile.class);
|
||||
addresses.add(new SignalProfileEntry(serviceAddress,
|
||||
profileKey,
|
||||
lastUpdateTimestamp,
|
||||
profile,
|
||||
profileKeyCredential));
|
||||
}
|
||||
}
|
||||
|
||||
return addresses;
|
||||
}
|
||||
}
|
||||
|
||||
public static class ProfileStoreSerializer extends JsonSerializer<List<SignalProfileEntry>> {
|
||||
|
||||
@Override
|
||||
public void serialize(
|
||||
List<SignalProfileEntry> profiles, JsonGenerator json, SerializerProvider serializerProvider
|
||||
) throws IOException {
|
||||
json.writeStartArray();
|
||||
for (var profileEntry : profiles) {
|
||||
final var address = profileEntry.getServiceAddress();
|
||||
json.writeStartObject();
|
||||
if (address.getNumber().isPresent()) {
|
||||
json.writeStringField("name", address.getNumber().get());
|
||||
}
|
||||
if (address.getUuid().isPresent()) {
|
||||
json.writeStringField("uuid", address.getUuid().get().toString());
|
||||
}
|
||||
json.writeStringField("profileKey",
|
||||
Base64.getEncoder().encodeToString(profileEntry.getProfileKey().serialize()));
|
||||
json.writeNumberField("lastUpdateTimestamp", profileEntry.getLastUpdateTimestamp());
|
||||
json.writeObjectField("profile", profileEntry.getProfile());
|
||||
if (profileEntry.getProfileKeyCredential() != null) {
|
||||
json.writeStringField("profileKeyCredential",
|
||||
Base64.getEncoder().encodeToString(profileEntry.getProfileKeyCredential().serialize()));
|
||||
}
|
||||
json.writeEndObject();
|
||||
}
|
||||
json.writeEndArray();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,142 @@
|
|||
package org.asamk.signal.manager.storage.profiles;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
import org.whispersystems.signalservice.api.profiles.SignalServiceProfile;
|
||||
|
||||
public class SignalProfile {
|
||||
|
||||
@JsonProperty
|
||||
private final String identityKey;
|
||||
|
||||
@JsonProperty
|
||||
private final String name;
|
||||
|
||||
@JsonProperty
|
||||
private final String about;
|
||||
|
||||
@JsonProperty
|
||||
private final String aboutEmoji;
|
||||
|
||||
@JsonProperty
|
||||
private final String unidentifiedAccess;
|
||||
|
||||
@JsonProperty
|
||||
private final boolean unrestrictedUnidentifiedAccess;
|
||||
|
||||
@JsonProperty
|
||||
private final Capabilities capabilities;
|
||||
|
||||
public SignalProfile(
|
||||
final String identityKey,
|
||||
final String name,
|
||||
final String about,
|
||||
final String aboutEmoji,
|
||||
final String unidentifiedAccess,
|
||||
final boolean unrestrictedUnidentifiedAccess,
|
||||
final SignalServiceProfile.Capabilities capabilities
|
||||
) {
|
||||
this.identityKey = identityKey;
|
||||
this.name = name;
|
||||
this.about = about;
|
||||
this.aboutEmoji = aboutEmoji;
|
||||
this.unidentifiedAccess = unidentifiedAccess;
|
||||
this.unrestrictedUnidentifiedAccess = unrestrictedUnidentifiedAccess;
|
||||
this.capabilities = new Capabilities();
|
||||
this.capabilities.storage = capabilities.isStorage();
|
||||
this.capabilities.gv1Migration = capabilities.isGv1Migration();
|
||||
this.capabilities.gv2 = capabilities.isGv2();
|
||||
}
|
||||
|
||||
public SignalProfile(
|
||||
@JsonProperty("identityKey") final String identityKey,
|
||||
@JsonProperty("name") final String name,
|
||||
@JsonProperty("about") final String about,
|
||||
@JsonProperty("aboutEmoji") final String aboutEmoji,
|
||||
@JsonProperty("unidentifiedAccess") final String unidentifiedAccess,
|
||||
@JsonProperty("unrestrictedUnidentifiedAccess") final boolean unrestrictedUnidentifiedAccess,
|
||||
@JsonProperty("capabilities") final Capabilities capabilities
|
||||
) {
|
||||
this.identityKey = identityKey;
|
||||
this.name = name;
|
||||
this.about = about;
|
||||
this.aboutEmoji = aboutEmoji;
|
||||
this.unidentifiedAccess = unidentifiedAccess;
|
||||
this.unrestrictedUnidentifiedAccess = unrestrictedUnidentifiedAccess;
|
||||
this.capabilities = capabilities;
|
||||
}
|
||||
|
||||
public String getIdentityKey() {
|
||||
return identityKey;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public String getDisplayName() {
|
||||
// First name and last name (if set) are separated by a NULL char + trim space in case only one is filled
|
||||
return name == null ? "" : name.replace("\0", " ").trim();
|
||||
}
|
||||
|
||||
public String getAbout() {
|
||||
return about;
|
||||
}
|
||||
|
||||
public String getAboutEmoji() {
|
||||
return aboutEmoji;
|
||||
}
|
||||
|
||||
public String getUnidentifiedAccess() {
|
||||
return unidentifiedAccess;
|
||||
}
|
||||
|
||||
public boolean isUnrestrictedUnidentifiedAccess() {
|
||||
return unrestrictedUnidentifiedAccess;
|
||||
}
|
||||
|
||||
public Capabilities getCapabilities() {
|
||||
return capabilities;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "SignalProfile{"
|
||||
+ "identityKey='"
|
||||
+ identityKey
|
||||
+ '\''
|
||||
+ ", name='"
|
||||
+ name
|
||||
+ '\''
|
||||
+ ", about='"
|
||||
+ about
|
||||
+ '\''
|
||||
+ ", aboutEmoji='"
|
||||
+ aboutEmoji
|
||||
+ '\''
|
||||
+ ", unidentifiedAccess='"
|
||||
+ unidentifiedAccess
|
||||
+ '\''
|
||||
+ ", unrestrictedUnidentifiedAccess="
|
||||
+ unrestrictedUnidentifiedAccess
|
||||
+ ", capabilities="
|
||||
+ capabilities
|
||||
+ '}';
|
||||
}
|
||||
|
||||
public static class Capabilities {
|
||||
|
||||
@JsonIgnore
|
||||
public boolean uuid;
|
||||
|
||||
@JsonProperty
|
||||
public boolean gv2;
|
||||
|
||||
@JsonProperty
|
||||
public boolean storage;
|
||||
|
||||
@JsonProperty
|
||||
public boolean gv1Migration;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,62 @@
|
|||
package org.asamk.signal.manager.storage.profiles;
|
||||
|
||||
import org.signal.zkgroup.profiles.ProfileKey;
|
||||
import org.signal.zkgroup.profiles.ProfileKeyCredential;
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
|
||||
|
||||
public class SignalProfileEntry {
|
||||
|
||||
private final SignalServiceAddress serviceAddress;
|
||||
|
||||
private final ProfileKey profileKey;
|
||||
|
||||
private final long lastUpdateTimestamp;
|
||||
|
||||
private final SignalProfile profile;
|
||||
|
||||
private final ProfileKeyCredential profileKeyCredential;
|
||||
|
||||
private boolean requestPending;
|
||||
|
||||
public SignalProfileEntry(
|
||||
final SignalServiceAddress serviceAddress,
|
||||
final ProfileKey profileKey,
|
||||
final long lastUpdateTimestamp,
|
||||
final SignalProfile profile,
|
||||
final ProfileKeyCredential profileKeyCredential
|
||||
) {
|
||||
this.serviceAddress = serviceAddress;
|
||||
this.profileKey = profileKey;
|
||||
this.lastUpdateTimestamp = lastUpdateTimestamp;
|
||||
this.profile = profile;
|
||||
this.profileKeyCredential = profileKeyCredential;
|
||||
}
|
||||
|
||||
public SignalServiceAddress getServiceAddress() {
|
||||
return serviceAddress;
|
||||
}
|
||||
|
||||
public ProfileKey getProfileKey() {
|
||||
return profileKey;
|
||||
}
|
||||
|
||||
public long getLastUpdateTimestamp() {
|
||||
return lastUpdateTimestamp;
|
||||
}
|
||||
|
||||
public SignalProfile getProfile() {
|
||||
return profile;
|
||||
}
|
||||
|
||||
public ProfileKeyCredential getProfileKeyCredential() {
|
||||
return profileKeyCredential;
|
||||
}
|
||||
|
||||
public boolean isRequestPending() {
|
||||
return requestPending;
|
||||
}
|
||||
|
||||
public void setRequestPending(final boolean requestPending) {
|
||||
this.requestPending = requestPending;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,50 @@
|
|||
package org.asamk.signal.manager.storage.protocol;
|
||||
|
||||
import org.asamk.signal.manager.TrustLevel;
|
||||
import org.whispersystems.libsignal.IdentityKey;
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
|
||||
|
||||
import java.util.Date;
|
||||
|
||||
public class IdentityInfo {
|
||||
|
||||
SignalServiceAddress address;
|
||||
IdentityKey identityKey;
|
||||
TrustLevel trustLevel;
|
||||
Date added;
|
||||
|
||||
IdentityInfo(SignalServiceAddress address, IdentityKey identityKey, TrustLevel trustLevel, Date added) {
|
||||
this.address = address;
|
||||
this.identityKey = identityKey;
|
||||
this.trustLevel = trustLevel;
|
||||
this.added = added;
|
||||
}
|
||||
|
||||
public SignalServiceAddress getAddress() {
|
||||
return address;
|
||||
}
|
||||
|
||||
public void setAddress(final SignalServiceAddress address) {
|
||||
this.address = address;
|
||||
}
|
||||
|
||||
boolean isTrusted() {
|
||||
return trustLevel == TrustLevel.TRUSTED_UNVERIFIED || trustLevel == TrustLevel.TRUSTED_VERIFIED;
|
||||
}
|
||||
|
||||
public IdentityKey getIdentityKey() {
|
||||
return this.identityKey;
|
||||
}
|
||||
|
||||
public TrustLevel getTrustLevel() {
|
||||
return this.trustLevel;
|
||||
}
|
||||
|
||||
public Date getDateAdded() {
|
||||
return this.added;
|
||||
}
|
||||
|
||||
public byte[] getFingerprint() {
|
||||
return identityKey.getPublicKey().serialize();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,277 @@
|
|||
package org.asamk.signal.manager.storage.protocol;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonGenerator;
|
||||
import com.fasterxml.jackson.core.JsonParser;
|
||||
import com.fasterxml.jackson.databind.DeserializationContext;
|
||||
import com.fasterxml.jackson.databind.JsonDeserializer;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.JsonSerializer;
|
||||
import com.fasterxml.jackson.databind.SerializerProvider;
|
||||
|
||||
import org.asamk.signal.manager.TrustLevel;
|
||||
import org.asamk.signal.manager.util.Utils;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.whispersystems.libsignal.IdentityKey;
|
||||
import org.whispersystems.libsignal.IdentityKeyPair;
|
||||
import org.whispersystems.libsignal.InvalidKeyException;
|
||||
import org.whispersystems.libsignal.SignalProtocolAddress;
|
||||
import org.whispersystems.libsignal.state.IdentityKeyStore;
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
|
||||
import org.whispersystems.signalservice.api.util.UuidUtil;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
|
||||
public class JsonIdentityKeyStore implements IdentityKeyStore {
|
||||
|
||||
private final static Logger logger = LoggerFactory.getLogger(JsonIdentityKeyStore.class);
|
||||
|
||||
private final List<IdentityInfo> identities = new ArrayList<>();
|
||||
|
||||
private final IdentityKeyPair identityKeyPair;
|
||||
private final int localRegistrationId;
|
||||
|
||||
private SignalServiceAddressResolver resolver;
|
||||
|
||||
public JsonIdentityKeyStore(IdentityKeyPair identityKeyPair, int localRegistrationId) {
|
||||
this.identityKeyPair = identityKeyPair;
|
||||
this.localRegistrationId = localRegistrationId;
|
||||
}
|
||||
|
||||
public void setResolver(final SignalServiceAddressResolver resolver) {
|
||||
this.resolver = resolver;
|
||||
}
|
||||
|
||||
private SignalServiceAddress resolveSignalServiceAddress(String identifier) {
|
||||
if (resolver != null) {
|
||||
return resolver.resolveSignalServiceAddress(identifier);
|
||||
} else {
|
||||
return Utils.getSignalServiceAddressFromIdentifier(identifier);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public IdentityKeyPair getIdentityKeyPair() {
|
||||
return identityKeyPair;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getLocalRegistrationId() {
|
||||
return localRegistrationId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean saveIdentity(SignalProtocolAddress address, IdentityKey identityKey) {
|
||||
return saveIdentity(resolveSignalServiceAddress(address.getName()),
|
||||
identityKey,
|
||||
TrustLevel.TRUSTED_UNVERIFIED,
|
||||
null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the given identityKey for the user name and sets the trustLevel and added timestamp.
|
||||
* If the identityKey already exists, the trustLevel and added timestamp are NOT updated.
|
||||
*
|
||||
* @param serviceAddress User address, i.e. phone number and/or uuid
|
||||
* @param identityKey The user's public key
|
||||
* @param trustLevel Level of trust: untrusted, trusted, trusted and verified
|
||||
* @param added Added timestamp, if null and the key is newly added, the current time is used.
|
||||
*/
|
||||
public boolean saveIdentity(
|
||||
SignalServiceAddress serviceAddress, IdentityKey identityKey, TrustLevel trustLevel, Date added
|
||||
) {
|
||||
for (var id : identities) {
|
||||
if (!id.address.matches(serviceAddress) || !id.identityKey.equals(identityKey)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!id.address.getUuid().isPresent() || !id.address.getNumber().isPresent()) {
|
||||
id.address = serviceAddress;
|
||||
}
|
||||
// Identity already exists, not updating the trust level
|
||||
return true;
|
||||
}
|
||||
|
||||
identities.add(new IdentityInfo(serviceAddress, identityKey, trustLevel, added != null ? added : new Date()));
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update trustLevel for the given identityKey for the user name.
|
||||
*
|
||||
* @param serviceAddress User address, i.e. phone number and/or uuid
|
||||
* @param identityKey The user's public key
|
||||
* @param trustLevel Level of trust: untrusted, trusted, trusted and verified
|
||||
*/
|
||||
public void setIdentityTrustLevel(
|
||||
SignalServiceAddress serviceAddress, IdentityKey identityKey, TrustLevel trustLevel
|
||||
) {
|
||||
for (var id : identities) {
|
||||
if (!id.address.matches(serviceAddress) || !id.identityKey.equals(identityKey)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!id.address.getUuid().isPresent() || !id.address.getNumber().isPresent()) {
|
||||
id.address = serviceAddress;
|
||||
}
|
||||
id.trustLevel = trustLevel;
|
||||
return;
|
||||
}
|
||||
|
||||
identities.add(new IdentityInfo(serviceAddress, identityKey, trustLevel, new Date()));
|
||||
}
|
||||
|
||||
public void removeIdentity(SignalServiceAddress serviceAddress, IdentityKey identityKey) {
|
||||
identities.removeIf(id -> id.address.matches(serviceAddress) && id.identityKey.equals(identityKey));
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isTrustedIdentity(SignalProtocolAddress address, IdentityKey identityKey, Direction direction) {
|
||||
// TODO implement possibility for different handling of incoming/outgoing trust decisions
|
||||
var serviceAddress = resolveSignalServiceAddress(address.getName());
|
||||
var trustOnFirstUse = true;
|
||||
|
||||
for (var id : identities) {
|
||||
if (!id.address.matches(serviceAddress)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (id.identityKey.equals(identityKey)) {
|
||||
return id.isTrusted();
|
||||
} else {
|
||||
trustOnFirstUse = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!trustOnFirstUse) {
|
||||
saveIdentity(resolveSignalServiceAddress(address.getName()), identityKey, TrustLevel.UNTRUSTED, null);
|
||||
}
|
||||
|
||||
return trustOnFirstUse;
|
||||
}
|
||||
|
||||
@Override
|
||||
public IdentityKey getIdentity(SignalProtocolAddress address) {
|
||||
var serviceAddress = resolveSignalServiceAddress(address.getName());
|
||||
var identity = getIdentity(serviceAddress);
|
||||
return identity == null ? null : identity.getIdentityKey();
|
||||
}
|
||||
|
||||
public IdentityInfo getIdentity(SignalServiceAddress serviceAddress) {
|
||||
long maxDate = 0;
|
||||
IdentityInfo maxIdentity = null;
|
||||
for (var id : this.identities) {
|
||||
if (!id.address.matches(serviceAddress)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final var time = id.getDateAdded().getTime();
|
||||
if (maxIdentity == null || maxDate <= time) {
|
||||
maxDate = time;
|
||||
maxIdentity = id;
|
||||
}
|
||||
}
|
||||
return maxIdentity;
|
||||
}
|
||||
|
||||
public List<IdentityInfo> getIdentities() {
|
||||
// TODO deep copy
|
||||
return identities;
|
||||
}
|
||||
|
||||
public List<IdentityInfo> getIdentities(SignalServiceAddress serviceAddress) {
|
||||
var identities = new ArrayList<IdentityInfo>();
|
||||
for (var identity : this.identities) {
|
||||
if (identity.address.matches(serviceAddress)) {
|
||||
identities.add(identity);
|
||||
}
|
||||
}
|
||||
return identities;
|
||||
}
|
||||
|
||||
public static class JsonIdentityKeyStoreDeserializer extends JsonDeserializer<JsonIdentityKeyStore> {
|
||||
|
||||
@Override
|
||||
public JsonIdentityKeyStore deserialize(
|
||||
JsonParser jsonParser, DeserializationContext deserializationContext
|
||||
) throws IOException {
|
||||
JsonNode node = jsonParser.getCodec().readTree(jsonParser);
|
||||
|
||||
var localRegistrationId = node.get("registrationId").asInt();
|
||||
var identityKeyPair = new IdentityKeyPair(Base64.getDecoder().decode(node.get("identityKey").asText()));
|
||||
|
||||
var keyStore = new JsonIdentityKeyStore(identityKeyPair, localRegistrationId);
|
||||
|
||||
var trustedKeysNode = node.get("trustedKeys");
|
||||
if (trustedKeysNode.isArray()) {
|
||||
for (var trustedKey : trustedKeysNode) {
|
||||
var trustedKeyName = trustedKey.hasNonNull("name") ? trustedKey.get("name").asText() : null;
|
||||
|
||||
if (UuidUtil.isUuid(trustedKeyName)) {
|
||||
// Ignore identities that were incorrectly created with UUIDs as name
|
||||
continue;
|
||||
}
|
||||
|
||||
var uuid = trustedKey.hasNonNull("uuid")
|
||||
? UuidUtil.parseOrNull(trustedKey.get("uuid").asText())
|
||||
: null;
|
||||
final var serviceAddress = uuid == null
|
||||
? Utils.getSignalServiceAddressFromIdentifier(trustedKeyName)
|
||||
: new SignalServiceAddress(uuid, trustedKeyName);
|
||||
try {
|
||||
var id = new IdentityKey(Base64.getDecoder().decode(trustedKey.get("identityKey").asText()), 0);
|
||||
var trustLevel = trustedKey.hasNonNull("trustLevel") ? TrustLevel.fromInt(trustedKey.get(
|
||||
"trustLevel").asInt()) : TrustLevel.TRUSTED_UNVERIFIED;
|
||||
var added = trustedKey.hasNonNull("addedTimestamp") ? new Date(trustedKey.get("addedTimestamp")
|
||||
.asLong()) : new Date();
|
||||
keyStore.saveIdentity(serviceAddress, id, trustLevel, added);
|
||||
} catch (InvalidKeyException e) {
|
||||
logger.warn("Error while decoding key for {}: {}", trustedKeyName, e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return keyStore;
|
||||
}
|
||||
}
|
||||
|
||||
public static class JsonIdentityKeyStoreSerializer extends JsonSerializer<JsonIdentityKeyStore> {
|
||||
|
||||
@Override
|
||||
public void serialize(
|
||||
JsonIdentityKeyStore jsonIdentityKeyStore, JsonGenerator json, SerializerProvider serializerProvider
|
||||
) throws IOException {
|
||||
json.writeStartObject();
|
||||
json.writeNumberField("registrationId", jsonIdentityKeyStore.getLocalRegistrationId());
|
||||
json.writeStringField("identityKey",
|
||||
Base64.getEncoder().encodeToString(jsonIdentityKeyStore.getIdentityKeyPair().serialize()));
|
||||
json.writeStringField("identityPrivateKey",
|
||||
Base64.getEncoder()
|
||||
.encodeToString(jsonIdentityKeyStore.getIdentityKeyPair().getPrivateKey().serialize()));
|
||||
json.writeStringField("identityPublicKey",
|
||||
Base64.getEncoder()
|
||||
.encodeToString(jsonIdentityKeyStore.getIdentityKeyPair().getPublicKey().serialize()));
|
||||
json.writeArrayFieldStart("trustedKeys");
|
||||
for (var trustedKey : jsonIdentityKeyStore.identities) {
|
||||
json.writeStartObject();
|
||||
if (trustedKey.getAddress().getNumber().isPresent()) {
|
||||
json.writeStringField("name", trustedKey.getAddress().getNumber().get());
|
||||
}
|
||||
if (trustedKey.getAddress().getUuid().isPresent()) {
|
||||
json.writeStringField("uuid", trustedKey.getAddress().getUuid().get().toString());
|
||||
}
|
||||
json.writeStringField("identityKey",
|
||||
Base64.getEncoder().encodeToString(trustedKey.identityKey.serialize()));
|
||||
json.writeNumberField("trustLevel", trustedKey.trustLevel.ordinal());
|
||||
json.writeNumberField("addedTimestamp", trustedKey.added.getTime());
|
||||
json.writeEndObject();
|
||||
}
|
||||
json.writeEndArray();
|
||||
json.writeEndObject();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,104 @@
|
|||
package org.asamk.signal.manager.storage.protocol;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonGenerator;
|
||||
import com.fasterxml.jackson.core.JsonParser;
|
||||
import com.fasterxml.jackson.databind.DeserializationContext;
|
||||
import com.fasterxml.jackson.databind.JsonDeserializer;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.JsonSerializer;
|
||||
import com.fasterxml.jackson.databind.SerializerProvider;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.whispersystems.libsignal.InvalidKeyIdException;
|
||||
import org.whispersystems.libsignal.state.PreKeyRecord;
|
||||
import org.whispersystems.libsignal.state.PreKeyStore;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Base64;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
class JsonPreKeyStore implements PreKeyStore {
|
||||
|
||||
private final static Logger logger = LoggerFactory.getLogger(JsonPreKeyStore.class);
|
||||
|
||||
private final Map<Integer, byte[]> store = new HashMap<>();
|
||||
|
||||
public JsonPreKeyStore() {
|
||||
|
||||
}
|
||||
|
||||
private void addPreKeys(Map<Integer, byte[]> preKeys) {
|
||||
store.putAll(preKeys);
|
||||
}
|
||||
|
||||
@Override
|
||||
public PreKeyRecord loadPreKey(int preKeyId) throws InvalidKeyIdException {
|
||||
try {
|
||||
if (!store.containsKey(preKeyId)) {
|
||||
throw new InvalidKeyIdException("No such prekeyrecord!");
|
||||
}
|
||||
|
||||
return new PreKeyRecord(store.get(preKeyId));
|
||||
} catch (IOException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void storePreKey(int preKeyId, PreKeyRecord record) {
|
||||
store.put(preKeyId, record.serialize());
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean containsPreKey(int preKeyId) {
|
||||
return store.containsKey(preKeyId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removePreKey(int preKeyId) {
|
||||
store.remove(preKeyId);
|
||||
}
|
||||
|
||||
public static class JsonPreKeyStoreDeserializer extends JsonDeserializer<JsonPreKeyStore> {
|
||||
|
||||
@Override
|
||||
public JsonPreKeyStore deserialize(
|
||||
JsonParser jsonParser, DeserializationContext deserializationContext
|
||||
) throws IOException {
|
||||
JsonNode node = jsonParser.getCodec().readTree(jsonParser);
|
||||
|
||||
var preKeyMap = new HashMap<Integer, byte[]>();
|
||||
if (node.isArray()) {
|
||||
for (var preKey : node) {
|
||||
final var preKeyId = preKey.get("id").asInt();
|
||||
final var preKeyRecord = Base64.getDecoder().decode(preKey.get("record").asText());
|
||||
preKeyMap.put(preKeyId, preKeyRecord);
|
||||
}
|
||||
}
|
||||
|
||||
var keyStore = new JsonPreKeyStore();
|
||||
keyStore.addPreKeys(preKeyMap);
|
||||
|
||||
return keyStore;
|
||||
}
|
||||
}
|
||||
|
||||
public static class JsonPreKeyStoreSerializer extends JsonSerializer<JsonPreKeyStore> {
|
||||
|
||||
@Override
|
||||
public void serialize(
|
||||
JsonPreKeyStore jsonPreKeyStore, JsonGenerator json, SerializerProvider serializerProvider
|
||||
) throws IOException {
|
||||
json.writeStartArray();
|
||||
for (var preKey : jsonPreKeyStore.store.entrySet()) {
|
||||
json.writeStartObject();
|
||||
json.writeNumberField("id", preKey.getKey());
|
||||
json.writeStringField("record", Base64.getEncoder().encodeToString(preKey.getValue()));
|
||||
json.writeEndObject();
|
||||
}
|
||||
json.writeEndArray();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,214 @@
|
|||
package org.asamk.signal.manager.storage.protocol;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonGenerator;
|
||||
import com.fasterxml.jackson.core.JsonParser;
|
||||
import com.fasterxml.jackson.databind.DeserializationContext;
|
||||
import com.fasterxml.jackson.databind.JsonDeserializer;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.JsonSerializer;
|
||||
import com.fasterxml.jackson.databind.SerializerProvider;
|
||||
|
||||
import org.asamk.signal.manager.util.Utils;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.whispersystems.libsignal.SignalProtocolAddress;
|
||||
import org.whispersystems.libsignal.protocol.CiphertextMessage;
|
||||
import org.whispersystems.libsignal.state.SessionRecord;
|
||||
import org.whispersystems.signalservice.api.SignalServiceSessionStore;
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
|
||||
import org.whispersystems.signalservice.api.util.UuidUtil;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
|
||||
class JsonSessionStore implements SignalServiceSessionStore {
|
||||
|
||||
private final static Logger logger = LoggerFactory.getLogger(JsonSessionStore.class);
|
||||
|
||||
private final List<SessionInfo> sessions = new ArrayList<>();
|
||||
|
||||
private SignalServiceAddressResolver resolver;
|
||||
|
||||
public JsonSessionStore() {
|
||||
}
|
||||
|
||||
public void setResolver(final SignalServiceAddressResolver resolver) {
|
||||
this.resolver = resolver;
|
||||
}
|
||||
|
||||
private SignalServiceAddress resolveSignalServiceAddress(String identifier) {
|
||||
if (resolver != null) {
|
||||
return resolver.resolveSignalServiceAddress(identifier);
|
||||
} else {
|
||||
return Utils.getSignalServiceAddressFromIdentifier(identifier);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized SessionRecord loadSession(SignalProtocolAddress address) {
|
||||
var serviceAddress = resolveSignalServiceAddress(address.getName());
|
||||
for (var info : sessions) {
|
||||
if (info.address.matches(serviceAddress) && info.deviceId == address.getDeviceId()) {
|
||||
try {
|
||||
return new SessionRecord(info.sessionRecord);
|
||||
} catch (IOException e) {
|
||||
logger.warn("Failed to load session, resetting session: {}", e.getMessage());
|
||||
return new SessionRecord();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new SessionRecord();
|
||||
}
|
||||
|
||||
public synchronized List<SessionInfo> getSessions() {
|
||||
return sessions;
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized List<Integer> getSubDeviceSessions(String name) {
|
||||
var serviceAddress = resolveSignalServiceAddress(name);
|
||||
|
||||
var deviceIds = new LinkedList<Integer>();
|
||||
for (var info : sessions) {
|
||||
if (info.address.matches(serviceAddress) && info.deviceId != 1) {
|
||||
deviceIds.add(info.deviceId);
|
||||
}
|
||||
}
|
||||
|
||||
return deviceIds;
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized void storeSession(SignalProtocolAddress address, SessionRecord record) {
|
||||
var serviceAddress = resolveSignalServiceAddress(address.getName());
|
||||
for (var info : sessions) {
|
||||
if (info.address.matches(serviceAddress) && info.deviceId == address.getDeviceId()) {
|
||||
if (!info.address.getUuid().isPresent() || !info.address.getNumber().isPresent()) {
|
||||
info.address = serviceAddress;
|
||||
}
|
||||
info.sessionRecord = record.serialize();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
sessions.add(new SessionInfo(serviceAddress, address.getDeviceId(), record.serialize()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized boolean containsSession(SignalProtocolAddress address) {
|
||||
var serviceAddress = resolveSignalServiceAddress(address.getName());
|
||||
for (var info : sessions) {
|
||||
if (info.address.matches(serviceAddress) && info.deviceId == address.getDeviceId()) {
|
||||
final SessionRecord sessionRecord;
|
||||
try {
|
||||
sessionRecord = new SessionRecord(info.sessionRecord);
|
||||
} catch (IOException e) {
|
||||
logger.warn("Failed to check session: {}", e.getMessage());
|
||||
return false;
|
||||
}
|
||||
|
||||
return sessionRecord.hasSenderChain()
|
||||
&& sessionRecord.getSessionVersion() == CiphertextMessage.CURRENT_VERSION;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized void deleteSession(SignalProtocolAddress address) {
|
||||
var serviceAddress = resolveSignalServiceAddress(address.getName());
|
||||
sessions.removeIf(info -> info.address.matches(serviceAddress) && info.deviceId == address.getDeviceId());
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized void deleteAllSessions(String name) {
|
||||
var serviceAddress = resolveSignalServiceAddress(name);
|
||||
deleteAllSessions(serviceAddress);
|
||||
}
|
||||
|
||||
public synchronized void deleteAllSessions(SignalServiceAddress serviceAddress) {
|
||||
sessions.removeIf(info -> info.address.matches(serviceAddress));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void archiveSession(final SignalProtocolAddress address) {
|
||||
final var sessionRecord = loadSession(address);
|
||||
if (sessionRecord == null) {
|
||||
return;
|
||||
}
|
||||
sessionRecord.archiveCurrentState();
|
||||
storeSession(address, sessionRecord);
|
||||
}
|
||||
|
||||
public void archiveAllSessions() {
|
||||
for (var info : sessions) {
|
||||
try {
|
||||
final var sessionRecord = new SessionRecord(info.sessionRecord);
|
||||
sessionRecord.archiveCurrentState();
|
||||
info.sessionRecord = sessionRecord.serialize();
|
||||
} catch (IOException ignored) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static class JsonSessionStoreDeserializer extends JsonDeserializer<JsonSessionStore> {
|
||||
|
||||
@Override
|
||||
public JsonSessionStore deserialize(
|
||||
JsonParser jsonParser, DeserializationContext deserializationContext
|
||||
) throws IOException {
|
||||
JsonNode node = jsonParser.getCodec().readTree(jsonParser);
|
||||
|
||||
var sessionStore = new JsonSessionStore();
|
||||
|
||||
if (node.isArray()) {
|
||||
for (var session : node) {
|
||||
var sessionName = session.hasNonNull("name") ? session.get("name").asText() : null;
|
||||
if (UuidUtil.isUuid(sessionName)) {
|
||||
// Ignore sessions that were incorrectly created with UUIDs as name
|
||||
continue;
|
||||
}
|
||||
|
||||
var uuid = session.hasNonNull("uuid") ? UuidUtil.parseOrNull(session.get("uuid").asText()) : null;
|
||||
final var serviceAddress = uuid == null
|
||||
? Utils.getSignalServiceAddressFromIdentifier(sessionName)
|
||||
: new SignalServiceAddress(uuid, sessionName);
|
||||
final var deviceId = session.get("deviceId").asInt();
|
||||
final var record = Base64.getDecoder().decode(session.get("record").asText());
|
||||
var sessionInfo = new SessionInfo(serviceAddress, deviceId, record);
|
||||
sessionStore.sessions.add(sessionInfo);
|
||||
}
|
||||
}
|
||||
|
||||
return sessionStore;
|
||||
}
|
||||
}
|
||||
|
||||
public static class JsonSessionStoreSerializer extends JsonSerializer<JsonSessionStore> {
|
||||
|
||||
@Override
|
||||
public void serialize(
|
||||
JsonSessionStore jsonSessionStore, JsonGenerator json, SerializerProvider serializerProvider
|
||||
) throws IOException {
|
||||
json.writeStartArray();
|
||||
for (var sessionInfo : jsonSessionStore.sessions) {
|
||||
json.writeStartObject();
|
||||
if (sessionInfo.address.getNumber().isPresent()) {
|
||||
json.writeStringField("name", sessionInfo.address.getNumber().get());
|
||||
}
|
||||
if (sessionInfo.address.getUuid().isPresent()) {
|
||||
json.writeStringField("uuid", sessionInfo.address.getUuid().get().toString());
|
||||
}
|
||||
json.writeNumberField("deviceId", sessionInfo.deviceId);
|
||||
json.writeStringField("record", Base64.getEncoder().encodeToString(sessionInfo.sessionRecord));
|
||||
json.writeEndObject();
|
||||
}
|
||||
json.writeEndArray();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,211 @@
|
|||
package org.asamk.signal.manager.storage.protocol;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
|
||||
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
|
||||
|
||||
import org.asamk.signal.manager.TrustLevel;
|
||||
import org.whispersystems.libsignal.IdentityKey;
|
||||
import org.whispersystems.libsignal.IdentityKeyPair;
|
||||
import org.whispersystems.libsignal.InvalidKeyIdException;
|
||||
import org.whispersystems.libsignal.SignalProtocolAddress;
|
||||
import org.whispersystems.libsignal.state.PreKeyRecord;
|
||||
import org.whispersystems.libsignal.state.SessionRecord;
|
||||
import org.whispersystems.libsignal.state.SignedPreKeyRecord;
|
||||
import org.whispersystems.signalservice.api.SignalServiceProtocolStore;
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public class JsonSignalProtocolStore implements SignalServiceProtocolStore {
|
||||
|
||||
@JsonProperty("preKeys")
|
||||
@JsonDeserialize(using = JsonPreKeyStore.JsonPreKeyStoreDeserializer.class)
|
||||
@JsonSerialize(using = JsonPreKeyStore.JsonPreKeyStoreSerializer.class)
|
||||
private JsonPreKeyStore preKeyStore;
|
||||
|
||||
@JsonProperty("sessionStore")
|
||||
@JsonDeserialize(using = JsonSessionStore.JsonSessionStoreDeserializer.class)
|
||||
@JsonSerialize(using = JsonSessionStore.JsonSessionStoreSerializer.class)
|
||||
private JsonSessionStore sessionStore;
|
||||
|
||||
@JsonProperty("signedPreKeyStore")
|
||||
@JsonDeserialize(using = JsonSignedPreKeyStore.JsonSignedPreKeyStoreDeserializer.class)
|
||||
@JsonSerialize(using = JsonSignedPreKeyStore.JsonSignedPreKeyStoreSerializer.class)
|
||||
private JsonSignedPreKeyStore signedPreKeyStore;
|
||||
|
||||
@JsonProperty("identityKeyStore")
|
||||
@JsonDeserialize(using = JsonIdentityKeyStore.JsonIdentityKeyStoreDeserializer.class)
|
||||
@JsonSerialize(using = JsonIdentityKeyStore.JsonIdentityKeyStoreSerializer.class)
|
||||
private JsonIdentityKeyStore identityKeyStore;
|
||||
|
||||
public JsonSignalProtocolStore() {
|
||||
}
|
||||
|
||||
public JsonSignalProtocolStore(
|
||||
JsonPreKeyStore preKeyStore,
|
||||
JsonSessionStore sessionStore,
|
||||
JsonSignedPreKeyStore signedPreKeyStore,
|
||||
JsonIdentityKeyStore identityKeyStore
|
||||
) {
|
||||
this.preKeyStore = preKeyStore;
|
||||
this.sessionStore = sessionStore;
|
||||
this.signedPreKeyStore = signedPreKeyStore;
|
||||
this.identityKeyStore = identityKeyStore;
|
||||
}
|
||||
|
||||
public JsonSignalProtocolStore(IdentityKeyPair identityKeyPair, int registrationId) {
|
||||
preKeyStore = new JsonPreKeyStore();
|
||||
sessionStore = new JsonSessionStore();
|
||||
signedPreKeyStore = new JsonSignedPreKeyStore();
|
||||
this.identityKeyStore = new JsonIdentityKeyStore(identityKeyPair, registrationId);
|
||||
}
|
||||
|
||||
public void setResolver(final SignalServiceAddressResolver resolver) {
|
||||
sessionStore.setResolver(resolver);
|
||||
identityKeyStore.setResolver(resolver);
|
||||
}
|
||||
|
||||
@Override
|
||||
public IdentityKeyPair getIdentityKeyPair() {
|
||||
return identityKeyStore.getIdentityKeyPair();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getLocalRegistrationId() {
|
||||
return identityKeyStore.getLocalRegistrationId();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean saveIdentity(SignalProtocolAddress address, IdentityKey identityKey) {
|
||||
return identityKeyStore.saveIdentity(address, identityKey);
|
||||
}
|
||||
|
||||
public void saveIdentity(SignalServiceAddress serviceAddress, IdentityKey identityKey, TrustLevel trustLevel) {
|
||||
identityKeyStore.saveIdentity(serviceAddress, identityKey, trustLevel, null);
|
||||
}
|
||||
|
||||
public void setIdentityTrustLevel(
|
||||
SignalServiceAddress serviceAddress, IdentityKey identityKey, TrustLevel trustLevel
|
||||
) {
|
||||
identityKeyStore.setIdentityTrustLevel(serviceAddress, identityKey, trustLevel);
|
||||
}
|
||||
|
||||
public void removeIdentity(SignalServiceAddress serviceAddress, IdentityKey identityKey) {
|
||||
identityKeyStore.removeIdentity(serviceAddress, identityKey);
|
||||
}
|
||||
|
||||
public List<IdentityInfo> getIdentities() {
|
||||
return identityKeyStore.getIdentities();
|
||||
}
|
||||
|
||||
public List<IdentityInfo> getIdentities(SignalServiceAddress serviceAddress) {
|
||||
return identityKeyStore.getIdentities(serviceAddress);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isTrustedIdentity(SignalProtocolAddress address, IdentityKey identityKey, Direction direction) {
|
||||
return identityKeyStore.isTrustedIdentity(address, identityKey, direction);
|
||||
}
|
||||
|
||||
@Override
|
||||
public IdentityKey getIdentity(SignalProtocolAddress address) {
|
||||
return identityKeyStore.getIdentity(address);
|
||||
}
|
||||
|
||||
public IdentityInfo getIdentity(SignalServiceAddress serviceAddress) {
|
||||
return identityKeyStore.getIdentity(serviceAddress);
|
||||
}
|
||||
|
||||
@Override
|
||||
public PreKeyRecord loadPreKey(int preKeyId) throws InvalidKeyIdException {
|
||||
return preKeyStore.loadPreKey(preKeyId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void storePreKey(int preKeyId, PreKeyRecord record) {
|
||||
preKeyStore.storePreKey(preKeyId, record);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean containsPreKey(int preKeyId) {
|
||||
return preKeyStore.containsPreKey(preKeyId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removePreKey(int preKeyId) {
|
||||
preKeyStore.removePreKey(preKeyId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public SessionRecord loadSession(SignalProtocolAddress address) {
|
||||
return sessionStore.loadSession(address);
|
||||
}
|
||||
|
||||
public List<SessionInfo> getSessions() {
|
||||
return sessionStore.getSessions();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Integer> getSubDeviceSessions(String name) {
|
||||
return sessionStore.getSubDeviceSessions(name);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void storeSession(SignalProtocolAddress address, SessionRecord record) {
|
||||
sessionStore.storeSession(address, record);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean containsSession(SignalProtocolAddress address) {
|
||||
return sessionStore.containsSession(address);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteSession(SignalProtocolAddress address) {
|
||||
sessionStore.deleteSession(address);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteAllSessions(String name) {
|
||||
sessionStore.deleteAllSessions(name);
|
||||
}
|
||||
|
||||
public void deleteAllSessions(SignalServiceAddress serviceAddress) {
|
||||
sessionStore.deleteAllSessions(serviceAddress);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void archiveSession(final SignalProtocolAddress address) {
|
||||
sessionStore.archiveSession(address);
|
||||
}
|
||||
|
||||
public void archiveAllSessions() {
|
||||
sessionStore.archiveAllSessions();
|
||||
}
|
||||
|
||||
@Override
|
||||
public SignedPreKeyRecord loadSignedPreKey(int signedPreKeyId) throws InvalidKeyIdException {
|
||||
return signedPreKeyStore.loadSignedPreKey(signedPreKeyId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<SignedPreKeyRecord> loadSignedPreKeys() {
|
||||
return signedPreKeyStore.loadSignedPreKeys();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void storeSignedPreKey(int signedPreKeyId, SignedPreKeyRecord record) {
|
||||
signedPreKeyStore.storeSignedPreKey(signedPreKeyId, record);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean containsSignedPreKey(int signedPreKeyId) {
|
||||
return signedPreKeyStore.containsSignedPreKey(signedPreKeyId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removeSignedPreKey(int signedPreKeyId) {
|
||||
signedPreKeyStore.removeSignedPreKey(signedPreKeyId);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,121 @@
|
|||
package org.asamk.signal.manager.storage.protocol;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonGenerator;
|
||||
import com.fasterxml.jackson.core.JsonParser;
|
||||
import com.fasterxml.jackson.databind.DeserializationContext;
|
||||
import com.fasterxml.jackson.databind.JsonDeserializer;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.JsonSerializer;
|
||||
import com.fasterxml.jackson.databind.SerializerProvider;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.whispersystems.libsignal.InvalidKeyIdException;
|
||||
import org.whispersystems.libsignal.state.SignedPreKeyRecord;
|
||||
import org.whispersystems.libsignal.state.SignedPreKeyStore;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Base64;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
class JsonSignedPreKeyStore implements SignedPreKeyStore {
|
||||
|
||||
private final static Logger logger = LoggerFactory.getLogger(JsonSignedPreKeyStore.class);
|
||||
|
||||
private final Map<Integer, byte[]> store = new HashMap<>();
|
||||
|
||||
public JsonSignedPreKeyStore() {
|
||||
|
||||
}
|
||||
|
||||
private void addSignedPreKeys(Map<Integer, byte[]> preKeys) {
|
||||
store.putAll(preKeys);
|
||||
}
|
||||
|
||||
@Override
|
||||
public SignedPreKeyRecord loadSignedPreKey(int signedPreKeyId) throws InvalidKeyIdException {
|
||||
try {
|
||||
if (!store.containsKey(signedPreKeyId)) {
|
||||
throw new InvalidKeyIdException("No such signedprekeyrecord! " + signedPreKeyId);
|
||||
}
|
||||
|
||||
return new SignedPreKeyRecord(store.get(signedPreKeyId));
|
||||
} catch (IOException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<SignedPreKeyRecord> loadSignedPreKeys() {
|
||||
try {
|
||||
var results = new LinkedList<SignedPreKeyRecord>();
|
||||
|
||||
for (var serialized : store.values()) {
|
||||
results.add(new SignedPreKeyRecord(serialized));
|
||||
}
|
||||
|
||||
return results;
|
||||
} catch (IOException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void storeSignedPreKey(int signedPreKeyId, SignedPreKeyRecord record) {
|
||||
store.put(signedPreKeyId, record.serialize());
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean containsSignedPreKey(int signedPreKeyId) {
|
||||
return store.containsKey(signedPreKeyId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removeSignedPreKey(int signedPreKeyId) {
|
||||
store.remove(signedPreKeyId);
|
||||
}
|
||||
|
||||
public static class JsonSignedPreKeyStoreDeserializer extends JsonDeserializer<JsonSignedPreKeyStore> {
|
||||
|
||||
@Override
|
||||
public JsonSignedPreKeyStore deserialize(
|
||||
JsonParser jsonParser, DeserializationContext deserializationContext
|
||||
) throws IOException {
|
||||
JsonNode node = jsonParser.getCodec().readTree(jsonParser);
|
||||
|
||||
var preKeyMap = new HashMap<Integer, byte[]>();
|
||||
if (node.isArray()) {
|
||||
for (var preKey : node) {
|
||||
final var preKeyId = preKey.get("id").asInt();
|
||||
final var preKeyRecord = Base64.getDecoder().decode(preKey.get("record").asText());
|
||||
preKeyMap.put(preKeyId, preKeyRecord);
|
||||
}
|
||||
}
|
||||
|
||||
var keyStore = new JsonSignedPreKeyStore();
|
||||
keyStore.addSignedPreKeys(preKeyMap);
|
||||
|
||||
return keyStore;
|
||||
}
|
||||
}
|
||||
|
||||
public static class JsonSignedPreKeyStoreSerializer extends JsonSerializer<JsonSignedPreKeyStore> {
|
||||
|
||||
@Override
|
||||
public void serialize(
|
||||
JsonSignedPreKeyStore jsonPreKeyStore, JsonGenerator json, SerializerProvider serializerProvider
|
||||
) throws IOException {
|
||||
json.writeStartArray();
|
||||
for (var signedPreKey : jsonPreKeyStore.store.entrySet()) {
|
||||
json.writeStartObject();
|
||||
json.writeNumberField("id", signedPreKey.getKey());
|
||||
json.writeStringField("record", Base64.getEncoder().encodeToString(signedPreKey.getValue()));
|
||||
json.writeEndObject();
|
||||
}
|
||||
json.writeEndArray();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,87 @@
|
|||
package org.asamk.signal.manager.storage.protocol;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import com.fasterxml.jackson.core.JsonGenerator;
|
||||
import com.fasterxml.jackson.core.JsonParser;
|
||||
import com.fasterxml.jackson.databind.DeserializationContext;
|
||||
import com.fasterxml.jackson.databind.JsonDeserializer;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.JsonSerializer;
|
||||
import com.fasterxml.jackson.databind.SerializerProvider;
|
||||
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
|
||||
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
|
||||
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
|
||||
import org.whispersystems.signalservice.api.util.UuidUtil;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
|
||||
public class RecipientStore {
|
||||
|
||||
@JsonProperty("recipientStore")
|
||||
@JsonDeserialize(using = RecipientStoreDeserializer.class)
|
||||
@JsonSerialize(using = RecipientStoreSerializer.class)
|
||||
private final Set<SignalServiceAddress> addresses = new HashSet<>();
|
||||
|
||||
public SignalServiceAddress resolveServiceAddress(SignalServiceAddress serviceAddress) {
|
||||
if (addresses.contains(serviceAddress)) {
|
||||
// If the Set already contains the exact address with UUID and Number,
|
||||
// we can just return it here.
|
||||
return serviceAddress;
|
||||
}
|
||||
|
||||
for (var address : addresses) {
|
||||
if (address.matches(serviceAddress)) {
|
||||
return address;
|
||||
}
|
||||
}
|
||||
|
||||
if (serviceAddress.getNumber().isPresent() && serviceAddress.getUuid().isPresent()) {
|
||||
addresses.add(serviceAddress);
|
||||
}
|
||||
|
||||
return serviceAddress;
|
||||
}
|
||||
|
||||
public static class RecipientStoreDeserializer extends JsonDeserializer<Set<SignalServiceAddress>> {
|
||||
|
||||
@Override
|
||||
public Set<SignalServiceAddress> deserialize(
|
||||
JsonParser jsonParser, DeserializationContext deserializationContext
|
||||
) throws IOException {
|
||||
JsonNode node = jsonParser.getCodec().readTree(jsonParser);
|
||||
|
||||
var addresses = new HashSet<SignalServiceAddress>();
|
||||
|
||||
if (node.isArray()) {
|
||||
for (var recipient : node) {
|
||||
var recipientName = recipient.get("name").asText();
|
||||
var uuid = UuidUtil.parseOrThrow(recipient.get("uuid").asText());
|
||||
final var serviceAddress = new SignalServiceAddress(uuid, recipientName);
|
||||
addresses.add(serviceAddress);
|
||||
}
|
||||
}
|
||||
|
||||
return addresses;
|
||||
}
|
||||
}
|
||||
|
||||
public static class RecipientStoreSerializer extends JsonSerializer<Set<SignalServiceAddress>> {
|
||||
|
||||
@Override
|
||||
public void serialize(
|
||||
Set<SignalServiceAddress> addresses, JsonGenerator json, SerializerProvider serializerProvider
|
||||
) throws IOException {
|
||||
json.writeStartArray();
|
||||
for (var address : addresses) {
|
||||
json.writeStartObject();
|
||||
json.writeStringField("name", address.getNumber().get());
|
||||
json.writeStringField("uuid", address.getUuid().get().toString());
|
||||
json.writeEndObject();
|
||||
}
|
||||
json.writeEndArray();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,18 @@
|
|||
package org.asamk.signal.manager.storage.protocol;
|
||||
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
|
||||
|
||||
public class SessionInfo {
|
||||
|
||||
public SignalServiceAddress address;
|
||||
|
||||
public int deviceId;
|
||||
|
||||
public byte[] sessionRecord;
|
||||
|
||||
public SessionInfo(final SignalServiceAddress address, final int deviceId, final byte[] sessionRecord) {
|
||||
this.address = address;
|
||||
this.deviceId = deviceId;
|
||||
this.sessionRecord = sessionRecord;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
package org.asamk.signal.manager.storage.protocol;
|
||||
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
|
||||
|
||||
public interface SignalServiceAddressResolver {
|
||||
|
||||
/**
|
||||
* Get a SignalServiceAddress with number and/or uuid from an identifier name.
|
||||
*
|
||||
* @param identifier can be either a serialized uuid or a e164 phone number
|
||||
*/
|
||||
SignalServiceAddress resolveSignalServiceAddress(String identifier);
|
||||
}
|
|
@ -0,0 +1,35 @@
|
|||
package org.asamk.signal.manager.storage.stickers;
|
||||
|
||||
public class Sticker {
|
||||
|
||||
private final byte[] packId;
|
||||
private final byte[] packKey;
|
||||
private boolean installed;
|
||||
|
||||
public Sticker(final byte[] packId, final byte[] packKey) {
|
||||
this.packId = packId;
|
||||
this.packKey = packKey;
|
||||
}
|
||||
|
||||
public Sticker(final byte[] packId, final byte[] packKey, final boolean installed) {
|
||||
this.packId = packId;
|
||||
this.packKey = packKey;
|
||||
this.installed = installed;
|
||||
}
|
||||
|
||||
public byte[] getPackId() {
|
||||
return packId;
|
||||
}
|
||||
|
||||
public byte[] getPackKey() {
|
||||
return packKey;
|
||||
}
|
||||
|
||||
public boolean isInstalled() {
|
||||
return installed;
|
||||
}
|
||||
|
||||
public void setInstalled(final boolean installed) {
|
||||
this.installed = installed;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,69 @@
|
|||
package org.asamk.signal.manager.storage.stickers;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonGenerator;
|
||||
import com.fasterxml.jackson.core.JsonParser;
|
||||
import com.fasterxml.jackson.databind.DeserializationContext;
|
||||
import com.fasterxml.jackson.databind.JsonDeserializer;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.JsonSerializer;
|
||||
import com.fasterxml.jackson.databind.SerializerProvider;
|
||||
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
|
||||
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Base64;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
public class StickerStore {
|
||||
|
||||
@JsonSerialize(using = StickersSerializer.class)
|
||||
@JsonDeserialize(using = StickersDeserializer.class)
|
||||
private final Map<byte[], Sticker> stickers = new HashMap<>();
|
||||
|
||||
public Sticker getSticker(byte[] packId) {
|
||||
return stickers.get(packId);
|
||||
}
|
||||
|
||||
public void updateSticker(Sticker sticker) {
|
||||
stickers.put(sticker.getPackId(), sticker);
|
||||
}
|
||||
|
||||
private static class StickersSerializer extends JsonSerializer<Map<byte[], Sticker>> {
|
||||
|
||||
@Override
|
||||
public void serialize(
|
||||
final Map<byte[], Sticker> value, final JsonGenerator jgen, final SerializerProvider provider
|
||||
) throws IOException {
|
||||
final var stickers = value.values();
|
||||
jgen.writeStartArray(stickers.size());
|
||||
for (var sticker : stickers) {
|
||||
jgen.writeStartObject();
|
||||
jgen.writeStringField("packId", Base64.getEncoder().encodeToString(sticker.getPackId()));
|
||||
jgen.writeStringField("packKey", Base64.getEncoder().encodeToString(sticker.getPackKey()));
|
||||
jgen.writeBooleanField("installed", sticker.isInstalled());
|
||||
jgen.writeEndObject();
|
||||
}
|
||||
jgen.writeEndArray();
|
||||
}
|
||||
}
|
||||
|
||||
private static class StickersDeserializer extends JsonDeserializer<Map<byte[], Sticker>> {
|
||||
|
||||
@Override
|
||||
public Map<byte[], Sticker> deserialize(
|
||||
JsonParser jsonParser, DeserializationContext deserializationContext
|
||||
) throws IOException {
|
||||
var stickers = new HashMap<byte[], Sticker>();
|
||||
JsonNode node = jsonParser.getCodec().readTree(jsonParser);
|
||||
for (var n : node) {
|
||||
var packId = Base64.getDecoder().decode(n.get("packId").asText());
|
||||
var packKey = Base64.getDecoder().decode(n.get("packKey").asText());
|
||||
var installed = n.get("installed").asBoolean(false);
|
||||
stickers.put(packId, new Sticker(packId, packKey, installed));
|
||||
}
|
||||
|
||||
return stickers;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,60 @@
|
|||
package org.asamk.signal.manager.storage.threads;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import com.fasterxml.jackson.core.JsonGenerator;
|
||||
import com.fasterxml.jackson.core.JsonParser;
|
||||
import com.fasterxml.jackson.databind.DeserializationContext;
|
||||
import com.fasterxml.jackson.databind.JsonDeserializer;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.JsonSerializer;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.SerializerProvider;
|
||||
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
|
||||
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
public class LegacyJsonThreadStore {
|
||||
|
||||
private static final ObjectMapper jsonProcessor = new ObjectMapper();
|
||||
|
||||
@JsonProperty("threads")
|
||||
@JsonSerialize(using = MapToListSerializer.class)
|
||||
@JsonDeserialize(using = ThreadsDeserializer.class)
|
||||
private Map<String, ThreadInfo> threads = new HashMap<>();
|
||||
|
||||
public List<ThreadInfo> getThreads() {
|
||||
return new ArrayList<>(threads.values());
|
||||
}
|
||||
|
||||
private static class MapToListSerializer extends JsonSerializer<Map<?, ?>> {
|
||||
|
||||
@Override
|
||||
public void serialize(
|
||||
final Map<?, ?> value, final JsonGenerator jgen, final SerializerProvider provider
|
||||
) throws IOException {
|
||||
jgen.writeObject(value.values());
|
||||
}
|
||||
}
|
||||
|
||||
private static class ThreadsDeserializer extends JsonDeserializer<Map<String, ThreadInfo>> {
|
||||
|
||||
@Override
|
||||
public Map<String, ThreadInfo> deserialize(
|
||||
JsonParser jsonParser, DeserializationContext deserializationContext
|
||||
) throws IOException {
|
||||
var threads = new HashMap<String, ThreadInfo>();
|
||||
JsonNode node = jsonParser.getCodec().readTree(jsonParser);
|
||||
for (var n : node) {
|
||||
var t = jsonProcessor.treeToValue(n, ThreadInfo.class);
|
||||
threads.put(t.id, t);
|
||||
}
|
||||
|
||||
return threads;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
package org.asamk.signal.manager.storage.threads;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
public class ThreadInfo {
|
||||
|
||||
@JsonProperty
|
||||
public String id;
|
||||
|
||||
@JsonProperty
|
||||
public int messageExpirationTime;
|
||||
}
|
|
@ -0,0 +1,62 @@
|
|||
package org.asamk.signal.manager.util;
|
||||
|
||||
import org.asamk.signal.manager.AttachmentInvalidException;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment;
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentStream;
|
||||
import org.whispersystems.signalservice.api.util.StreamDetails;
|
||||
import org.whispersystems.signalservice.internal.push.http.ResumableUploadSpec;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public class AttachmentUtils {
|
||||
|
||||
public static List<SignalServiceAttachment> getSignalServiceAttachments(List<String> attachments) throws AttachmentInvalidException {
|
||||
List<SignalServiceAttachment> signalServiceAttachments = null;
|
||||
if (attachments != null) {
|
||||
signalServiceAttachments = new ArrayList<>(attachments.size());
|
||||
for (var attachment : attachments) {
|
||||
try {
|
||||
signalServiceAttachments.add(createAttachment(new File(attachment)));
|
||||
} catch (IOException e) {
|
||||
throw new AttachmentInvalidException(attachment, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
return signalServiceAttachments;
|
||||
}
|
||||
|
||||
public static SignalServiceAttachmentStream createAttachment(File attachmentFile) throws IOException {
|
||||
final var streamDetails = Utils.createStreamDetailsFromFile(attachmentFile);
|
||||
return createAttachment(streamDetails, Optional.of(attachmentFile.getName()));
|
||||
}
|
||||
|
||||
public static SignalServiceAttachmentStream createAttachment(
|
||||
StreamDetails streamDetails, Optional<String> name
|
||||
) {
|
||||
// TODO mabybe add a parameter to set the voiceNote, borderless, preview, width, height and caption option
|
||||
final var uploadTimestamp = System.currentTimeMillis();
|
||||
Optional<byte[]> preview = Optional.absent();
|
||||
Optional<String> caption = Optional.absent();
|
||||
Optional<String> blurHash = Optional.absent();
|
||||
final Optional<ResumableUploadSpec> resumableUploadSpec = Optional.absent();
|
||||
return new SignalServiceAttachmentStream(streamDetails.getStream(),
|
||||
streamDetails.getContentType(),
|
||||
streamDetails.getLength(),
|
||||
name,
|
||||
false,
|
||||
false,
|
||||
preview,
|
||||
0,
|
||||
0,
|
||||
uploadTimestamp,
|
||||
caption,
|
||||
blurHash,
|
||||
null,
|
||||
null,
|
||||
resumableUploadSpec);
|
||||
}
|
||||
}
|
75
lib/src/main/java/org/asamk/signal/manager/util/IOUtils.java
Normal file
75
lib/src/main/java/org/asamk/signal/manager/util/IOUtils.java
Normal file
|
@ -0,0 +1,75 @@
|
|||
package org.asamk.signal.manager.util;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.attribute.PosixFilePermission;
|
||||
import java.nio.file.attribute.PosixFilePermissions;
|
||||
import java.util.EnumSet;
|
||||
import java.util.Set;
|
||||
|
||||
import static java.nio.file.attribute.PosixFilePermission.OWNER_EXECUTE;
|
||||
import static java.nio.file.attribute.PosixFilePermission.OWNER_READ;
|
||||
import static java.nio.file.attribute.PosixFilePermission.OWNER_WRITE;
|
||||
|
||||
public class IOUtils {
|
||||
|
||||
public static File createTempFile() throws IOException {
|
||||
final var tempFile = File.createTempFile("signal-cli_tmp_", ".tmp");
|
||||
tempFile.deleteOnExit();
|
||||
return tempFile;
|
||||
}
|
||||
|
||||
public static byte[] readFully(InputStream in) throws IOException {
|
||||
var baos = new ByteArrayOutputStream();
|
||||
IOUtils.copyStream(in, baos);
|
||||
return baos.toByteArray();
|
||||
}
|
||||
|
||||
public static void createPrivateDirectories(File file) throws IOException {
|
||||
if (file.exists()) {
|
||||
return;
|
||||
}
|
||||
|
||||
final var path = file.toPath();
|
||||
try {
|
||||
Set<PosixFilePermission> perms = EnumSet.of(OWNER_READ, OWNER_WRITE, OWNER_EXECUTE);
|
||||
Files.createDirectories(path, PosixFilePermissions.asFileAttribute(perms));
|
||||
} catch (UnsupportedOperationException e) {
|
||||
Files.createDirectories(path);
|
||||
}
|
||||
}
|
||||
|
||||
public static void createPrivateFile(File path) throws IOException {
|
||||
final var file = path.toPath();
|
||||
try {
|
||||
Set<PosixFilePermission> perms = EnumSet.of(OWNER_READ, OWNER_WRITE);
|
||||
Files.createFile(file, PosixFilePermissions.asFileAttribute(perms));
|
||||
} catch (UnsupportedOperationException e) {
|
||||
Files.createFile(file);
|
||||
}
|
||||
}
|
||||
|
||||
public static void copyFileToStream(File inputFile, OutputStream output) throws IOException {
|
||||
try (InputStream inputStream = new FileInputStream(inputFile)) {
|
||||
copyStream(inputStream, output);
|
||||
}
|
||||
}
|
||||
|
||||
public static void copyStream(InputStream input, OutputStream output) throws IOException {
|
||||
copyStream(input, output, 4096);
|
||||
}
|
||||
|
||||
public static void copyStream(InputStream input, OutputStream output, int bufferSize) throws IOException {
|
||||
var buffer = new byte[bufferSize];
|
||||
int read;
|
||||
|
||||
while ((read = input.read(buffer)) != -1) {
|
||||
output.write(buffer, 0, read);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,93 @@
|
|||
package org.asamk.signal.manager.util;
|
||||
|
||||
import org.signal.zkgroup.InvalidInputException;
|
||||
import org.signal.zkgroup.profiles.ProfileKey;
|
||||
import org.whispersystems.libsignal.IdentityKey;
|
||||
import org.whispersystems.libsignal.IdentityKeyPair;
|
||||
import org.whispersystems.libsignal.InvalidKeyException;
|
||||
import org.whispersystems.libsignal.ecc.Curve;
|
||||
import org.whispersystems.libsignal.state.PreKeyRecord;
|
||||
import org.whispersystems.libsignal.state.SignedPreKeyRecord;
|
||||
import org.whispersystems.libsignal.util.Medium;
|
||||
import org.whispersystems.signalservice.api.kbs.MasterKey;
|
||||
|
||||
import java.security.SecureRandom;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
|
||||
public class KeyUtils {
|
||||
|
||||
private static final SecureRandom secureRandom = new SecureRandom();
|
||||
|
||||
private KeyUtils() {
|
||||
}
|
||||
|
||||
public static IdentityKeyPair generateIdentityKeyPair() {
|
||||
var djbKeyPair = Curve.generateKeyPair();
|
||||
var djbIdentityKey = new IdentityKey(djbKeyPair.getPublicKey());
|
||||
var djbPrivateKey = djbKeyPair.getPrivateKey();
|
||||
|
||||
return new IdentityKeyPair(djbIdentityKey, djbPrivateKey);
|
||||
}
|
||||
|
||||
public static List<PreKeyRecord> generatePreKeyRecords(final int offset, final int batchSize) {
|
||||
var records = new ArrayList<PreKeyRecord>(batchSize);
|
||||
for (var i = 0; i < batchSize; i++) {
|
||||
var preKeyId = (offset + i) % Medium.MAX_VALUE;
|
||||
var keyPair = Curve.generateKeyPair();
|
||||
var record = new PreKeyRecord(preKeyId, keyPair);
|
||||
|
||||
records.add(record);
|
||||
}
|
||||
return records;
|
||||
}
|
||||
|
||||
public static SignedPreKeyRecord generateSignedPreKeyRecord(
|
||||
final IdentityKeyPair identityKeyPair, final int signedPreKeyId
|
||||
) {
|
||||
var keyPair = Curve.generateKeyPair();
|
||||
byte[] signature;
|
||||
try {
|
||||
signature = Curve.calculateSignature(identityKeyPair.getPrivateKey(), keyPair.getPublicKey().serialize());
|
||||
} catch (InvalidKeyException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
return new SignedPreKeyRecord(signedPreKeyId, System.currentTimeMillis(), keyPair, signature);
|
||||
}
|
||||
|
||||
public static String createSignalingKey() {
|
||||
return getSecret(52);
|
||||
}
|
||||
|
||||
public static ProfileKey createProfileKey() {
|
||||
try {
|
||||
return new ProfileKey(getSecretBytes(32));
|
||||
} catch (InvalidInputException e) {
|
||||
throw new AssertionError("Profile key is guaranteed to be 32 bytes here");
|
||||
}
|
||||
}
|
||||
|
||||
public static String createPassword() {
|
||||
return getSecret(18);
|
||||
}
|
||||
|
||||
public static byte[] createStickerUploadKey() {
|
||||
return getSecretBytes(32);
|
||||
}
|
||||
|
||||
public static MasterKey createMasterKey() {
|
||||
return MasterKey.createNew(secureRandom);
|
||||
}
|
||||
|
||||
private static String getSecret(int size) {
|
||||
var secret = getSecretBytes(size);
|
||||
return Base64.getEncoder().encodeToString(secret);
|
||||
}
|
||||
|
||||
public static byte[] getSecretBytes(int size) {
|
||||
var secret = new byte[size];
|
||||
secureRandom.nextBytes(secret);
|
||||
return secret;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,105 @@
|
|||
package org.asamk.signal.manager.util;
|
||||
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceEnvelope;
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
|
||||
import org.whispersystems.signalservice.api.util.UuidUtil;
|
||||
|
||||
import java.io.DataInputStream;
|
||||
import java.io.DataOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.util.UUID;
|
||||
|
||||
public class MessageCacheUtils {
|
||||
|
||||
public static SignalServiceEnvelope loadEnvelope(File file) throws IOException {
|
||||
try (var f = new FileInputStream(file)) {
|
||||
var in = new DataInputStream(f);
|
||||
var version = in.readInt();
|
||||
if (version > 4) {
|
||||
return null;
|
||||
}
|
||||
var type = in.readInt();
|
||||
var source = in.readUTF();
|
||||
UUID sourceUuid = null;
|
||||
if (version >= 3) {
|
||||
sourceUuid = UuidUtil.parseOrNull(in.readUTF());
|
||||
}
|
||||
var sourceDevice = in.readInt();
|
||||
if (version == 1) {
|
||||
// read legacy relay field
|
||||
in.readUTF();
|
||||
}
|
||||
var timestamp = in.readLong();
|
||||
byte[] content = null;
|
||||
var contentLen = in.readInt();
|
||||
if (contentLen > 0) {
|
||||
content = new byte[contentLen];
|
||||
in.readFully(content);
|
||||
}
|
||||
byte[] legacyMessage = null;
|
||||
var legacyMessageLen = in.readInt();
|
||||
if (legacyMessageLen > 0) {
|
||||
legacyMessage = new byte[legacyMessageLen];
|
||||
in.readFully(legacyMessage);
|
||||
}
|
||||
long serverReceivedTimestamp = 0;
|
||||
String uuid = null;
|
||||
if (version >= 2) {
|
||||
serverReceivedTimestamp = in.readLong();
|
||||
uuid = in.readUTF();
|
||||
if ("".equals(uuid)) {
|
||||
uuid = null;
|
||||
}
|
||||
}
|
||||
long serverDeliveredTimestamp = 0;
|
||||
if (version >= 4) {
|
||||
serverDeliveredTimestamp = in.readLong();
|
||||
}
|
||||
Optional<SignalServiceAddress> addressOptional = sourceUuid == null && source.isEmpty()
|
||||
? Optional.absent()
|
||||
: Optional.of(new SignalServiceAddress(sourceUuid, source));
|
||||
return new SignalServiceEnvelope(type,
|
||||
addressOptional,
|
||||
sourceDevice,
|
||||
timestamp,
|
||||
legacyMessage,
|
||||
content,
|
||||
serverReceivedTimestamp,
|
||||
serverDeliveredTimestamp,
|
||||
uuid);
|
||||
}
|
||||
}
|
||||
|
||||
public static void storeEnvelope(SignalServiceEnvelope envelope, File file) throws IOException {
|
||||
try (var f = new FileOutputStream(file)) {
|
||||
try (var out = new DataOutputStream(f)) {
|
||||
out.writeInt(4); // version
|
||||
out.writeInt(envelope.getType());
|
||||
out.writeUTF(envelope.getSourceE164().isPresent() ? envelope.getSourceE164().get() : "");
|
||||
out.writeUTF(envelope.getSourceUuid().isPresent() ? envelope.getSourceUuid().get() : "");
|
||||
out.writeInt(envelope.getSourceDevice());
|
||||
out.writeLong(envelope.getTimestamp());
|
||||
if (envelope.hasContent()) {
|
||||
out.writeInt(envelope.getContent().length);
|
||||
out.write(envelope.getContent());
|
||||
} else {
|
||||
out.writeInt(0);
|
||||
}
|
||||
if (envelope.hasLegacyMessage()) {
|
||||
out.writeInt(envelope.getLegacyMessage().length);
|
||||
out.write(envelope.getLegacyMessage());
|
||||
} else {
|
||||
out.writeInt(0);
|
||||
}
|
||||
out.writeLong(envelope.getServerReceivedTimestamp());
|
||||
var uuid = envelope.getUuid();
|
||||
out.writeUTF(uuid == null ? "" : uuid);
|
||||
out.writeLong(envelope.getServerDeliveredTimestamp());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,31 @@
|
|||
package org.asamk.signal.manager.util;
|
||||
|
||||
import org.bouncycastle.crypto.generators.Argon2BytesGenerator;
|
||||
import org.bouncycastle.crypto.params.Argon2Parameters;
|
||||
import org.whispersystems.signalservice.api.KeyBackupService;
|
||||
import org.whispersystems.signalservice.api.kbs.HashedPin;
|
||||
import org.whispersystems.signalservice.internal.registrationpin.PinHasher;
|
||||
|
||||
public final class PinHashing {
|
||||
|
||||
private PinHashing() {
|
||||
}
|
||||
|
||||
public static HashedPin hashPin(String pin, KeyBackupService.HashSession hashSession) {
|
||||
final var params = new Argon2Parameters.Builder(Argon2Parameters.ARGON2_id).withParallelism(1)
|
||||
.withIterations(32)
|
||||
.withVersion(Argon2Parameters.ARGON2_VERSION_13)
|
||||
.withMemoryAsKB(16 * 1024)
|
||||
.withSalt(hashSession.hashSalt())
|
||||
.build();
|
||||
|
||||
final var generator = new Argon2BytesGenerator();
|
||||
generator.init(params);
|
||||
|
||||
return PinHasher.hashPin(PinHasher.normalize(pin), password -> {
|
||||
var output = new byte[64];
|
||||
generator.generateBytes(password, output);
|
||||
return output;
|
||||
});
|
||||
}
|
||||
}
|
|
@ -0,0 +1,54 @@
|
|||
package org.asamk.signal.manager.util;
|
||||
|
||||
import org.asamk.signal.manager.storage.profiles.SignalProfile;
|
||||
import org.signal.zkgroup.profiles.ProfileKey;
|
||||
import org.whispersystems.signalservice.api.crypto.InvalidCiphertextException;
|
||||
import org.whispersystems.signalservice.api.crypto.ProfileCipher;
|
||||
import org.whispersystems.signalservice.api.profiles.SignalServiceProfile;
|
||||
|
||||
import java.util.Base64;
|
||||
|
||||
public class ProfileUtils {
|
||||
|
||||
public static SignalProfile decryptProfile(
|
||||
final ProfileKey profileKey, final SignalServiceProfile encryptedProfile
|
||||
) {
|
||||
var profileCipher = new ProfileCipher(profileKey);
|
||||
try {
|
||||
var name = decryptName(encryptedProfile.getName(), profileCipher);
|
||||
var about = decryptName(encryptedProfile.getAbout(), profileCipher);
|
||||
var aboutEmoji = decryptName(encryptedProfile.getAboutEmoji(), profileCipher);
|
||||
String unidentifiedAccess;
|
||||
try {
|
||||
unidentifiedAccess = encryptedProfile.getUnidentifiedAccess() == null
|
||||
|| !profileCipher.verifyUnidentifiedAccess(Base64.getDecoder()
|
||||
.decode(encryptedProfile.getUnidentifiedAccess()))
|
||||
? null
|
||||
: encryptedProfile.getUnidentifiedAccess();
|
||||
} catch (IllegalArgumentException e) {
|
||||
unidentifiedAccess = null;
|
||||
}
|
||||
return new SignalProfile(encryptedProfile.getIdentityKey(),
|
||||
name,
|
||||
about,
|
||||
aboutEmoji,
|
||||
unidentifiedAccess,
|
||||
encryptedProfile.isUnrestrictedUnidentifiedAccess(),
|
||||
encryptedProfile.getCapabilities());
|
||||
} catch (InvalidCiphertextException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static String decryptName(
|
||||
final String encryptedName, final ProfileCipher profileCipher
|
||||
) throws InvalidCiphertextException {
|
||||
try {
|
||||
return encryptedName == null
|
||||
? null
|
||||
: new String(profileCipher.decryptName(Base64.getDecoder().decode(encryptedName)));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,110 @@
|
|||
package org.asamk.signal.manager.util;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import org.asamk.signal.manager.JsonStickerPack;
|
||||
import org.asamk.signal.manager.StickerPackInvalidException;
|
||||
import org.whispersystems.libsignal.util.Pair;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceStickerManifestUpload;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.zip.ZipFile;
|
||||
|
||||
public class StickerUtils {
|
||||
|
||||
public static SignalServiceStickerManifestUpload getSignalServiceStickerManifestUpload(
|
||||
final File file
|
||||
) throws IOException, StickerPackInvalidException {
|
||||
ZipFile zip = null;
|
||||
String rootPath = null;
|
||||
|
||||
if (file.getName().endsWith(".zip")) {
|
||||
zip = new ZipFile(file);
|
||||
} else if (file.getName().equals("manifest.json")) {
|
||||
rootPath = file.getParent();
|
||||
} else {
|
||||
throw new StickerPackInvalidException("Could not find manifest.json");
|
||||
}
|
||||
|
||||
var pack = parseStickerPack(rootPath, zip);
|
||||
|
||||
if (pack.stickers == null) {
|
||||
throw new StickerPackInvalidException("Must set a 'stickers' field.");
|
||||
}
|
||||
|
||||
if (pack.stickers.isEmpty()) {
|
||||
throw new StickerPackInvalidException("Must include stickers.");
|
||||
}
|
||||
|
||||
var stickers = new ArrayList<SignalServiceStickerManifestUpload.StickerInfo>(pack.stickers.size());
|
||||
for (var sticker : pack.stickers) {
|
||||
if (sticker.file == null) {
|
||||
throw new StickerPackInvalidException("Must set a 'file' field on each sticker.");
|
||||
}
|
||||
|
||||
Pair<InputStream, Long> data;
|
||||
try {
|
||||
data = getInputStreamAndLength(rootPath, zip, sticker.file);
|
||||
} catch (IOException ignored) {
|
||||
throw new StickerPackInvalidException("Could not find find " + sticker.file);
|
||||
}
|
||||
|
||||
var contentType = Utils.getFileMimeType(new File(sticker.file), null);
|
||||
var stickerInfo = new SignalServiceStickerManifestUpload.StickerInfo(data.first(),
|
||||
data.second(),
|
||||
Optional.fromNullable(sticker.emoji).or(""),
|
||||
contentType);
|
||||
stickers.add(stickerInfo);
|
||||
}
|
||||
|
||||
SignalServiceStickerManifestUpload.StickerInfo cover = null;
|
||||
if (pack.cover != null) {
|
||||
if (pack.cover.file == null) {
|
||||
throw new StickerPackInvalidException("Must set a 'file' field on the cover.");
|
||||
}
|
||||
|
||||
Pair<InputStream, Long> data;
|
||||
try {
|
||||
data = getInputStreamAndLength(rootPath, zip, pack.cover.file);
|
||||
} catch (IOException ignored) {
|
||||
throw new StickerPackInvalidException("Could not find find " + pack.cover.file);
|
||||
}
|
||||
|
||||
var contentType = Utils.getFileMimeType(new File(pack.cover.file), null);
|
||||
cover = new SignalServiceStickerManifestUpload.StickerInfo(data.first(),
|
||||
data.second(),
|
||||
Optional.fromNullable(pack.cover.emoji).or(""),
|
||||
contentType);
|
||||
}
|
||||
|
||||
return new SignalServiceStickerManifestUpload(pack.title, pack.author, cover, stickers);
|
||||
}
|
||||
|
||||
private static JsonStickerPack parseStickerPack(String rootPath, ZipFile zip) throws IOException {
|
||||
InputStream inputStream;
|
||||
if (zip != null) {
|
||||
inputStream = zip.getInputStream(zip.getEntry("manifest.json"));
|
||||
} else {
|
||||
inputStream = new FileInputStream((new File(rootPath, "manifest.json")));
|
||||
}
|
||||
return new ObjectMapper().readValue(inputStream, JsonStickerPack.class);
|
||||
}
|
||||
|
||||
private static Pair<InputStream, Long> getInputStreamAndLength(
|
||||
final String rootPath, final ZipFile zip, final String subfile
|
||||
) throws IOException {
|
||||
if (zip != null) {
|
||||
final var entry = zip.getEntry(subfile);
|
||||
return new Pair<>(zip.getInputStream(entry), entry.getSize());
|
||||
} else {
|
||||
final var file = new File(rootPath, subfile);
|
||||
return new Pair<>(new FileInputStream(file), file.length());
|
||||
}
|
||||
}
|
||||
|
||||
}
|
93
lib/src/main/java/org/asamk/signal/manager/util/Utils.java
Normal file
93
lib/src/main/java/org/asamk/signal/manager/util/Utils.java
Normal file
|
@ -0,0 +1,93 @@
|
|||
package org.asamk.signal.manager.util;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
|
||||
import org.whispersystems.libsignal.IdentityKey;
|
||||
import org.whispersystems.libsignal.fingerprint.NumericFingerprintGenerator;
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
|
||||
import org.whispersystems.signalservice.api.util.StreamDetails;
|
||||
import org.whispersystems.signalservice.api.util.UuidUtil;
|
||||
|
||||
import java.io.BufferedInputStream;
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.InvalidObjectException;
|
||||
import java.net.URLConnection;
|
||||
import java.nio.file.Files;
|
||||
|
||||
public class Utils {
|
||||
|
||||
public static String getFileMimeType(File file, String defaultMimeType) throws IOException {
|
||||
var mime = Files.probeContentType(file.toPath());
|
||||
if (mime == null) {
|
||||
try (InputStream bufferedStream = new BufferedInputStream(new FileInputStream(file))) {
|
||||
mime = URLConnection.guessContentTypeFromStream(bufferedStream);
|
||||
}
|
||||
}
|
||||
if (mime == null) {
|
||||
return defaultMimeType;
|
||||
}
|
||||
return mime;
|
||||
}
|
||||
|
||||
public static StreamDetails createStreamDetailsFromFile(File file) throws IOException {
|
||||
InputStream stream = new FileInputStream(file);
|
||||
final var size = file.length();
|
||||
final var mime = getFileMimeType(file, "application/octet-stream");
|
||||
return new StreamDetails(stream, mime, size);
|
||||
}
|
||||
|
||||
public static String computeSafetyNumber(
|
||||
boolean isUuidCapable,
|
||||
SignalServiceAddress ownAddress,
|
||||
IdentityKey ownIdentityKey,
|
||||
SignalServiceAddress theirAddress,
|
||||
IdentityKey theirIdentityKey
|
||||
) {
|
||||
int version;
|
||||
byte[] ownId;
|
||||
byte[] theirId;
|
||||
|
||||
if (isUuidCapable && ownAddress.getUuid().isPresent() && theirAddress.getUuid().isPresent()) {
|
||||
// Version 2: UUID user
|
||||
version = 2;
|
||||
ownId = UuidUtil.toByteArray(ownAddress.getUuid().get());
|
||||
theirId = UuidUtil.toByteArray(theirAddress.getUuid().get());
|
||||
} else {
|
||||
// Version 1: E164 user
|
||||
version = 1;
|
||||
if (!ownAddress.getNumber().isPresent() || !theirAddress.getNumber().isPresent()) {
|
||||
return "INVALID ID";
|
||||
}
|
||||
ownId = ownAddress.getNumber().get().getBytes();
|
||||
theirId = theirAddress.getNumber().get().getBytes();
|
||||
}
|
||||
|
||||
var fingerprint = new NumericFingerprintGenerator(5200).createFor(version,
|
||||
ownId,
|
||||
ownIdentityKey,
|
||||
theirId,
|
||||
theirIdentityKey);
|
||||
return fingerprint.getDisplayableFingerprint().getDisplayText();
|
||||
}
|
||||
|
||||
public static SignalServiceAddress getSignalServiceAddressFromIdentifier(final String identifier) {
|
||||
if (UuidUtil.isUuid(identifier)) {
|
||||
return new SignalServiceAddress(UuidUtil.parseOrNull(identifier), null);
|
||||
} else {
|
||||
return new SignalServiceAddress(null, identifier);
|
||||
}
|
||||
}
|
||||
|
||||
public static JsonNode getNotNullNode(JsonNode parent, String name) throws InvalidObjectException {
|
||||
var node = parent.get(name);
|
||||
if (node == null || node.isNull()) {
|
||||
throw new InvalidObjectException(String.format("Incorrect file format: expected parameter %s not found ",
|
||||
name));
|
||||
}
|
||||
|
||||
return node;
|
||||
}
|
||||
}
|
BIN
lib/src/main/resources/org/asamk/signal/manager/config/ias.store
Normal file
BIN
lib/src/main/resources/org/asamk/signal/manager/config/ias.store
Normal file
Binary file not shown.
Binary file not shown.
259
man/signal-cli-dbus.5.adoc
Executable file
259
man/signal-cli-dbus.5.adoc
Executable file
|
@ -0,0 +1,259 @@
|
|||
/////
|
||||
vim:set ts=4 sw=4 tw=82 noet:
|
||||
/////
|
||||
:quotes.~:
|
||||
|
||||
= signal-cli-dbus (5)
|
||||
|
||||
== Name
|
||||
|
||||
DBus API for signal-cli - A commandline and dbus interface for the Signal messenger
|
||||
|
||||
== Synopsis
|
||||
|
||||
*signal-cli* [--verbose] [--config CONFIG] [-u USERNAME] [-o {plain-text,json}] daemon [--system]
|
||||
|
||||
*dbus-send* [--system | --session] [--print-reply] --type=method_call --dest="org.asamk.Signal" /org/asamk/Signal[/_<phonenum>] org.asamk.Signal.<method> [string:<string argument>] [array:<type>:<array argument>]
|
||||
|
||||
Note: when daemon was started without explicit `-u USERNAME`, the `dbus-send` command requires adding the phone number in `/org/asamk/Signal/_<phonenum>`.
|
||||
|
||||
== Description
|
||||
|
||||
See signal-cli (1) for details on the application.
|
||||
|
||||
This documentation handles the supported methods when running signal-cli as a DBus daemon.
|
||||
|
||||
The method are described as follows:
|
||||
|
||||
method(arg1<type>, arg2<type>, ...) -> return<type>
|
||||
|
||||
Where <type> is according to DBus specification:
|
||||
|
||||
* <s> : String
|
||||
* <ay> : Byte Array
|
||||
* <aay> : Array of Byte Arrays
|
||||
* <as> : String Array
|
||||
* <b> : Boolean (0|1)
|
||||
* <x> : Signed 64 bit integer
|
||||
* <> : no return value
|
||||
|
||||
Exceptions are the names of the Java Exceptions returned in the body field. They typically contain an additional message with details. All Exceptions begin with "org.asamk.Signal.Error." which is omitted here for better readability.
|
||||
|
||||
Phone numbers always have the format +<countrycode><regional number>
|
||||
|
||||
== Methods
|
||||
|
||||
updateGroup(groupId<ay>, newName<s>, members<as>, avatar<s>) -> groupId<ay>::
|
||||
* groupId : Byte array representing the internal group identifier
|
||||
* newName : New name of group (empty if unchanged)
|
||||
* members : String array of new members to be invited to group
|
||||
* avatar : Filename of avatar picture to be set for group (empty if none)
|
||||
|
||||
Exceptions: AttachmentInvalid, Failure, InvalidNumber, GroupNotFound
|
||||
|
||||
updateProfile(newName<s>, about <s>, aboutEmoji <s>, avatar<s>, remove<b>) -> <>::
|
||||
* newName : New name for your own profile (empty if unchanged)
|
||||
* about : About message for profile (empty if unchanged)
|
||||
* aboutEmoji : Emoji for profile (empty if unchanged)
|
||||
* avatar : Filename of avatar picture for profile (empty if unchanged)
|
||||
* remove : Set to 1 if the existing avatar picture should be removed
|
||||
|
||||
Exceptions: Failure
|
||||
|
||||
setContactBlocked(number<s>, block<b>) -> <>::
|
||||
* number : Phone number affected by method
|
||||
* block : 0=remove block , 1=blocked
|
||||
|
||||
Messages from blocked numbers will no longer be forwarded via DBus.
|
||||
|
||||
Exceptions: InvalidNumber
|
||||
|
||||
setGroupBlocked(groupId<ay>, block<b>) -> <>::
|
||||
* groupId : Byte array representing the internal group identifier
|
||||
* block : 0=remove block , 1=blocked
|
||||
|
||||
Messages from blocked groups will no longer be forwarded via DBus.
|
||||
|
||||
Exceptions: GroupNotFound
|
||||
|
||||
joinGroup(inviteURI<s>) -> <>::
|
||||
* inviteURI : String starting with https://signal.group which is generated when you share a group link via Signal App
|
||||
|
||||
Exceptions: Failure
|
||||
|
||||
quitGroup(groupId<ay>) -> <>::
|
||||
* groupId : Byte array representing the internal group identifier
|
||||
|
||||
Note that quitting a group will not remove the group from the getGroupIds command, but set it inactive which can be tested with isMember()
|
||||
|
||||
Exceptions: GroupNotFound, Failure
|
||||
|
||||
isMember(groupId<ay>) -> active<b>::
|
||||
* groupId : Byte array representing the internal group identifier
|
||||
|
||||
Note that this method does not raise an Exception for a non-existing/unknown group but will simply return 0 (false)
|
||||
|
||||
sendEndSessionMessage(recipients<as>) -> <>::
|
||||
* recipients : Array of phone numbers
|
||||
|
||||
Exceptions: Failure, InvalidNumber, UntrustedIdentity
|
||||
|
||||
sendGroupMessage(message<s>, attachments<as>, groupId<ay>) -> timestamp<x>::
|
||||
* message : Text to send (can be UTF8)
|
||||
* attachments : String array of filenames to send as attachments (passed as filename, so need to be readable by the user signal-cli is running under)
|
||||
* groupId : Byte array representing the internal group identifier
|
||||
* timestamp : Can be used to identify the corresponding signal reply
|
||||
|
||||
Exceptions: GroupNotFound, Failure, AttachmentInvalid
|
||||
|
||||
sendNoteToSelfMessage(message<s>, attachments<as>) -> timestamp<x>::
|
||||
* message : Text to send (can be UTF8)
|
||||
* attachments : String array of filenames to send as attachments (passed as filename, so need to be readable by the user signal-cli is running under)
|
||||
* timestamp : Can be used to identify the corresponding signal reply
|
||||
|
||||
Exceptions: Failure, AttachmentInvalid
|
||||
|
||||
sendMessage(message<s>, attachments<as>, recipient<s>) -> timestamp<x>::
|
||||
sendMessage(message<s>, attachments<as>, recipients<as>) -> timestamp<x>::
|
||||
* message : Text to send (can be UTF8)
|
||||
* attachments : String array of filenames to send as attachments (passed as filename, so need to be readable by the user signal-cli is running under)
|
||||
* recipient : Phone number of a single recipient
|
||||
* recipients : Array of phone numbers
|
||||
* timestamp : Can be used to identify the corresponding signal reply
|
||||
|
||||
Depending on the type of the recipient field this sends a message to one or multiple recipients.
|
||||
|
||||
Exceptions: AttachmentInvalid, Failure, InvalidNumber, UntrustedIdentity
|
||||
|
||||
sendGroupMessageReaction(emoji<s>, remove<b>, targetAuthor<s>, targetSentTimestamp<x>, groupId<ay>) -> timestamp<x>::
|
||||
* emoji : Unicode grapheme cluster of the emoji
|
||||
* remove : Boolean, whether a previously sent reaction (emoji) should be removed
|
||||
* targetAuthor : String with the phone number of the author of the message to which to react
|
||||
* targetSentTimestamp : Long representing timestamp of the message to which to react
|
||||
* groupId : Byte array with base64 encoded group identifier
|
||||
* timestamp : Long, can be used to identify the corresponding signal reply
|
||||
|
||||
Exceptions: Failure, InvalidNumber, GroupNotFound
|
||||
|
||||
sendMessageReaction(emoji<s>, remove<b>, targetAuthor<s>, targetSentTimestamp<x>, recipient<s>) -> timestamp<x>::
|
||||
sendMessageReaction(emoji<s>, remove<b>, targetAuthor<s>, targetSentTimestamp<x>, recipients<as>) -> timestamp<x>::
|
||||
* emoji : Unicode grapheme cluster of the emoji
|
||||
* remove : Boolean, whether a previously sent reaction (emoji) should be removed
|
||||
* targetAuthor : String with the phone number of the author of the message to which to react
|
||||
* targetSentTimestamp : Long representing timestamp of the message to which to react
|
||||
* recipient : String with the phone number of a single recipient
|
||||
* recipients : Array of strings with phone numbers, should there be more recipients
|
||||
* timestamp : Long, can be used to identify the corresponding signal reply
|
||||
|
||||
Depending on the type of the recipient(s) field this sends a reaction to one or multiple recipients.
|
||||
|
||||
Exceptions: Failure, InvalidNumber
|
||||
|
||||
sendGroupRemoteDeleteMessage(targetSentTimestamp<x>, groupId<ay>) -> timestamp<x>::
|
||||
* targetSentTimestamp : Long representing timestamp of the message to delete
|
||||
* groupId : Byte array with base64 encoded group identifier
|
||||
* timestamp : Long, can be used to identify the corresponding signal reply
|
||||
|
||||
Exceptions: Failure, GroupNotFound
|
||||
|
||||
sendRemoteDeleteMessage(targetSentTimestamp<x>, recipient<s>) -> timestamp<x>::
|
||||
sendRemoteDeleteMessage(targetSentTimestamp<x>, recipients<as>) -> timestamp<x>::
|
||||
* targetSentTimestamp : Long representing timestamp of the message to delete
|
||||
* recipient : String with the phone number of a single recipient
|
||||
* recipients : Array of strings with phone numbers, should there be more recipients
|
||||
* timestamp : Long, can be used to identify the corresponding signal reply
|
||||
|
||||
Depending on the type of the recipient(s) field this deletes a message with one or multiple recipients.
|
||||
|
||||
Exceptions: Failure, InvalidNumber
|
||||
|
||||
getContactName(number<s>) -> name<s>::
|
||||
* number : Phone number
|
||||
* name : Contact's name in local storage (from the master device for a linked account, or the one set with setContactName); if not set, contact's profile name is used
|
||||
|
||||
setContactName(number<s>,name<>) -> <>::
|
||||
* number : Phone number
|
||||
* name : Name to be set in contacts (in local storage with signal-cli)
|
||||
|
||||
getGroupIds() -> groupList<aay>::
|
||||
groupList : Array of Byte arrays representing the internal group identifiers
|
||||
|
||||
All groups known are returned, regardless of their active or blocked status. To query that use isMember() and isGroupBlocked()
|
||||
|
||||
getGroupName(groupId<ay>) -> groupName<s>::
|
||||
groupName : The display name of the group
|
||||
groupId : Byte array representing the internal group identifier
|
||||
|
||||
Exceptions: None, if the group name is not found an empty string is returned
|
||||
|
||||
getGroupMembers(groupId<ay>) -> members<as>::
|
||||
members : String array with the phone numbers of all active members of a group
|
||||
groupId : Byte array representing the internal group identifier
|
||||
|
||||
Exceptions: None, if the group name is not found an empty array is returned
|
||||
|
||||
listNumbers() -> numbers<as>::
|
||||
numbers : String array of all known numbers
|
||||
|
||||
This is a concatenated list of all defined contacts as well of profiles known (e.g. peer group members or sender of received messages)
|
||||
|
||||
getContactNumber(name<s>) -> numbers<as>::
|
||||
* numbers : Array of phone number
|
||||
* name : Contact or profile name ("firstname lastname")
|
||||
|
||||
Searches contacts and known profiles for a given name and returns the list of all known numbers. May result in e.g. two entries if a contact and profile name is set.
|
||||
|
||||
isContactBlocked(number<s>) -> state<b>::
|
||||
* number : Phone number
|
||||
* state : 1=blocked, 0=not blocked
|
||||
|
||||
Exceptions: None, for unknown numbers 0 (false) is returned
|
||||
|
||||
isGroupBlocked(groupId<ay>) -> state<b>::
|
||||
* groupId : Byte array representing the internal group identifier
|
||||
* state : 1=blocked, 0=not blocked
|
||||
|
||||
Exceptions: None, for unknown groups 0 (false) is returned
|
||||
|
||||
version() -> version<s>::
|
||||
* version : Version string of signal-cli
|
||||
|
||||
isRegistred -> result<b>::
|
||||
* result : Currently always returns 1=true
|
||||
|
||||
== Signals
|
||||
|
||||
SyncMessageReceived (timestamp<x>, sender<s>, destination<s>, groupId<ay>,message<s>, attachments<as>)::
|
||||
The sync message is received when the user sends a message from a linked device.
|
||||
|
||||
ReceiptReceived (timestamp<x>, sender<s>)::
|
||||
* timestamp : Integer value that can be used to associate this e.g. with a sendMessage()
|
||||
* sender : Phone number of the sender
|
||||
|
||||
This signal is sent by each recipient (e.g. each group member) after the message was successfully delivered to the device
|
||||
|
||||
MessageReceived(timestamp<x>, sender<s>, groupId<ay>, message<s>, attachments<as>)::
|
||||
* timestamp : Integer value that is used by the system to send a ReceiptReceived reply
|
||||
* sender : Phone number of the sender
|
||||
* groupId : Byte array representing the internal group identifier (empty when private message)
|
||||
* message : Message text
|
||||
* attachments : String array of filenames for the attachments. These files are located in the signal-cli storage and the current user needs to have read access there
|
||||
|
||||
This signal is received whenever we get a private message or a message is posted in a group we are an active member
|
||||
|
||||
== Examples
|
||||
|
||||
Send a text message (without attachment) to a contact::
|
||||
dbus-send --print-reply --type=method_call --dest="org.asamk.Signal" /org/asamk/Signal org.asamk.Signal.sendMessage string:"Message text goes here" array:string: string:+123456789
|
||||
|
||||
Send a group message::
|
||||
dbus-send --session --print-reply --type=method_call --dest=org.asamk.Signal /org/asamk/Signal org.asamk.Signal.sendGroupMessage string:'The message goes here' array:string:'/path/to/attachmnt1','/path/to/attachmnt2' array:byte:139,22,72,247,116,32,170,104,205,164,207,21,248,77,185
|
||||
|
||||
Print the group name corresponding to a groupId; the daemon runs on system bus, and was started without an explicit `-u USERNAME`::
|
||||
dbus-send --system --print-reply --type=method_call --dest='org.asamk.Signal' /org/asamk/Signal/_1234567890 org.asamk.Signal.getGroupName array:byte:139,22,72,247,116,32,170,104,205,164,207,21,248,77,185
|
||||
|
||||
== Authors
|
||||
|
||||
Maintained by AsamK <asamk@gmx.de>, who is assisted by other open source contributors.
|
||||
For more information about signal-cli development, see
|
||||
<https://github.com/AsamK/signal-cli>.
|
|
@ -21,6 +21,9 @@ For registering you need a phone number where you can receive SMS or incoming ca
|
|||
signal-cli was primarily developed to be used on servers to notify admins of important events.
|
||||
For this use-case, it has a dbus interface, that can be used to send messages from any programming language that has dbus bindings.
|
||||
|
||||
For some functionality the Signal protocol requires that all messages have been received from the server.
|
||||
The `receive` command should be regularly executed. In daemon mode messages are continuously received.
|
||||
|
||||
== Options
|
||||
|
||||
*-h*, *--help*::
|
||||
|
@ -29,6 +32,9 @@ Show help message and quit.
|
|||
*-v*, *--version*::
|
||||
Print the version and quit.
|
||||
|
||||
*--verbose*::
|
||||
Raise log level and include lib signal logs.
|
||||
|
||||
*--config* CONFIG::
|
||||
Set the path, where to store the config.
|
||||
Make sure you have full read/write access to the given directory.
|
||||
|
@ -38,12 +44,20 @@ Make sure you have full read/write access to the given directory.
|
|||
Specify your phone number, that will be your identifier.
|
||||
The phone number must include the country calling code, i.e. the number must start with a "+" sign.
|
||||
|
||||
This flag must not be given for the `link` command.
|
||||
It is optional for the `daemon` command.
|
||||
For all other commands it is only optional if there is exactly one local user in the
|
||||
config directory.
|
||||
|
||||
*--dbus*::
|
||||
Make request via user dbus.
|
||||
|
||||
*--dbus-system*::
|
||||
Make request via system dbus.
|
||||
|
||||
*-o* OUTPUT-MODE, *--output* OUTPUT-MODE::
|
||||
Specify if you want commands to output in either "plain-text" mode or in "json". Defaults to "plain-text"
|
||||
|
||||
== Commands
|
||||
|
||||
=== register
|
||||
|
@ -97,7 +111,8 @@ Remove the registration lock pin.
|
|||
=== link
|
||||
|
||||
Link to an existing device, instead of registering a new number.
|
||||
This shows a "tsdevice:/…" URI. If you want to connect to another signal-cli instance, you can just use this URI. If you want to link to an Android/iOS device, create a QR code with the URI (e.g. with qrencode) and scan that in the Signal app.
|
||||
This shows a "tsdevice:/…" URI. If you want to connect to another signal-cli instance, you can just use this URI.
|
||||
If you want to link to an Android/iOS device, create a QR code with the URI (e.g. with qrencode) and scan that in the Signal app.
|
||||
|
||||
*-n* NAME, *--name* NAME::
|
||||
Optionally specify a name to describe this new device.
|
||||
|
@ -109,7 +124,8 @@ Link another device to this device.
|
|||
Only works, if this is the master device.
|
||||
|
||||
*--uri* URI::
|
||||
Specify the uri contained in the QR code shown by the new device. You will need the full uri enclosed in quotation marks, such as "tsdevice:/?uuid=....."
|
||||
Specify the uri contained in the QR code shown by the new device.
|
||||
You will need the full uri enclosed in quotation marks, such as "tsdevice:/?uuid=....."
|
||||
|
||||
=== listDevices
|
||||
|
||||
|
@ -124,6 +140,15 @@ Only works, if this is the master device.
|
|||
Specify the device you want to remove.
|
||||
Use listDevices to see the deviceIds.
|
||||
|
||||
=== getUserStatus
|
||||
|
||||
Uses a list of phone numbers to determine the statuses of those users.
|
||||
Shows if they are registered on the Signal Servers or not.
|
||||
In json mode this is outputted as a list of objects.
|
||||
|
||||
[NUMBER [NUMBER ...]]::
|
||||
One or more numbers to check.
|
||||
|
||||
=== send
|
||||
|
||||
Send a message to another user or group.
|
||||
|
@ -140,6 +165,9 @@ Specify the message, if missing, standard input is used.
|
|||
*-a* [ATTACHMENT [ATTACHMENT ...]], *--attachment* [ATTACHMENT [ATTACHMENT ...]]::
|
||||
Add one or more files as attachment.
|
||||
|
||||
*--note-to-self*::
|
||||
Send the message to self without notification.
|
||||
|
||||
*-e*, *--endsession*::
|
||||
Clear session state and send end session message.
|
||||
|
||||
|
@ -165,22 +193,36 @@ Specify the timestamp of the message to which to react.
|
|||
*-r*, *--remove*::
|
||||
Remove a reaction.
|
||||
|
||||
=== remoteDelete
|
||||
|
||||
Remotely delete a previously sent message.
|
||||
|
||||
RECIPIENT::
|
||||
Specify the recipients’ phone number.
|
||||
|
||||
*-g* GROUP, *--group* GROUP::
|
||||
Specify the recipient group ID in base64 encoding.
|
||||
|
||||
*-t* TIMESTAMP, *--target-timestamp* TIMESTAMP::
|
||||
Specify the timestamp of the message to delete.
|
||||
|
||||
=== receive
|
||||
|
||||
Query the server for new messages.
|
||||
New messages are printed on standardoutput and attachments are downloaded to the config directory.
|
||||
New messages are printed on standard output and attachments are downloaded to the config directory.
|
||||
In json mode this is outputted as one json object per line.
|
||||
|
||||
*-t* TIMEOUT, *--timeout* TIMEOUT::
|
||||
Number of seconds to wait for new messages (negative values disable timeout).
|
||||
Default is 5 seconds.
|
||||
*--ignore-attachments*::
|
||||
Don’t download attachments of received messages.
|
||||
*--json*::
|
||||
Output received messages in json format, one object per line.
|
||||
|
||||
=== joinGroup
|
||||
|
||||
Join a group via an invitation link.
|
||||
To be able to join a v2 group the account needs to have a profile (can be created
|
||||
with the `updateProfile` command)
|
||||
|
||||
*--uri*::
|
||||
The invitation link URI (starts with `https://signal.group/#`)
|
||||
|
@ -189,6 +231,8 @@ The invitation link URI (starts with `https://signal.group/#`)
|
|||
|
||||
Create or update a group.
|
||||
If the user is a pending member, this command will accept the group invitation.
|
||||
To be able to join or create a v2 group the account needs to have a profile (can
|
||||
be created with the `updateProfile` command)
|
||||
|
||||
*-g* GROUP, *--group* GROUP::
|
||||
Specify the recipient group ID in base64 encoding.
|
||||
|
@ -213,10 +257,11 @@ Specify the recipient group ID in base64 encoding.
|
|||
|
||||
=== listGroups
|
||||
|
||||
Show a list of known groups.
|
||||
Show a list of known groups and related information.
|
||||
In json mode this is outputted as an list of objects and is always in detailed mode.
|
||||
|
||||
*-d*, *--detailed*::
|
||||
Include the list of members of each group.
|
||||
Include the list of members of each group and the group invite link.
|
||||
|
||||
=== listIdentities
|
||||
|
||||
|
@ -328,6 +373,8 @@ The path of the manifest.json or a zip file containing the sticker pack you wish
|
|||
=== daemon
|
||||
|
||||
signal-cli can run in daemon mode and provides an experimental dbus interface.
|
||||
If no `-u` username is given, all local users will be exported as separate dbus
|
||||
objects under the same bus name.
|
||||
|
||||
*--system*::
|
||||
Use DBus system bus instead of user bus.
|
||||
|
@ -366,6 +413,12 @@ signal-cli -u USERNAME trust -v SAFETY_NUMBER NUMBER
|
|||
Trust new key, without having verified it. Only use this if you don't care about security::
|
||||
signal-cli -u USERNAME trust -a NUMBER
|
||||
|
||||
== Exit codes
|
||||
* *1*: Error is probably caused and fixable by the user
|
||||
* *2*: Some unexpected error
|
||||
* *3*: Server or IO error
|
||||
* *4*: Sending failed due to untrusted key
|
||||
|
||||
== Files
|
||||
|
||||
The password and cryptographic keys are created when registering and stored in the current users home directory, the directory can be changed with *--config*:
|
||||
|
|
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