diff --git a/.gitignore b/.gitignore index 3dc9875b..8fa9c8bd 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ local.properties .project .settings/ out/ +.DS_Store diff --git a/CHANGELOG.md b/CHANGELOG.md index 38907d6f..ecaecbd5 100644 --- a/CHANGELOG.md +++ b/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` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..c0afee3f --- /dev/null +++ b/CONTRIBUTING.md @@ -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 diff --git a/FUNDING.yml b/FUNDING.yml new file mode 100644 index 00000000..d109a964 --- /dev/null +++ b/FUNDING.yml @@ -0,0 +1 @@ +liberapay: asamk diff --git a/README.md b/README.md index b0e1b8a3..15714b83 100644 --- a/README.md +++ b/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. diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 00000000..55d95145 --- /dev/null +++ b/build.gradle.kts @@ -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 { + options.encoding = "UTF-8" +} + +tasks.withType { + manifest { + attributes( + "Implementation-Title" to project.name, + "Implementation-Version" to project.version, + "Main-Class" to application.mainClass.get() + ) + } +} + +tasks.withType { + 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 + } +} + +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()) + } + } +} diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts new file mode 100644 index 00000000..4516d475 --- /dev/null +++ b/buildSrc/build.gradle.kts @@ -0,0 +1,16 @@ +plugins { + `kotlin-dsl` +} + +repositories { + mavenCentral() +} + +gradlePlugin { + plugins { + register("check-lib-versions") { + id = "check-lib-versions" + implementationClass = "CheckLibVersionsPlugin" + } + } +} diff --git a/buildSrc/src/main/kotlin/CheckLibVersionsPlugin.kt b/buildSrc/src/main/kotlin/CheckLibVersionsPlugin.kt new file mode 100644 index 00000000..701bd6e3 --- /dev/null +++ b/buildSrc/src/main/kotlin/CheckLibVersionsPlugin.kt @@ -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 { + 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") + } + } +} diff --git a/data/signal-cli.service b/data/signal-cli.service new file mode 100644 index 00000000..1e4a5a5c --- /dev/null +++ b/data/signal-cli.service @@ -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 diff --git a/data/signal-cli@.service b/data/signal-cli@.service index 4cc6e2cf..61addf3b 100644 --- a/data/signal-cli@.service +++ b/data/signal-cli@.service @@ -13,4 +13,4 @@ User=signal-cli BusName=org.asamk.Signal [Install] -WantedBy=multi-user.target +Alias=dbus-org.asamk.Signal.service diff --git a/graalvm-config-dir/jni-config.json b/graalvm-config-dir/jni-config.json new file mode 100644 index 00000000..849f5e32 --- /dev/null +++ b/graalvm-config-dir/jni-config.json @@ -0,0 +1,97 @@ +[ +{ + "name":"java.lang.ClassLoader", + "methods":[{"name":"getPlatformClassLoader","parameterTypes":[] }] +}, +{ + "name":"java.lang.IllegalStateException", + "methods":[{"name":"","parameterTypes":["java.lang.String"] }] +}, +{ + "name":"java.lang.NoSuchMethodError" +}, +{ + "name":"java.lang.UnsatisfiedLinkError", + "methods":[{"name":"","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":"","parameterTypes":["java.lang.String"] }] +}, +{ + "name":"org.whispersystems.libsignal.IdentityKey", + "methods":[ + {"name":"","parameterTypes":["byte[]"] }, + {"name":"serialize","parameterTypes":[] } + ] +}, +{ + "name":"org.whispersystems.libsignal.IdentityKeyPair", + "methods":[{"name":"serialize","parameterTypes":[] }] +}, +{ + "name":"org.whispersystems.libsignal.InvalidMessageException", + "methods":[{"name":"","parameterTypes":["java.lang.String"] }] +}, +{ + "name":"org.whispersystems.libsignal.SignalProtocolAddress", + "methods":[{"name":"","parameterTypes":["java.lang.String","int"] }] +}, +{ + "name":"org.whispersystems.libsignal.protocol.PreKeySignalMessage", + "methods":[{"name":"","parameterTypes":["long"] }] +}, +{ + "name":"org.whispersystems.libsignal.protocol.SignalMessage", + "methods":[{"name":"","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":"","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" +} +] diff --git a/graalvm-config-dir/proxy-config.json b/graalvm-config-dir/proxy-config.json new file mode 100644 index 00000000..883065a9 --- /dev/null +++ b/graalvm-config-dir/proxy-config.json @@ -0,0 +1,3 @@ +[ + ["org.freedesktop.DBus"] +] diff --git a/graalvm-config-dir/reflect-config.json b/graalvm-config-dir/reflect-config.json new file mode 100644 index 00000000..17a761a0 --- /dev/null +++ b/graalvm-config-dir/reflect-config.json @@ -0,0 +1,2018 @@ +[ +{ + "name":"byte[]", + "allDeclaredMethods":true, + "allPublicMethods":true +}, +{ + "name":"char[]" +}, +{ + "name":"com.fasterxml.jackson.databind.ext.Java7SupportImpl", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"com.google.protobuf.AbstractProtobufList", + "allDeclaredFields":true, + "allDeclaredMethods":true +}, +{ + "name":"com.google.protobuf.Internal$LongList", + "allDeclaredMethods":true +}, +{ + "name":"com.google.protobuf.Internal$ProtobufList", + "allDeclaredMethods":true +}, +{ + "name":"com.google.protobuf.LongArrayList", + "allDeclaredFields":true, + "allDeclaredMethods":true, + "allDeclaredConstructors":true +}, +{ + "name":"com.google.protobuf.PrimitiveNonBoxingCollection", + "allDeclaredMethods":true +}, +{ + "name":"com.kenai.jffi.Invoker", + "methods":[ + {"name":"invokeI6","parameterTypes":["com.kenai.jffi.CallContext","long","int","int","int","int","int","int"] }, + {"name":"invokeL6","parameterTypes":["com.kenai.jffi.CallContext","long","long","long","long","long","long","long"] }, + {"name":"invokeN6","parameterTypes":["com.kenai.jffi.CallContext","long","long","long","long","long","long","long"] } + ] +}, +{ + "name":"com.kenai.jffi.Version", + "fields":[ + {"name":"MAJOR"}, + {"name":"MICRO"}, + {"name":"MINOR"} + ] +}, +{ + "name":"com.kenai.jffi.internal.StubLoader", + "methods":[{"name":"isLoaded","parameterTypes":[] }] +}, +{ + "name":"com.sun.crypto.provider.AESCipher$General", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"com.sun.crypto.provider.DHParameters", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"com.sun.crypto.provider.HmacCore$HmacSHA256", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"com.sun.crypto.provider.TlsKeyMaterialGenerator", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"com.sun.crypto.provider.TlsMasterSecretGenerator", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"com.sun.crypto.provider.TlsPrfGenerator$V12", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"int", + "allDeclaredMethods":true, + "allPublicMethods":true +}, +{ + "name":"int[]", + "allDeclaredMethods":true, + "allPublicMethods":true +}, +{ + "name":"java.io.Serializable", + "allDeclaredMethods":true +}, +{ + "name":"java.lang.Boolean", + "allDeclaredFields":true, + "allDeclaredMethods":true, + "allDeclaredConstructors":true +}, +{ + "name":"java.lang.Comparable", + "allDeclaredMethods":true +}, +{ + "name":"java.lang.Double", + "methods":[{"name":"valueOf","parameterTypes":["java.lang.String"] }] +}, +{ + "name":"java.lang.Integer", + "allDeclaredFields":true, + "allDeclaredMethods":true, + "allDeclaredConstructors":true +}, +{ + "name":"java.lang.Iterable", + "allDeclaredMethods":true +}, +{ + "name":"java.lang.Long", + "allDeclaredFields":true, + "allDeclaredMethods":true, + "allDeclaredConstructors":true +}, +{ + "name":"java.lang.Number", + "allDeclaredFields":true, + "allDeclaredMethods":true +}, +{ + "name":"java.lang.String", + "allPublicMethods":true +}, +{ + "name":"java.lang.reflect.Method", + "methods":[{"name":"isDefault","parameterTypes":[] }] +}, +{ + "name":"java.nio.Buffer", + "allDeclaredMethods":true, + "fields":[{"name":"address", "allowUnsafeAccess":true}] +}, +{ + "name":"java.nio.ByteBuffer", + "allDeclaredMethods":true, + "allPublicMethods":true +}, +{ + "name":"java.security.KeyStoreSpi" +}, +{ + "name":"java.security.SecureRandomParameters" +}, +{ + "name":"java.security.cert.PKIXRevocationChecker" +}, +{ + "name":"java.security.interfaces.ECPrivateKey" +}, +{ + "name":"java.security.interfaces.ECPublicKey" +}, +{ + "name":"java.security.interfaces.RSAPrivateKey" +}, +{ + "name":"java.security.interfaces.RSAPublicKey" +}, +{ + "name":"java.util.AbstractCollection", + "allDeclaredFields":true, + "allDeclaredMethods":true +}, +{ + "name":"java.util.AbstractList", + "allDeclaredFields":true, + "allDeclaredMethods":true +}, +{ + "name":"java.util.ArrayList", + "allDeclaredMethods":true, + "allDeclaredConstructors":true +}, +{ + "name":"java.util.Collection", + "allDeclaredMethods":true +}, +{ + "name":"java.util.LinkedHashMap", + "allDeclaredMethods":true, + "allDeclaredConstructors":true +}, +{ + "name":"java.util.List", + "allDeclaredMethods":true +}, +{ + "name":"java.util.Locale", + "methods":[{"name":"getUnicodeLocaleType","parameterTypes":["java.lang.String"] }] +}, +{ + "name":"java.util.RandomAccess", + "allDeclaredMethods":true +}, +{ + "name":"java.util.UUID", + "allDeclaredFields":true, + "allDeclaredMethods":true, + "allDeclaredConstructors":true +}, +{ + "name":"jnr.constants.platform.linux.ProtocolFamily" +}, +{ + "name":"jnr.constants.platform.linux.Shutdown" +}, +{ + "name":"jnr.constants.platform.linux.Sock" +}, +{ + "name":"jnr.constants.platform.linux.SocketLevel" +}, +{ + "name":"jnr.constants.platform.linux.SocketOption" +}, +{ + "name":"jnr.enxio.channels.Native$LibC", + "allPublicMethods":true +}, +{ + "name":"jnr.ffi.Pointer", + "allDeclaredMethods":true, + "allPublicMethods":true +}, +{ + "name":"jnr.ffi.StructLayout$gid_t", + "methods":[{"name":"","parameterTypes":["jnr.ffi.StructLayout"] }] +}, +{ + "name":"jnr.ffi.byref.IntByReference", + "allPublicMethods":true +}, +{ + "name":"jnr.ffi.provider.converters.ByReferenceParameterConverter", + "methods":[{"name":"nativeType","parameterTypes":[] }] +}, +{ + "name":"jnr.ffi.provider.converters.ByReferenceParameterConverter$Out", + "allPublicMethods":true +}, +{ + "name":"jnr.ffi.provider.converters.StringResultConverter", + "allPublicMethods":true +}, +{ + "name":"jnr.ffi.provider.converters.StructByReferenceToNativeConverter", + "allPublicMethods":true +}, +{ + "name":"jnr.ffi.provider.jffi.BufferParameterStrategy", + "methods":[{"name":"address","parameterTypes":["java.nio.Buffer"] }] +}, +{ + "name":"jnr.ffi.provider.jffi.PointerParameterStrategy", + "methods":[{"name":"address","parameterTypes":["jnr.ffi.Pointer"] }] +}, +{ + "name":"jnr.ffi.provider.jffi.Provider", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"jnr.ffi.provider.jffi.platform.x86_64.linux.TypeAliases", + "fields":[{"name":"ALIASES"}] +}, +{ + "name":"jnr.posix.Timeval", + "allPublicMethods":true +}, +{ + "name":"jnr.unixsocket.Native$LibC", + "allPublicMethods":true +}, +{ + "name":"jnr.unixsocket.SockAddrUnix", + "allPublicMethods":true +}, +{ + "name":"long", + "allDeclaredMethods":true, + "allPublicMethods":true +}, +{ + "name":"long[]" +}, +{ + "name":"org.asamk.Signal", + "allDeclaredMethods":true, + "allDeclaredClasses":true +}, +{ + "name":"org.asamk.Signal$MessageReceived", + "allDeclaredConstructors":true, + "allPublicConstructors":true +}, +{ + "name":"org.asamk.Signal$ReceiptReceived", + "allDeclaredConstructors":true, + "allPublicConstructors":true +}, +{ + "name":"org.asamk.Signal$SyncMessageReceived", + "allDeclaredConstructors":true, + "allPublicConstructors":true +}, +{ + "name":"org.asamk.signal.json.JsonAttachment", + "allDeclaredFields":true, + "allDeclaredMethods":true, + "allDeclaredConstructors":true +}, +{ + "name":"org.asamk.signal.json.JsonDataMessage", + "allDeclaredFields":true, + "allDeclaredMethods":true, + "allDeclaredConstructors":true +}, +{ + "name":"org.asamk.signal.json.JsonError", + "allDeclaredFields":true, + "allDeclaredMethods":true, + "allDeclaredConstructors":true +}, +{ + "name":"org.asamk.signal.json.JsonMessageEnvelope", + "allDeclaredFields":true, + "allDeclaredMethods":true, + "allDeclaredConstructors":true +}, +{ + "name":"org.asamk.signal.json.JsonReaction", + "allDeclaredFields":true, + "allDeclaredMethods":true, + "allDeclaredConstructors":true +}, +{ + "name":"org.asamk.signal.json.JsonReceiptMessage", + "allDeclaredFields":true, + "allDeclaredMethods":true, + "allDeclaredConstructors":true +}, +{ + "name":"org.asamk.signal.json.JsonRemoteDelete", + "allDeclaredFields":true, + "allDeclaredMethods":true, + "allDeclaredConstructors":true +}, +{ + "name":"org.asamk.signal.json.JsonSticker", + "allDeclaredFields":true, + "allDeclaredMethods":true, + "allDeclaredConstructors":true +}, +{ + "name":"org.asamk.signal.json.JsonTypingMessage", + "allDeclaredFields":true, + "allDeclaredMethods":true, + "allDeclaredConstructors":true +}, +{ + "name":"org.asamk.signal.manager.storage.contacts.ContactInfo", + "allDeclaredFields":true, + "allDeclaredMethods":true, + "allDeclaredConstructors":true +}, +{ + "name":"org.asamk.signal.manager.storage.contacts.JsonContactsStore", + "allDeclaredFields":true, + "allDeclaredMethods":true, + "allDeclaredConstructors":true +}, +{ + "name":"org.asamk.signal.manager.storage.groups.GroupInfo", + "allDeclaredFields":true, + "allDeclaredMethods":true +}, +{ + "name":"org.asamk.signal.manager.storage.groups.GroupInfoV1", + "allDeclaredFields":true, + "allDeclaredMethods":true, + "allDeclaredConstructors":true +}, +{ + "name":"org.asamk.signal.manager.storage.groups.GroupInfoV1$JsonSignalServiceAddress", + "allDeclaredFields":true, + "allDeclaredMethods":true, + "allDeclaredConstructors":true +}, +{ + "name":"org.asamk.signal.manager.storage.groups.GroupInfoV1$MembersDeserializer", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.asamk.signal.manager.storage.groups.GroupInfoV1$MembersSerializer", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.asamk.signal.manager.storage.groups.JsonGroupStore", + "allDeclaredFields":true, + "allDeclaredMethods":true, + "allDeclaredConstructors":true, + "fields":[{"name":"groups", "allowWrite":true}] +}, +{ + "name":"org.asamk.signal.manager.storage.groups.JsonGroupStore$GroupsDeserializer", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.asamk.signal.manager.storage.groups.JsonGroupStore$GroupsSerializer", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.asamk.signal.manager.storage.profiles.ProfileStore", + "allDeclaredFields":true, + "allDeclaredMethods":true, + "allDeclaredConstructors":true, + "fields":[{"name":"profiles", "allowWrite":true}] +}, +{ + "name":"org.asamk.signal.manager.storage.profiles.ProfileStore$ProfileStoreDeserializer", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.asamk.signal.manager.storage.profiles.ProfileStore$ProfileStoreSerializer", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.asamk.signal.manager.storage.profiles.SignalProfile", + "allDeclaredFields":true, + "allDeclaredMethods":true, + "allDeclaredConstructors":true +}, +{ + "name":"org.asamk.signal.manager.storage.profiles.SignalProfile$Capabilities", + "allDeclaredFields":true, + "allDeclaredMethods":true, + "allDeclaredConstructors":true +}, +{ + "name":"org.asamk.signal.manager.storage.protocol.JsonIdentityKeyStore$JsonIdentityKeyStoreDeserializer", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.asamk.signal.manager.storage.protocol.JsonIdentityKeyStore$JsonIdentityKeyStoreSerializer", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.asamk.signal.manager.storage.protocol.JsonPreKeyStore$JsonPreKeyStoreDeserializer", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.asamk.signal.manager.storage.protocol.JsonPreKeyStore$JsonPreKeyStoreSerializer", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.asamk.signal.manager.storage.protocol.JsonSessionStore$JsonSessionStoreDeserializer", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.asamk.signal.manager.storage.protocol.JsonSessionStore$JsonSessionStoreSerializer", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.asamk.signal.manager.storage.protocol.JsonSignalProtocolStore", + "allDeclaredFields":true, + "allDeclaredMethods":true, + "allDeclaredConstructors":true +}, +{ + "name":"org.asamk.signal.manager.storage.protocol.JsonSignedPreKeyStore$JsonSignedPreKeyStoreDeserializer", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.asamk.signal.manager.storage.protocol.JsonSignedPreKeyStore$JsonSignedPreKeyStoreSerializer", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.asamk.signal.manager.storage.protocol.RecipientStore", + "allDeclaredFields":true, + "allDeclaredMethods":true, + "allDeclaredConstructors":true, + "fields":[{"name":"addresses", "allowWrite":true}] +}, +{ + "name":"org.asamk.signal.manager.storage.protocol.RecipientStore$RecipientStoreDeserializer", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.asamk.signal.manager.storage.protocol.RecipientStore$RecipientStoreSerializer", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.asamk.signal.manager.storage.stickers.StickerStore", + "allDeclaredFields":true, + "allDeclaredMethods":true, + "allDeclaredConstructors":true, + "fields":[{"name":"stickers", "allowWrite":true}] +}, +{ + "name":"org.asamk.signal.manager.storage.stickers.StickerStore$StickersDeserializer", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.asamk.signal.manager.storage.stickers.StickerStore$StickersSerializer", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.asamk.signal.util.SecurityProvider$DefaultRandom", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.bouncycastle.jcajce.provider.asymmetric.COMPOSITE$Mappings", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.bouncycastle.jcajce.provider.asymmetric.DH$Mappings", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.bouncycastle.jcajce.provider.asymmetric.DSA$Mappings", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.bouncycastle.jcajce.provider.asymmetric.DSTU4145$Mappings", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.bouncycastle.jcajce.provider.asymmetric.EC$Mappings", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.bouncycastle.jcajce.provider.asymmetric.ECGOST$Mappings", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.bouncycastle.jcajce.provider.asymmetric.EdEC$Mappings", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.bouncycastle.jcajce.provider.asymmetric.ElGamal$Mappings", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.bouncycastle.jcajce.provider.asymmetric.GM$Mappings", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.bouncycastle.jcajce.provider.asymmetric.GOST$Mappings", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.bouncycastle.jcajce.provider.asymmetric.IES$Mappings", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.bouncycastle.jcajce.provider.asymmetric.RSA$Mappings", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.bouncycastle.jcajce.provider.asymmetric.X509$Mappings", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.bouncycastle.jcajce.provider.asymmetric.edec.SignatureSpi$Ed25519", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.bouncycastle.jcajce.provider.asymmetric.edec.SignatureSpi$Ed448", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.bouncycastle.jcajce.provider.asymmetric.x509.CertificateFactory", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.bouncycastle.jcajce.provider.digest.Blake2b$Mappings", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.bouncycastle.jcajce.provider.digest.Blake2s$Mappings", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.bouncycastle.jcajce.provider.digest.DSTU7564$Mappings", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.bouncycastle.jcajce.provider.digest.GOST3411$Mappings", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.bouncycastle.jcajce.provider.digest.Haraka$Mappings", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.bouncycastle.jcajce.provider.digest.Keccak$Mappings", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.bouncycastle.jcajce.provider.digest.MD2$Mappings", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.bouncycastle.jcajce.provider.digest.MD4$Mappings", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.bouncycastle.jcajce.provider.digest.MD5$Mappings", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.bouncycastle.jcajce.provider.digest.RIPEMD128$Mappings", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.bouncycastle.jcajce.provider.digest.RIPEMD160$Mappings", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.bouncycastle.jcajce.provider.digest.RIPEMD256$Mappings", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.bouncycastle.jcajce.provider.digest.RIPEMD320$Mappings", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.bouncycastle.jcajce.provider.digest.SHA1$Mappings", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.bouncycastle.jcajce.provider.digest.SHA224$Mappings", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.bouncycastle.jcajce.provider.digest.SHA256$Digest", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.bouncycastle.jcajce.provider.digest.SHA256$Mappings", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.bouncycastle.jcajce.provider.digest.SHA3$Mappings", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.bouncycastle.jcajce.provider.digest.SHA384$Mappings", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.bouncycastle.jcajce.provider.digest.SHA512$Mappings", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.bouncycastle.jcajce.provider.digest.SM3$Mappings", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.bouncycastle.jcajce.provider.digest.Skein$Mappings", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.bouncycastle.jcajce.provider.digest.Tiger$Mappings", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.bouncycastle.jcajce.provider.digest.Whirlpool$Mappings", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.bouncycastle.jcajce.provider.drbg.DRBG$Mappings", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.bouncycastle.jcajce.provider.keystore.BC$Mappings", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.bouncycastle.jcajce.provider.keystore.BCFKS$Mappings", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.bouncycastle.jcajce.provider.keystore.PKCS12$Mappings", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.bouncycastle.jcajce.provider.keystore.bc.BcKeyStoreSpi$Std", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.bouncycastle.jcajce.provider.symmetric.AES$Mappings", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.bouncycastle.jcajce.provider.symmetric.ARC4$Mappings", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.bouncycastle.jcajce.provider.symmetric.ARIA$Mappings", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.bouncycastle.jcajce.provider.symmetric.Blowfish$Mappings", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.bouncycastle.jcajce.provider.symmetric.CAST5$Mappings", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.bouncycastle.jcajce.provider.symmetric.CAST6$Mappings", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.bouncycastle.jcajce.provider.symmetric.Camellia$Mappings", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.bouncycastle.jcajce.provider.symmetric.ChaCha$Mappings", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.bouncycastle.jcajce.provider.symmetric.DES$Mappings", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.bouncycastle.jcajce.provider.symmetric.DESede$Mappings", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.bouncycastle.jcajce.provider.symmetric.DSTU7624$Mappings", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.bouncycastle.jcajce.provider.symmetric.GOST28147$Mappings", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.bouncycastle.jcajce.provider.symmetric.GOST3412_2015$Mappings", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.bouncycastle.jcajce.provider.symmetric.Grain128$Mappings", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.bouncycastle.jcajce.provider.symmetric.Grainv1$Mappings", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.bouncycastle.jcajce.provider.symmetric.HC128$Mappings", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.bouncycastle.jcajce.provider.symmetric.HC256$Mappings", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.bouncycastle.jcajce.provider.symmetric.IDEA$Mappings", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.bouncycastle.jcajce.provider.symmetric.Noekeon$Mappings", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.bouncycastle.jcajce.provider.symmetric.OpenSSLPBKDF$Mappings", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.bouncycastle.jcajce.provider.symmetric.PBEPBKDF1$Mappings", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.bouncycastle.jcajce.provider.symmetric.PBEPBKDF2$Mappings", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.bouncycastle.jcajce.provider.symmetric.PBEPKCS12$Mappings", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.bouncycastle.jcajce.provider.symmetric.Poly1305$Mappings", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.bouncycastle.jcajce.provider.symmetric.RC2$Mappings", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.bouncycastle.jcajce.provider.symmetric.RC5$Mappings", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.bouncycastle.jcajce.provider.symmetric.RC6$Mappings", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.bouncycastle.jcajce.provider.symmetric.Rijndael$Mappings", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.bouncycastle.jcajce.provider.symmetric.SCRYPT$Mappings", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.bouncycastle.jcajce.provider.symmetric.SEED$Mappings", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.bouncycastle.jcajce.provider.symmetric.SM4$Mappings", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.bouncycastle.jcajce.provider.symmetric.Salsa20$Mappings", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.bouncycastle.jcajce.provider.symmetric.Serpent$Mappings", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.bouncycastle.jcajce.provider.symmetric.Shacal2$Mappings", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.bouncycastle.jcajce.provider.symmetric.SipHash$Mappings", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.bouncycastle.jcajce.provider.symmetric.SipHash128$Mappings", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.bouncycastle.jcajce.provider.symmetric.Skipjack$Mappings", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.bouncycastle.jcajce.provider.symmetric.TEA$Mappings", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.bouncycastle.jcajce.provider.symmetric.TLSKDF$Mappings", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.bouncycastle.jcajce.provider.symmetric.Threefish$Mappings", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.bouncycastle.jcajce.provider.symmetric.Twofish$Mappings", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.bouncycastle.jcajce.provider.symmetric.VMPC$Mappings", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.bouncycastle.jcajce.provider.symmetric.VMPCKSA3$Mappings", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.bouncycastle.jcajce.provider.symmetric.XSalsa20$Mappings", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.bouncycastle.jcajce.provider.symmetric.XTEA$Mappings", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.bouncycastle.jcajce.provider.symmetric.Zuc$Mappings", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.freedesktop.DBus$NameAcquired", + "allDeclaredConstructors":true +}, +{ + "name":"org.freedesktop.dbus.interfaces.Introspectable", + "allDeclaredMethods":true, + "allDeclaredClasses":true +}, +{ + "name":"org.freedesktop.dbus.interfaces.Peer", + "allDeclaredMethods":true, + "allDeclaredClasses":true +}, +{ + "name":"org.objectweb.asm.util.TraceMethodVisitor" +}, +{ + "name":"org.signal.storageservice.protos.groups.AccessControl", + "fields":[ + {"name":"addFromInviteLink_", "allowUnsafeAccess":true}, + {"name":"attributes_", "allowUnsafeAccess":true}, + {"name":"members_", "allowUnsafeAccess":true} + ] +}, +{ + "name":"org.signal.storageservice.protos.groups.Group", + "fields":[ + {"name":"accessControl_", "allowUnsafeAccess":true}, + {"name":"avatar_", "allowUnsafeAccess":true}, + {"name":"disappearingMessagesTimer_", "allowUnsafeAccess":true}, + {"name":"inviteLinkPassword_", "allowUnsafeAccess":true}, + {"name":"members_", "allowUnsafeAccess":true}, + {"name":"pendingMembers_", "allowUnsafeAccess":true}, + {"name":"publicKey_", "allowUnsafeAccess":true}, + {"name":"requestingMembers_", "allowUnsafeAccess":true}, + {"name":"revision_", "allowUnsafeAccess":true}, + {"name":"title_", "allowUnsafeAccess":true} + ] +}, +{ + "name":"org.signal.storageservice.protos.groups.GroupAttributeBlob", + "fields":[ + {"name":"contentCase_", "allowUnsafeAccess":true}, + {"name":"content_", "allowUnsafeAccess":true} + ] +}, +{ + "name":"org.signal.storageservice.protos.groups.GroupChange", + "fields":[ + {"name":"actions_", "allowUnsafeAccess":true}, + {"name":"changeEpoch_", "allowUnsafeAccess":true}, + {"name":"serverSignature_", "allowUnsafeAccess":true} + ] +}, +{ + "name":"org.signal.storageservice.protos.groups.GroupChange$Actions", + "fields":[ + {"name":"addMembers_", "allowUnsafeAccess":true}, + {"name":"addPendingMembers_", "allowUnsafeAccess":true}, + {"name":"addRequestingMembers_", "allowUnsafeAccess":true}, + {"name":"deleteMembers_", "allowUnsafeAccess":true}, + {"name":"deletePendingMembers_", "allowUnsafeAccess":true}, + {"name":"deleteRequestingMembers_", "allowUnsafeAccess":true}, + {"name":"modifyAddFromInviteLinkAccess_", "allowUnsafeAccess":true}, + {"name":"modifyAttributesAccess_", "allowUnsafeAccess":true}, + {"name":"modifyAvatar_", "allowUnsafeAccess":true}, + {"name":"modifyDisappearingMessagesTimer_", "allowUnsafeAccess":true}, + {"name":"modifyInviteLinkPassword_", "allowUnsafeAccess":true}, + {"name":"modifyMemberAccess_", "allowUnsafeAccess":true}, + {"name":"modifyMemberProfileKeys_", "allowUnsafeAccess":true}, + {"name":"modifyMemberRoles_", "allowUnsafeAccess":true}, + {"name":"modifyTitle_", "allowUnsafeAccess":true}, + {"name":"promotePendingMembers_", "allowUnsafeAccess":true}, + {"name":"promoteRequestingMembers_", "allowUnsafeAccess":true}, + {"name":"revision_", "allowUnsafeAccess":true}, + {"name":"sourceUuid_", "allowUnsafeAccess":true} + ] +}, +{ + "name":"org.signal.storageservice.protos.groups.GroupChange$Actions$AddMemberAction", + "fields":[ + {"name":"added_", "allowUnsafeAccess":true}, + {"name":"joinFromInviteLink_", "allowUnsafeAccess":true} + ] +}, +{ + "name":"org.signal.storageservice.protos.groups.GroupChange$Actions$AddPendingMemberAction", + "fields":[{"name":"added_", "allowUnsafeAccess":true}] +}, +{ + "name":"org.signal.storageservice.protos.groups.GroupChange$Actions$AddRequestingMemberAction", + "fields":[{"name":"added_", "allowUnsafeAccess":true}] +}, +{ + "name":"org.signal.storageservice.protos.groups.GroupChange$Actions$DeleteMemberAction", + "fields":[{"name":"deletedUserId_", "allowUnsafeAccess":true}] +}, +{ + "name":"org.signal.storageservice.protos.groups.GroupChange$Actions$DeletePendingMemberAction", + "fields":[{"name":"deletedUserId_", "allowUnsafeAccess":true}] +}, +{ + "name":"org.signal.storageservice.protos.groups.GroupChange$Actions$DeleteRequestingMemberAction", + "fields":[{"name":"deletedUserId_", "allowUnsafeAccess":true}] +}, +{ + "name":"org.signal.storageservice.protos.groups.GroupChange$Actions$ModifyMemberProfileKeyAction", + "fields":[{"name":"presentation_", "allowUnsafeAccess":true}] +}, +{ + "name":"org.signal.storageservice.protos.groups.GroupChange$Actions$ModifyMemberRoleAction", + "fields":[ + {"name":"role_", "allowUnsafeAccess":true}, + {"name":"userId_", "allowUnsafeAccess":true} + ] +}, +{ + "name":"org.signal.storageservice.protos.groups.GroupChange$Actions$PromotePendingMemberAction", + "fields":[{"name":"presentation_", "allowUnsafeAccess":true}] +}, +{ + "name":"org.signal.storageservice.protos.groups.GroupChange$Actions$PromoteRequestingMemberAction", + "fields":[ + {"name":"role_", "allowUnsafeAccess":true}, + {"name":"userId_", "allowUnsafeAccess":true} + ] +}, +{ + "name":"org.signal.storageservice.protos.groups.Member", + "fields":[ + {"name":"joinedAtRevision_", "allowUnsafeAccess":true}, + {"name":"presentation_", "allowUnsafeAccess":true}, + {"name":"profileKey_", "allowUnsafeAccess":true}, + {"name":"role_", "allowUnsafeAccess":true}, + {"name":"userId_", "allowUnsafeAccess":true} + ] +}, +{ + "name":"org.signal.storageservice.protos.groups.PendingMember", + "fields":[ + {"name":"addedByUserId_", "allowUnsafeAccess":true}, + {"name":"member_", "allowUnsafeAccess":true}, + {"name":"timestamp_", "allowUnsafeAccess":true} + ] +}, +{ + "name":"org.signal.storageservice.protos.groups.RequestingMember", + "fields":[ + {"name":"presentation_", "allowUnsafeAccess":true}, + {"name":"profileKey_", "allowUnsafeAccess":true}, + {"name":"timestamp_", "allowUnsafeAccess":true}, + {"name":"userId_", "allowUnsafeAccess":true} + ] +}, +{ + "name":"org.signal.storageservice.protos.groups.local.DecryptedGroup", + "fields":[ + {"name":"accessControl_", "allowUnsafeAccess":true}, + {"name":"avatar_", "allowUnsafeAccess":true}, + {"name":"disappearingMessagesTimer_", "allowUnsafeAccess":true}, + {"name":"inviteLinkPassword_", "allowUnsafeAccess":true}, + {"name":"members_", "allowUnsafeAccess":true}, + {"name":"pendingMembers_", "allowUnsafeAccess":true}, + {"name":"requestingMembers_", "allowUnsafeAccess":true}, + {"name":"revision_", "allowUnsafeAccess":true}, + {"name":"title_", "allowUnsafeAccess":true} + ] +}, +{ + "name":"org.signal.storageservice.protos.groups.local.DecryptedGroupChange", + "fields":[ + {"name":"deleteMembers_", "allowUnsafeAccess":true}, + {"name":"deletePendingMembers_", "allowUnsafeAccess":true}, + {"name":"deleteRequestingMembers_", "allowUnsafeAccess":true}, + {"name":"editor_", "allowUnsafeAccess":true}, + {"name":"modifiedProfileKeys_", "allowUnsafeAccess":true}, + {"name":"modifyMemberRoles_", "allowUnsafeAccess":true}, + {"name":"newAttributeAccess_", "allowUnsafeAccess":true}, + {"name":"newAvatar_", "allowUnsafeAccess":true}, + {"name":"newInviteLinkAccess_", "allowUnsafeAccess":true}, + {"name":"newInviteLinkPassword_", "allowUnsafeAccess":true}, + {"name":"newMemberAccess_", "allowUnsafeAccess":true}, + {"name":"newMembers_", "allowUnsafeAccess":true}, + {"name":"newPendingMembers_", "allowUnsafeAccess":true}, + {"name":"newRequestingMembers_", "allowUnsafeAccess":true}, + {"name":"newTimer_", "allowUnsafeAccess":true}, + {"name":"newTitle_", "allowUnsafeAccess":true}, + {"name":"promotePendingMembers_", "allowUnsafeAccess":true}, + {"name":"promoteRequestingMembers_", "allowUnsafeAccess":true}, + {"name":"revision_", "allowUnsafeAccess":true} + ] +}, +{ + "name":"org.signal.storageservice.protos.groups.local.DecryptedMember", + "fields":[ + {"name":"joinedAtRevision_", "allowUnsafeAccess":true}, + {"name":"profileKey_", "allowUnsafeAccess":true}, + {"name":"role_", "allowUnsafeAccess":true}, + {"name":"uuid_", "allowUnsafeAccess":true} + ] +}, +{ + "name":"org.signal.storageservice.protos.groups.local.DecryptedPendingMember", + "fields":[ + {"name":"addedByUuid_", "allowUnsafeAccess":true}, + {"name":"role_", "allowUnsafeAccess":true}, + {"name":"timestamp_", "allowUnsafeAccess":true}, + {"name":"uuidCipherText_", "allowUnsafeAccess":true}, + {"name":"uuid_", "allowUnsafeAccess":true} + ] +}, +{ + "name":"org.signal.storageservice.protos.groups.local.DecryptedRequestingMember", + "fields":[ + {"name":"profileKey_", "allowUnsafeAccess":true}, + {"name":"timestamp_", "allowUnsafeAccess":true}, + {"name":"uuid_", "allowUnsafeAccess":true} + ] +}, +{ + "name":"org.signal.storageservice.protos.groups.local.DecryptedTimer", + "fields":[{"name":"duration_", "allowUnsafeAccess":true}] +}, +{ + "name":"org.whispersystems.libsignal.state.IdentityKeyStore", + "allDeclaredMethods":true +}, +{ + "name":"org.whispersystems.libsignal.state.PreKeyStore", + "allDeclaredMethods":true +}, +{ + "name":"org.whispersystems.libsignal.state.SessionStore", + "allDeclaredMethods":true +}, +{ + "name":"org.whispersystems.libsignal.state.SignalProtocolStore", + "allDeclaredMethods":true +}, +{ + "name":"org.whispersystems.libsignal.state.SignedPreKeyStore", + "allDeclaredMethods":true +}, +{ + "name":"org.whispersystems.signalservice.api.account.AccountAttributes", + "allDeclaredFields":true, + "allDeclaredMethods":true, + "allDeclaredConstructors":true +}, +{ + "name":"org.whispersystems.signalservice.api.account.AccountAttributes$Capabilities", + "allDeclaredFields":true, + "allDeclaredMethods":true, + "allDeclaredConstructors":true +}, +{ + "name":"org.whispersystems.signalservice.api.groupsv2.CredentialResponse", + "allDeclaredFields":true, + "allDeclaredMethods":true, + "allDeclaredConstructors":true +}, +{ + "name":"org.whispersystems.signalservice.api.groupsv2.TemporalCredential", + "allDeclaredFields":true, + "allDeclaredMethods":true, + "allDeclaredConstructors":true +}, +{ + "name":"org.whispersystems.signalservice.api.groupsv2.TemporalCredential[]" +}, +{ + "name":"org.whispersystems.signalservice.api.messages.multidevice.DeviceInfo", + "allDeclaredFields":true, + "allDeclaredMethods":true, + "allDeclaredConstructors":true +}, +{ + "name":"org.whispersystems.signalservice.api.profiles.SignalServiceProfile", + "allDeclaredFields":true, + "allDeclaredMethods":true, + "allDeclaredConstructors":true +}, +{ + "name":"org.whispersystems.signalservice.api.profiles.SignalServiceProfile$Capabilities", + "allDeclaredFields":true, + "allDeclaredMethods":true, + "allDeclaredConstructors":true +}, +{ + "name":"org.whispersystems.signalservice.api.push.SignedPreKeyEntity", + "allDeclaredFields":true, + "allDeclaredMethods":true, + "allDeclaredConstructors":true +}, +{ + "name":"org.whispersystems.signalservice.api.push.SignedPreKeyEntity$ByteArrayDeserializer", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.whispersystems.signalservice.api.push.SignedPreKeyEntity$ByteArraySerializer", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.whispersystems.signalservice.internal.contacts.crypto.SignatureBodyEntity", + "allDeclaredFields":true, + "allDeclaredMethods":true, + "allDeclaredConstructors":true +}, +{ + "name":"org.whispersystems.signalservice.internal.contacts.entities.DiscoveryRequest", + "allDeclaredFields":true, + "allDeclaredMethods":true, + "allDeclaredConstructors":true +}, +{ + "name":"org.whispersystems.signalservice.internal.contacts.entities.DiscoveryResponse", + "allDeclaredFields":true, + "allDeclaredMethods":true, + "allDeclaredConstructors":true +}, +{ + "name":"org.whispersystems.signalservice.internal.contacts.entities.KeyBackupRequest", + "allDeclaredFields":true, + "allDeclaredMethods":true, + "allDeclaredConstructors":true +}, +{ + "name":"org.whispersystems.signalservice.internal.contacts.entities.KeyBackupResponse", + "allDeclaredFields":true, + "allDeclaredMethods":true, + "allDeclaredConstructors":true +}, +{ + "name":"org.whispersystems.signalservice.internal.contacts.entities.MultiRemoteAttestationResponse", + "allDeclaredFields":true, + "allDeclaredMethods":true, + "allDeclaredConstructors":true +}, +{ + "name":"org.whispersystems.signalservice.internal.contacts.entities.QueryEnvelope", + "allDeclaredFields":true, + "allDeclaredMethods":true, + "allDeclaredConstructors":true +}, +{ + "name":"org.whispersystems.signalservice.internal.contacts.entities.RemoteAttestationRequest", + "allDeclaredFields":true, + "allDeclaredMethods":true, + "allDeclaredConstructors":true +}, +{ + "name":"org.whispersystems.signalservice.internal.contacts.entities.RemoteAttestationResponse", + "allDeclaredFields":true, + "allDeclaredMethods":true, + "allDeclaredConstructors":true +}, +{ + "name":"org.whispersystems.signalservice.internal.contacts.entities.TokenResponse", + "allDeclaredFields":true, + "allDeclaredMethods":true, + "allDeclaredConstructors":true +}, +{ + "name":"org.whispersystems.signalservice.internal.keybackup.protos.BackupRequest", + "fields":[ + {"name":"backupId_", "allowUnsafeAccess":true}, + {"name":"bitField0_", "allowUnsafeAccess":true}, + {"name":"data_", "allowUnsafeAccess":true}, + {"name":"pin_", "allowUnsafeAccess":true}, + {"name":"serviceId_", "allowUnsafeAccess":true}, + {"name":"token_", "allowUnsafeAccess":true}, + {"name":"tries_", "allowUnsafeAccess":true}, + {"name":"validFrom_", "allowUnsafeAccess":true} + ] +}, +{ + "name":"org.whispersystems.signalservice.internal.keybackup.protos.BackupResponse", + "fields":[ + {"name":"bitField0_", "allowUnsafeAccess":true}, + {"name":"status_", "allowUnsafeAccess":true}, + {"name":"token_", "allowUnsafeAccess":true} + ] +}, +{ + "name":"org.whispersystems.signalservice.internal.keybackup.protos.DeleteRequest", + "fields":[ + {"name":"backupId_", "allowUnsafeAccess":true}, + {"name":"bitField0_", "allowUnsafeAccess":true}, + {"name":"serviceId_", "allowUnsafeAccess":true} + ] +}, +{ + "name":"org.whispersystems.signalservice.internal.keybackup.protos.Request", + "fields":[ + {"name":"backup_", "allowUnsafeAccess":true}, + {"name":"bitField0_", "allowUnsafeAccess":true}, + {"name":"delete_", "allowUnsafeAccess":true}, + {"name":"restore_", "allowUnsafeAccess":true} + ] +}, +{ + "name":"org.whispersystems.signalservice.internal.keybackup.protos.Response", + "fields":[ + {"name":"backup_", "allowUnsafeAccess":true}, + {"name":"bitField0_", "allowUnsafeAccess":true}, + {"name":"delete_", "allowUnsafeAccess":true}, + {"name":"restore_", "allowUnsafeAccess":true} + ] +}, +{ + "name":"org.whispersystems.signalservice.internal.push.AttachmentV2UploadAttributes", + "allDeclaredFields":true, + "allDeclaredMethods":true, + "allDeclaredConstructors":true +}, +{ + "name":"org.whispersystems.signalservice.internal.push.AuthCredentials", + "allDeclaredFields":true, + "allDeclaredMethods":true, + "allDeclaredConstructors":true +}, +{ + "name":"org.whispersystems.signalservice.internal.push.ConfirmCodeMessage", + "allDeclaredFields":true, + "allDeclaredMethods":true, + "allDeclaredConstructors":true +}, +{ + "name":"org.whispersystems.signalservice.internal.push.DeviceCode", + "allDeclaredFields":true, + "allDeclaredMethods":true, + "allDeclaredConstructors":true +}, +{ + "name":"org.whispersystems.signalservice.internal.push.DeviceId", + "allDeclaredFields":true, + "allDeclaredMethods":true, + "allDeclaredConstructors":true +}, +{ + "name":"org.whispersystems.signalservice.internal.push.DeviceInfoList", + "allDeclaredFields":true, + "allDeclaredMethods":true, + "allDeclaredConstructors":true +}, +{ + "name":"org.whispersystems.signalservice.internal.push.MismatchedDevices", + "allDeclaredFields":true, + "allDeclaredMethods":true, + "allDeclaredConstructors":true +}, +{ + "name":"org.whispersystems.signalservice.internal.push.OutgoingPushMessage", + "allDeclaredFields":true, + "allDeclaredMethods":true, + "allDeclaredConstructors":true +}, +{ + "name":"org.whispersystems.signalservice.internal.push.OutgoingPushMessageList", + "allDeclaredFields":true, + "allDeclaredMethods":true, + "allDeclaredConstructors":true +}, +{ + "name":"org.whispersystems.signalservice.internal.push.PreKeyEntity", + "allDeclaredFields":true, + "allDeclaredMethods":true, + "allDeclaredConstructors":true +}, +{ + "name":"org.whispersystems.signalservice.internal.push.PreKeyEntity$ECPublicKeyDeserializer", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.whispersystems.signalservice.internal.push.PreKeyEntity$ECPublicKeySerializer", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.whispersystems.signalservice.internal.push.PreKeyResponse", + "allDeclaredFields":true, + "allDeclaredMethods":true, + "allDeclaredConstructors":true +}, +{ + "name":"org.whispersystems.signalservice.internal.push.PreKeyResponseItem", + "allDeclaredFields":true, + "allDeclaredMethods":true, + "allDeclaredConstructors":true +}, +{ + "name":"org.whispersystems.signalservice.internal.push.PreKeyState", + "allDeclaredFields":true, + "allDeclaredMethods":true, + "allDeclaredConstructors":true +}, +{ + "name":"org.whispersystems.signalservice.internal.push.PreKeyStatus", + "allDeclaredFields":true, + "allDeclaredMethods":true, + "allDeclaredConstructors":true +}, +{ + "name":"org.whispersystems.signalservice.internal.push.ProvisioningMessage", + "allDeclaredFields":true, + "allDeclaredMethods":true, + "allDeclaredConstructors":true +}, +{ + "name":"org.whispersystems.signalservice.internal.push.ProvisioningProtos$ProvisionEnvelope", + "fields":[ + {"name":"bitField0_", "allowUnsafeAccess":true}, + {"name":"body_", "allowUnsafeAccess":true}, + {"name":"publicKey_", "allowUnsafeAccess":true} + ] +}, +{ + "name":"org.whispersystems.signalservice.internal.push.ProvisioningProtos$ProvisionMessage", + "fields":[ + {"name":"bitField0_", "allowUnsafeAccess":true}, + {"name":"identityKeyPrivate_", "allowUnsafeAccess":true}, + {"name":"identityKeyPublic_", "allowUnsafeAccess":true}, + {"name":"number_", "allowUnsafeAccess":true}, + {"name":"profileKey_", "allowUnsafeAccess":true}, + {"name":"provisioningCode_", "allowUnsafeAccess":true}, + {"name":"provisioningVersion_", "allowUnsafeAccess":true}, + {"name":"readReceipts_", "allowUnsafeAccess":true}, + {"name":"userAgent_", "allowUnsafeAccess":true}, + {"name":"uuid_", "allowUnsafeAccess":true} + ] +}, +{ + "name":"org.whispersystems.signalservice.internal.push.ProvisioningProtos$ProvisioningUuid", + "fields":[ + {"name":"bitField0_", "allowUnsafeAccess":true}, + {"name":"uuid_", "allowUnsafeAccess":true} + ] +}, +{ + "name":"org.whispersystems.signalservice.internal.push.PushServiceSocket$RegistrationLockV2", + "allDeclaredFields":true, + "allDeclaredMethods":true, + "allDeclaredConstructors":true +}, +{ + "name":"org.whispersystems.signalservice.internal.push.SendMessageResponse", + "allDeclaredFields":true, + "allDeclaredMethods":true, + "allDeclaredConstructors":true +}, +{ + "name":"org.whispersystems.signalservice.internal.push.SenderCertificate", + "allDeclaredFields":true, + "allDeclaredMethods":true, + "allDeclaredConstructors":true +}, +{ + "name":"org.whispersystems.signalservice.internal.push.SenderCertificate$ByteArrayDesieralizer", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.whispersystems.signalservice.internal.push.SignalServiceProtos$AttachmentPointer", + "fields":[ + {"name":"attachmentIdentifierCase_", "allowUnsafeAccess":true}, + {"name":"attachmentIdentifier_", "allowUnsafeAccess":true}, + {"name":"bitField0_", "allowUnsafeAccess":true}, + {"name":"blurHash_", "allowUnsafeAccess":true}, + {"name":"caption_", "allowUnsafeAccess":true}, + {"name":"cdnNumber_", "allowUnsafeAccess":true}, + {"name":"contentType_", "allowUnsafeAccess":true}, + {"name":"digest_", "allowUnsafeAccess":true}, + {"name":"fileName_", "allowUnsafeAccess":true}, + {"name":"flags_", "allowUnsafeAccess":true}, + {"name":"height_", "allowUnsafeAccess":true}, + {"name":"key_", "allowUnsafeAccess":true}, + {"name":"size_", "allowUnsafeAccess":true}, + {"name":"thumbnail_", "allowUnsafeAccess":true}, + {"name":"uploadTimestamp_", "allowUnsafeAccess":true}, + {"name":"width_", "allowUnsafeAccess":true} + ] +}, +{ + "name":"org.whispersystems.signalservice.internal.push.SignalServiceProtos$ContactDetails", + "fields":[ + {"name":"archived_", "allowUnsafeAccess":true}, + {"name":"avatar_", "allowUnsafeAccess":true}, + {"name":"bitField0_", "allowUnsafeAccess":true}, + {"name":"blocked_", "allowUnsafeAccess":true}, + {"name":"color_", "allowUnsafeAccess":true}, + {"name":"expireTimer_", "allowUnsafeAccess":true}, + {"name":"inboxPosition_", "allowUnsafeAccess":true}, + {"name":"name_", "allowUnsafeAccess":true}, + {"name":"number_", "allowUnsafeAccess":true}, + {"name":"profileKey_", "allowUnsafeAccess":true}, + {"name":"uuid_", "allowUnsafeAccess":true}, + {"name":"verified_", "allowUnsafeAccess":true} + ] +}, +{ + "name":"org.whispersystems.signalservice.internal.push.SignalServiceProtos$ContactDetails$Avatar", + "fields":[ + {"name":"bitField0_", "allowUnsafeAccess":true}, + {"name":"contentType_", "allowUnsafeAccess":true}, + {"name":"length_", "allowUnsafeAccess":true} + ] +}, +{ + "name":"org.whispersystems.signalservice.internal.push.SignalServiceProtos$Content", + "fields":[ + {"name":"bitField0_", "allowUnsafeAccess":true}, + {"name":"callMessage_", "allowUnsafeAccess":true}, + {"name":"dataMessage_", "allowUnsafeAccess":true}, + {"name":"nullMessage_", "allowUnsafeAccess":true}, + {"name":"receiptMessage_", "allowUnsafeAccess":true}, + {"name":"syncMessage_", "allowUnsafeAccess":true}, + {"name":"typingMessage_", "allowUnsafeAccess":true} + ] +}, +{ + "name":"org.whispersystems.signalservice.internal.push.SignalServiceProtos$DataMessage", + "fields":[ + {"name":"attachments_", "allowUnsafeAccess":true}, + {"name":"bitField0_", "allowUnsafeAccess":true}, + {"name":"bodyRanges_", "allowUnsafeAccess":true}, + {"name":"body_", "allowUnsafeAccess":true}, + {"name":"contact_", "allowUnsafeAccess":true}, + {"name":"delete_", "allowUnsafeAccess":true}, + {"name":"expireTimer_", "allowUnsafeAccess":true}, + {"name":"flags_", "allowUnsafeAccess":true}, + {"name":"groupCallUpdate_", "allowUnsafeAccess":true}, + {"name":"groupV2_", "allowUnsafeAccess":true}, + {"name":"group_", "allowUnsafeAccess":true}, + {"name":"isViewOnce_", "allowUnsafeAccess":true}, + {"name":"preview_", "allowUnsafeAccess":true}, + {"name":"profileKey_", "allowUnsafeAccess":true}, + {"name":"quote_", "allowUnsafeAccess":true}, + {"name":"reaction_", "allowUnsafeAccess":true}, + {"name":"requiredProtocolVersion_", "allowUnsafeAccess":true}, + {"name":"sticker_", "allowUnsafeAccess":true}, + {"name":"timestamp_", "allowUnsafeAccess":true} + ] +}, +{ + "name":"org.whispersystems.signalservice.internal.push.SignalServiceProtos$DataMessage$BodyRange", + "fields":[ + {"name":"associatedValueCase_", "allowUnsafeAccess":true}, + {"name":"associatedValue_", "allowUnsafeAccess":true}, + {"name":"bitField0_", "allowUnsafeAccess":true}, + {"name":"length_", "allowUnsafeAccess":true}, + {"name":"start_", "allowUnsafeAccess":true} + ] +}, +{ + "name":"org.whispersystems.signalservice.internal.push.SignalServiceProtos$DataMessage$Contact", + "fields":[ + {"name":"address_", "allowUnsafeAccess":true}, + {"name":"avatar_", "allowUnsafeAccess":true}, + {"name":"bitField0_", "allowUnsafeAccess":true}, + {"name":"email_", "allowUnsafeAccess":true}, + {"name":"name_", "allowUnsafeAccess":true}, + {"name":"number_", "allowUnsafeAccess":true}, + {"name":"organization_", "allowUnsafeAccess":true} + ] +}, +{ + "name":"org.whispersystems.signalservice.internal.push.SignalServiceProtos$DataMessage$Contact$Avatar", + "fields":[ + {"name":"avatar_", "allowUnsafeAccess":true}, + {"name":"bitField0_", "allowUnsafeAccess":true}, + {"name":"isProfile_", "allowUnsafeAccess":true} + ] +}, +{ + "name":"org.whispersystems.signalservice.internal.push.SignalServiceProtos$DataMessage$Contact$Email", + "fields":[ + {"name":"bitField0_", "allowUnsafeAccess":true}, + {"name":"label_", "allowUnsafeAccess":true}, + {"name":"type_", "allowUnsafeAccess":true}, + {"name":"value_", "allowUnsafeAccess":true} + ] +}, +{ + "name":"org.whispersystems.signalservice.internal.push.SignalServiceProtos$DataMessage$Contact$Name", + "fields":[ + {"name":"bitField0_", "allowUnsafeAccess":true}, + {"name":"displayName_", "allowUnsafeAccess":true}, + {"name":"familyName_", "allowUnsafeAccess":true}, + {"name":"givenName_", "allowUnsafeAccess":true}, + {"name":"middleName_", "allowUnsafeAccess":true}, + {"name":"prefix_", "allowUnsafeAccess":true}, + {"name":"suffix_", "allowUnsafeAccess":true} + ] +}, +{ + "name":"org.whispersystems.signalservice.internal.push.SignalServiceProtos$DataMessage$Contact$Phone", + "fields":[ + {"name":"bitField0_", "allowUnsafeAccess":true}, + {"name":"label_", "allowUnsafeAccess":true}, + {"name":"type_", "allowUnsafeAccess":true}, + {"name":"value_", "allowUnsafeAccess":true} + ] +}, +{ + "name":"org.whispersystems.signalservice.internal.push.SignalServiceProtos$DataMessage$Contact$PostalAddress", + "fields":[ + {"name":"bitField0_", "allowUnsafeAccess":true}, + {"name":"city_", "allowUnsafeAccess":true}, + {"name":"country_", "allowUnsafeAccess":true}, + {"name":"label_", "allowUnsafeAccess":true}, + {"name":"neighborhood_", "allowUnsafeAccess":true}, + {"name":"pobox_", "allowUnsafeAccess":true}, + {"name":"postcode_", "allowUnsafeAccess":true}, + {"name":"region_", "allowUnsafeAccess":true}, + {"name":"street_", "allowUnsafeAccess":true}, + {"name":"type_", "allowUnsafeAccess":true} + ] +}, +{ + "name":"org.whispersystems.signalservice.internal.push.SignalServiceProtos$DataMessage$Delete", + "fields":[ + {"name":"bitField0_", "allowUnsafeAccess":true}, + {"name":"targetSentTimestamp_", "allowUnsafeAccess":true} + ] +}, +{ + "name":"org.whispersystems.signalservice.internal.push.SignalServiceProtos$DataMessage$Preview", + "fields":[ + {"name":"bitField0_", "allowUnsafeAccess":true}, + {"name":"date_", "allowUnsafeAccess":true}, + {"name":"description_", "allowUnsafeAccess":true}, + {"name":"image_", "allowUnsafeAccess":true}, + {"name":"title_", "allowUnsafeAccess":true}, + {"name":"url_", "allowUnsafeAccess":true} + ] +}, +{ + "name":"org.whispersystems.signalservice.internal.push.SignalServiceProtos$DataMessage$Quote", + "fields":[ + {"name":"attachments_", "allowUnsafeAccess":true}, + {"name":"authorE164_", "allowUnsafeAccess":true}, + {"name":"authorUuid_", "allowUnsafeAccess":true}, + {"name":"bitField0_", "allowUnsafeAccess":true}, + {"name":"bodyRanges_", "allowUnsafeAccess":true}, + {"name":"id_", "allowUnsafeAccess":true}, + {"name":"text_", "allowUnsafeAccess":true} + ] +}, +{ + "name":"org.whispersystems.signalservice.internal.push.SignalServiceProtos$DataMessage$Reaction", + "fields":[ + {"name":"bitField0_", "allowUnsafeAccess":true}, + {"name":"emoji_", "allowUnsafeAccess":true}, + {"name":"remove_", "allowUnsafeAccess":true}, + {"name":"targetAuthorUuid_", "allowUnsafeAccess":true}, + {"name":"targetSentTimestamp_", "allowUnsafeAccess":true} + ] +}, +{ + "name":"org.whispersystems.signalservice.internal.push.SignalServiceProtos$DataMessage$Sticker", + "fields":[ + {"name":"bitField0_", "allowUnsafeAccess":true}, + {"name":"data_", "allowUnsafeAccess":true}, + {"name":"emoji_", "allowUnsafeAccess":true}, + {"name":"packId_", "allowUnsafeAccess":true}, + {"name":"packKey_", "allowUnsafeAccess":true}, + {"name":"stickerId_", "allowUnsafeAccess":true} + ] +}, +{ + "name":"org.whispersystems.signalservice.internal.push.SignalServiceProtos$Envelope", + "fields":[ + {"name":"bitField0_", "allowUnsafeAccess":true}, + {"name":"content_", "allowUnsafeAccess":true}, + {"name":"legacyMessage_", "allowUnsafeAccess":true}, + {"name":"relay_", "allowUnsafeAccess":true}, + {"name":"serverGuid_", "allowUnsafeAccess":true}, + {"name":"serverTimestamp_", "allowUnsafeAccess":true}, + {"name":"sourceDevice_", "allowUnsafeAccess":true}, + {"name":"sourceE164_", "allowUnsafeAccess":true}, + {"name":"sourceUuid_", "allowUnsafeAccess":true}, + {"name":"timestamp_", "allowUnsafeAccess":true}, + {"name":"type_", "allowUnsafeAccess":true} + ] +}, +{ + "name":"org.whispersystems.signalservice.internal.push.SignalServiceProtos$GroupContextV2", + "fields":[ + {"name":"bitField0_", "allowUnsafeAccess":true}, + {"name":"groupChange_", "allowUnsafeAccess":true}, + {"name":"masterKey_", "allowUnsafeAccess":true}, + {"name":"revision_", "allowUnsafeAccess":true} + ] +}, +{ + "name":"org.whispersystems.signalservice.internal.push.SignalServiceProtos$NullMessage", + "fields":[ + {"name":"bitField0_", "allowUnsafeAccess":true}, + {"name":"padding_", "allowUnsafeAccess":true} + ] +}, +{ + "name":"org.whispersystems.signalservice.internal.push.SignalServiceProtos$ReceiptMessage", + "fields":[ + {"name":"bitField0_", "allowUnsafeAccess":true}, + {"name":"timestamp_", "allowUnsafeAccess":true}, + {"name":"type_", "allowUnsafeAccess":true} + ] +}, +{ + "name":"org.whispersystems.signalservice.internal.push.SignalServiceProtos$SyncMessage", + "fields":[ + {"name":"bitField0_", "allowUnsafeAccess":true}, + {"name":"blocked_", "allowUnsafeAccess":true}, + {"name":"configuration_", "allowUnsafeAccess":true}, + {"name":"contacts_", "allowUnsafeAccess":true}, + {"name":"fetchLatest_", "allowUnsafeAccess":true}, + {"name":"groups_", "allowUnsafeAccess":true}, + {"name":"keys_", "allowUnsafeAccess":true}, + {"name":"messageRequestResponse_", "allowUnsafeAccess":true}, + {"name":"padding_", "allowUnsafeAccess":true}, + {"name":"read_", "allowUnsafeAccess":true}, + {"name":"request_", "allowUnsafeAccess":true}, + {"name":"sent_", "allowUnsafeAccess":true}, + {"name":"stickerPackOperation_", "allowUnsafeAccess":true}, + {"name":"verified_", "allowUnsafeAccess":true}, + {"name":"viewOnceOpen_", "allowUnsafeAccess":true} + ] +}, +{ + "name":"org.whispersystems.signalservice.internal.push.SignalServiceProtos$SyncMessage$Contacts", + "fields":[ + {"name":"bitField0_", "allowUnsafeAccess":true}, + {"name":"blob_", "allowUnsafeAccess":true}, + {"name":"complete_", "allowUnsafeAccess":true} + ] +}, +{ + "name":"org.whispersystems.signalservice.internal.push.SignalServiceProtos$SyncMessage$FetchLatest", + "fields":[ + {"name":"bitField0_", "allowUnsafeAccess":true}, + {"name":"type_", "allowUnsafeAccess":true} + ] +}, +{ + "name":"org.whispersystems.signalservice.internal.push.SignalServiceProtos$SyncMessage$Keys", + "fields":[ + {"name":"bitField0_", "allowUnsafeAccess":true}, + {"name":"storageService_", "allowUnsafeAccess":true} + ] +}, +{ + "name":"org.whispersystems.signalservice.internal.push.SignalServiceProtos$SyncMessage$Read", + "fields":[ + {"name":"bitField0_", "allowUnsafeAccess":true}, + {"name":"senderE164_", "allowUnsafeAccess":true}, + {"name":"senderUuid_", "allowUnsafeAccess":true}, + {"name":"timestamp_", "allowUnsafeAccess":true} + ] +}, +{ + "name":"org.whispersystems.signalservice.internal.push.SignalServiceProtos$SyncMessage$Request", + "fields":[ + {"name":"bitField0_", "allowUnsafeAccess":true}, + {"name":"type_", "allowUnsafeAccess":true} + ] +}, +{ + "name":"org.whispersystems.signalservice.internal.push.SignalServiceProtos$SyncMessage$Sent", + "fields":[ + {"name":"bitField0_", "allowUnsafeAccess":true}, + {"name":"destinationE164_", "allowUnsafeAccess":true}, + {"name":"destinationUuid_", "allowUnsafeAccess":true}, + {"name":"expirationStartTimestamp_", "allowUnsafeAccess":true}, + {"name":"isRecipientUpdate_", "allowUnsafeAccess":true}, + {"name":"message_", "allowUnsafeAccess":true}, + {"name":"timestamp_", "allowUnsafeAccess":true}, + {"name":"unidentifiedStatus_", "allowUnsafeAccess":true} + ] +}, +{ + "name":"org.whispersystems.signalservice.internal.push.SignalServiceProtos$SyncMessage$Sent$UnidentifiedDeliveryStatus", + "fields":[ + {"name":"bitField0_", "allowUnsafeAccess":true}, + {"name":"destinationE164_", "allowUnsafeAccess":true}, + {"name":"destinationUuid_", "allowUnsafeAccess":true}, + {"name":"unidentified_", "allowUnsafeAccess":true} + ] +}, +{ + "name":"org.whispersystems.signalservice.internal.push.SignalServiceProtos$SyncMessage$StickerPackOperation", + "fields":[ + {"name":"bitField0_", "allowUnsafeAccess":true}, + {"name":"packId_", "allowUnsafeAccess":true}, + {"name":"packKey_", "allowUnsafeAccess":true}, + {"name":"type_", "allowUnsafeAccess":true} + ] +}, +{ + "name":"org.whispersystems.signalservice.internal.push.SignalServiceProtos$TypingMessage", + "fields":[ + {"name":"action_", "allowUnsafeAccess":true}, + {"name":"bitField0_", "allowUnsafeAccess":true}, + {"name":"groupId_", "allowUnsafeAccess":true}, + {"name":"timestamp_", "allowUnsafeAccess":true} + ] +}, +{ + "name":"org.whispersystems.signalservice.internal.push.SignalServiceProtos$Verified", + "fields":[ + {"name":"bitField0_", "allowUnsafeAccess":true}, + {"name":"destinationE164_", "allowUnsafeAccess":true}, + {"name":"destinationUuid_", "allowUnsafeAccess":true}, + {"name":"identityKey_", "allowUnsafeAccess":true}, + {"name":"nullMessage_", "allowUnsafeAccess":true}, + {"name":"state_", "allowUnsafeAccess":true} + ] +}, +{ + "name":"org.whispersystems.signalservice.internal.push.VerifyAccountResponse", + "allDeclaredFields":true, + "allDeclaredMethods":true, + "allDeclaredConstructors":true +}, +{ + "name":"org.whispersystems.signalservice.internal.serialize.protos.AddressProto", + "fields":[ + {"name":"bitField0_", "allowUnsafeAccess":true}, + {"name":"e164_", "allowUnsafeAccess":true}, + {"name":"relay_", "allowUnsafeAccess":true}, + {"name":"uuid_", "allowUnsafeAccess":true} + ] +}, +{ + "name":"org.whispersystems.signalservice.internal.serialize.protos.MetadataProto", + "fields":[ + {"name":"address_", "allowUnsafeAccess":true}, + {"name":"bitField0_", "allowUnsafeAccess":true}, + {"name":"needsReceipt_", "allowUnsafeAccess":true}, + {"name":"senderDevice_", "allowUnsafeAccess":true}, + {"name":"serverDeliveredTimestamp_", "allowUnsafeAccess":true}, + {"name":"serverReceivedTimestamp_", "allowUnsafeAccess":true}, + {"name":"timestamp_", "allowUnsafeAccess":true} + ] +}, +{ + "name":"org.whispersystems.signalservice.internal.serialize.protos.SignalServiceContentProto", + "fields":[ + {"name":"bitField0_", "allowUnsafeAccess":true}, + {"name":"dataCase_", "allowUnsafeAccess":true}, + {"name":"data_", "allowUnsafeAccess":true}, + {"name":"localAddress_", "allowUnsafeAccess":true}, + {"name":"metadata_", "allowUnsafeAccess":true} + ] +}, +{ + "name":"org.whispersystems.signalservice.internal.util.JsonUtil$IdentityKeyDeserializer", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.whispersystems.signalservice.internal.util.JsonUtil$IdentityKeySerializer", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.whispersystems.signalservice.internal.util.JsonUtil$UuidDeserializer", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.whispersystems.signalservice.internal.websocket.WebSocketProtos$WebSocketMessage", + "fields":[ + {"name":"bitField0_", "allowUnsafeAccess":true}, + {"name":"request_", "allowUnsafeAccess":true}, + {"name":"response_", "allowUnsafeAccess":true}, + {"name":"type_", "allowUnsafeAccess":true} + ] +}, +{ + "name":"org.whispersystems.signalservice.internal.websocket.WebSocketProtos$WebSocketRequestMessage", + "fields":[ + {"name":"bitField0_", "allowUnsafeAccess":true}, + {"name":"body_", "allowUnsafeAccess":true}, + {"name":"headers_", "allowUnsafeAccess":true}, + {"name":"id_", "allowUnsafeAccess":true}, + {"name":"path_", "allowUnsafeAccess":true}, + {"name":"verb_", "allowUnsafeAccess":true} + ] +}, +{ + "name":"org.whispersystems.signalservice.internal.websocket.WebSocketProtos$WebSocketResponseMessage", + "fields":[ + {"name":"bitField0_", "allowUnsafeAccess":true}, + {"name":"body_", "allowUnsafeAccess":true}, + {"name":"headers_", "allowUnsafeAccess":true}, + {"name":"id_", "allowUnsafeAccess":true}, + {"name":"message_", "allowUnsafeAccess":true}, + {"name":"status_", "allowUnsafeAccess":true} + ] +}, +{ + "name":"sun.misc.Unsafe", + "allDeclaredFields":true, + "methods":[ + {"name":"allocateMemory","parameterTypes":["long"] }, + {"name":"arrayBaseOffset","parameterTypes":["java.lang.Class"] }, + {"name":"arrayIndexScale","parameterTypes":["java.lang.Class"] }, + {"name":"copyMemory","parameterTypes":["long","long","long"] }, + {"name":"copyMemory","parameterTypes":["java.lang.Object","long","java.lang.Object","long","long"] }, + {"name":"freeMemory","parameterTypes":["long"] }, + {"name":"getAddress","parameterTypes":["long"] }, + {"name":"getBoolean","parameterTypes":["java.lang.Object","long"] }, + {"name":"getByte","parameterTypes":["long"] }, + {"name":"getByte","parameterTypes":["java.lang.Object","long"] }, + {"name":"getDouble","parameterTypes":["long"] }, + {"name":"getDouble","parameterTypes":["java.lang.Object","long"] }, + {"name":"getFloat","parameterTypes":["long"] }, + {"name":"getFloat","parameterTypes":["java.lang.Object","long"] }, + {"name":"getInt","parameterTypes":["long"] }, + {"name":"getInt","parameterTypes":["java.lang.Object","long"] }, + {"name":"getLong","parameterTypes":["long"] }, + {"name":"getLong","parameterTypes":["java.lang.Object","long"] }, + {"name":"getObject","parameterTypes":["java.lang.Object","long"] }, + {"name":"getShort","parameterTypes":["long"] }, + {"name":"objectFieldOffset","parameterTypes":["java.lang.reflect.Field"] }, + {"name":"putAddress","parameterTypes":["long","long"] }, + {"name":"putBoolean","parameterTypes":["java.lang.Object","long","boolean"] }, + {"name":"putByte","parameterTypes":["long","byte"] }, + {"name":"putByte","parameterTypes":["java.lang.Object","long","byte"] }, + {"name":"putDouble","parameterTypes":["long","double"] }, + {"name":"putDouble","parameterTypes":["java.lang.Object","long","double"] }, + {"name":"putFloat","parameterTypes":["long","float"] }, + {"name":"putFloat","parameterTypes":["java.lang.Object","long","float"] }, + {"name":"putInt","parameterTypes":["long","int"] }, + {"name":"putInt","parameterTypes":["java.lang.Object","long","int"] }, + {"name":"putLong","parameterTypes":["long","long"] }, + {"name":"putLong","parameterTypes":["java.lang.Object","long","long"] }, + {"name":"putObject","parameterTypes":["java.lang.Object","long","java.lang.Object"] }, + {"name":"putShort","parameterTypes":["long","short"] } + ] +}, +{ + "name":"sun.security.provider.DSA$SHA224withDSA", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"sun.security.provider.JavaKeyStore$DualFormatJKS", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"sun.security.provider.JavaKeyStore$JKS", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"sun.security.provider.NativePRNG", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"sun.security.provider.SHA", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"sun.security.provider.SHA2$SHA224", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"sun.security.provider.SHA2$SHA256", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"sun.security.provider.SHA5$SHA384", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"sun.security.provider.SHA5$SHA512", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"sun.security.provider.SecureRandom", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"sun.security.provider.certpath.PKIXCertPathValidator", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"sun.security.rsa.RSAKeyFactory$Legacy", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"sun.security.rsa.RSAPSSSignature", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"sun.security.rsa.RSASignature$SHA224withRSA", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"sun.security.rsa.RSASignature$SHA256withRSA", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"sun.security.rsa.RSASignature$SHA512withRSA", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"sun.security.ssl.SSLContextImpl$TLSContext", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"sun.security.ssl.TrustManagerFactoryImpl$PKIXFactory", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"sun.security.x509.AuthorityKeyIdentifierExtension", + "methods":[{"name":"","parameterTypes":["java.lang.Boolean","java.lang.Object"] }] +}, +{ + "name":"sun.security.x509.BasicConstraintsExtension", + "methods":[{"name":"","parameterTypes":["java.lang.Boolean","java.lang.Object"] }] +}, +{ + "name":"sun.security.x509.CRLDistributionPointsExtension", + "methods":[{"name":"","parameterTypes":["java.lang.Boolean","java.lang.Object"] }] +}, +{ + "name":"sun.security.x509.KeyUsageExtension", + "methods":[{"name":"","parameterTypes":["java.lang.Boolean","java.lang.Object"] }] +}, +{ + "name":"sun.security.x509.SubjectAlternativeNameExtension", + "methods":[{"name":"","parameterTypes":["java.lang.Boolean","java.lang.Object"] }] +}, +{ + "name":"sun.security.x509.SubjectKeyIdentifierExtension", + "methods":[{"name":"","parameterTypes":["java.lang.Boolean","java.lang.Object"] }] +} +] diff --git a/graalvm-config-dir/resource-config.json b/graalvm-config-dir/resource-config.json new file mode 100644 index 00000000..9922e29f --- /dev/null +++ b/graalvm-config-dir/resource-config.json @@ -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"}] +} diff --git a/graalvm-config-dir/serialization-config.json b/graalvm-config-dir/serialization-config.json new file mode 100644 index 00000000..0d4f101c --- /dev/null +++ b/graalvm-config-dir/serialization-config.json @@ -0,0 +1,2 @@ +[ +] diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 12d38de6..f371643e 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/lib/build.gradle.kts b/lib/build.gradle.kts new file mode 100644 index 00000000..b9dfbc21 --- /dev/null +++ b/lib/build.gradle.kts @@ -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 { + options.encoding = "UTF-8" +} diff --git a/lib/src/main/java/org/asamk/signal/manager/AttachmentInvalidException.java b/lib/src/main/java/org/asamk/signal/manager/AttachmentInvalidException.java new file mode 100644 index 00000000..78fba6e0 --- /dev/null +++ b/lib/src/main/java/org/asamk/signal/manager/AttachmentInvalidException.java @@ -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()); + } +} diff --git a/lib/src/main/java/org/asamk/signal/manager/AttachmentStore.java b/lib/src/main/java/org/asamk/signal/manager/AttachmentStore.java new file mode 100644 index 00000000..f983a90b --- /dev/null +++ b/lib/src/main/java/org/asamk/signal/manager/AttachmentStore.java @@ -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; + } +} diff --git a/lib/src/main/java/org/asamk/signal/manager/AvatarStore.java b/lib/src/main/java/org/asamk/signal/manager/AvatarStore.java new file mode 100644 index 00000000..de59f2af --- /dev/null +++ b/lib/src/main/java/org/asamk/signal/manager/AvatarStore.java @@ -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; + } +} diff --git a/lib/src/main/java/org/asamk/signal/manager/DeviceLinkInfo.java b/lib/src/main/java/org/asamk/signal/manager/DeviceLinkInfo.java new file mode 100644 index 00000000..1f9d10ff --- /dev/null +++ b/lib/src/main/java/org/asamk/signal/manager/DeviceLinkInfo.java @@ -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 getQueryMap(String query) { + var params = query.split("&"); + var map = new HashMap(); + 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); + } + } +} diff --git a/lib/src/main/java/org/asamk/signal/manager/HandleAction.java b/lib/src/main/java/org/asamk/signal/manager/HandleAction.java new file mode 100644 index 00000000..9d119c11 --- /dev/null +++ b/lib/src/main/java/org/asamk/signal/manager/HandleAction.java @@ -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; + } +} diff --git a/lib/src/main/java/org/asamk/signal/manager/JsonStickerPack.java b/lib/src/main/java/org/asamk/signal/manager/JsonStickerPack.java new file mode 100644 index 00000000..e5e0e445 --- /dev/null +++ b/lib/src/main/java/org/asamk/signal/manager/JsonStickerPack.java @@ -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 stickers; + + public static class JsonSticker { + + @JsonProperty + public String emoji; + + @JsonProperty + public String file; + } +} diff --git a/lib/src/main/java/org/asamk/signal/manager/LibSignalLogger.java b/lib/src/main/java/org/asamk/signal/manager/LibSignalLogger.java new file mode 100644 index 00000000..3be4d7e9 --- /dev/null +++ b/lib/src/main/java/org/asamk/signal/manager/LibSignalLogger.java @@ -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; + } + } +} diff --git a/lib/src/main/java/org/asamk/signal/manager/Manager.java b/lib/src/main/java/org/asamk/signal/manager/Manager.java new file mode 100644 index 00000000..48e55317 --- /dev/null +++ b/lib/src/main/java/org/asamk/signal/manager/Manager.java @@ -0,0 +1,2664 @@ +/* + 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 . + */ +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.groups.GroupId; +import org.asamk.signal.manager.groups.GroupIdV1; +import org.asamk.signal.manager.groups.GroupInviteLinkUrl; +import org.asamk.signal.manager.groups.GroupNotFoundException; +import org.asamk.signal.manager.groups.GroupUtils; +import org.asamk.signal.manager.groups.NotAGroupMemberException; +import org.asamk.signal.manager.helper.GroupHelper; +import org.asamk.signal.manager.helper.PinHelper; +import org.asamk.signal.manager.helper.ProfileHelper; +import org.asamk.signal.manager.helper.UnidentifiedAccessHelper; +import org.asamk.signal.manager.storage.SignalAccount; +import org.asamk.signal.manager.storage.contacts.ContactInfo; +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.asamk.signal.manager.storage.messageCache.CachedMessage; +import org.asamk.signal.manager.storage.profiles.SignalProfile; +import org.asamk.signal.manager.storage.protocol.IdentityInfo; +import org.asamk.signal.manager.storage.stickers.Sticker; +import org.asamk.signal.manager.util.AttachmentUtils; +import org.asamk.signal.manager.util.IOUtils; +import org.asamk.signal.manager.util.KeyUtils; +import org.asamk.signal.manager.util.ProfileUtils; +import org.asamk.signal.manager.util.StickerUtils; +import org.asamk.signal.manager.util.Utils; +import org.signal.libsignal.metadata.InvalidMetadataMessageException; +import org.signal.libsignal.metadata.InvalidMetadataVersionException; +import org.signal.libsignal.metadata.ProtocolDuplicateMessageException; +import org.signal.libsignal.metadata.ProtocolInvalidKeyException; +import org.signal.libsignal.metadata.ProtocolInvalidKeyIdException; +import org.signal.libsignal.metadata.ProtocolInvalidMessageException; +import org.signal.libsignal.metadata.ProtocolInvalidVersionException; +import org.signal.libsignal.metadata.ProtocolLegacyMessageException; +import org.signal.libsignal.metadata.ProtocolNoSessionException; +import org.signal.libsignal.metadata.ProtocolUntrustedIdentityException; +import org.signal.libsignal.metadata.SelfSendException; +import org.signal.libsignal.metadata.certificate.CertificateValidator; +import org.signal.storageservice.protos.groups.GroupChange; +import org.signal.storageservice.protos.groups.local.DecryptedGroup; +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.profiles.ClientZkProfileOperations; +import org.signal.zkgroup.profiles.ProfileKey; +import org.signal.zkgroup.profiles.ProfileKeyCredential; +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.InvalidMessageException; +import org.whispersystems.libsignal.ecc.ECPublicKey; +import org.whispersystems.libsignal.state.PreKeyRecord; +import org.whispersystems.libsignal.state.SignedPreKeyRecord; +import org.whispersystems.libsignal.util.Pair; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.SignalServiceAccountManager; +import org.whispersystems.signalservice.api.SignalServiceMessagePipe; +import org.whispersystems.signalservice.api.SignalServiceMessageReceiver; +import org.whispersystems.signalservice.api.SignalServiceMessageSender; +import org.whispersystems.signalservice.api.crypto.SignalServiceCipher; +import org.whispersystems.signalservice.api.crypto.UntrustedIdentityException; +import org.whispersystems.signalservice.api.groupsv2.ClientZkOperations; +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.messages.SendMessageResult; +import org.whispersystems.signalservice.api.messages.SignalServiceAttachment; +import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentPointer; +import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentRemoteId; +import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentStream; +import org.whispersystems.signalservice.api.messages.SignalServiceContent; +import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage; +import org.whispersystems.signalservice.api.messages.SignalServiceEnvelope; +import org.whispersystems.signalservice.api.messages.SignalServiceGroup; +import org.whispersystems.signalservice.api.messages.SignalServiceGroupV2; +import org.whispersystems.signalservice.api.messages.SignalServiceReceiptMessage; +import org.whispersystems.signalservice.api.messages.multidevice.BlockedListMessage; +import org.whispersystems.signalservice.api.messages.multidevice.ContactsMessage; +import org.whispersystems.signalservice.api.messages.multidevice.DeviceContact; +import org.whispersystems.signalservice.api.messages.multidevice.DeviceContactsInputStream; +import org.whispersystems.signalservice.api.messages.multidevice.DeviceContactsOutputStream; +import org.whispersystems.signalservice.api.messages.multidevice.DeviceGroup; +import org.whispersystems.signalservice.api.messages.multidevice.DeviceGroupsInputStream; +import org.whispersystems.signalservice.api.messages.multidevice.DeviceGroupsOutputStream; +import org.whispersystems.signalservice.api.messages.multidevice.DeviceInfo; +import org.whispersystems.signalservice.api.messages.multidevice.RequestMessage; +import org.whispersystems.signalservice.api.messages.multidevice.SentTranscriptMessage; +import org.whispersystems.signalservice.api.messages.multidevice.SignalServiceSyncMessage; +import org.whispersystems.signalservice.api.messages.multidevice.StickerPackOperationMessage; +import org.whispersystems.signalservice.api.messages.multidevice.VerifiedMessage; +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.MissingConfigurationException; +import org.whispersystems.signalservice.api.util.InvalidNumberException; +import org.whispersystems.signalservice.api.util.PhoneNumberFormatter; +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.contacts.crypto.Quote; +import org.whispersystems.signalservice.internal.contacts.crypto.UnauthenticatedQuoteException; +import org.whispersystems.signalservice.internal.contacts.crypto.UnauthenticatedResponseException; +import org.whispersystems.signalservice.internal.push.SignalServiceProtos; +import org.whispersystems.signalservice.internal.push.UnsupportedDataMessageException; +import org.whispersystems.signalservice.internal.util.DynamicCredentialsProvider; +import org.whispersystems.signalservice.internal.util.Hex; +import org.whispersystems.signalservice.internal.util.Util; + +import java.io.Closeable; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URLEncoder; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.security.SignatureException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Date; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.function.Function; +import java.util.stream.Collectors; + +<<<<<<< HEAD:src/main/java/org/asamk/signal/manager/Manager.java +import static org.asamk.signal.manager.ServiceConfig.CDS_MRENCLAVE; +import static org.asamk.signal.manager.ServiceConfig.capabilities; +import static org.asamk.signal.util.ErrorUtils.handleAssertionError; +import static org.asamk.signal.manager.ServiceConfig.getIasKeyStore; +======= +import static org.asamk.signal.manager.config.ServiceConfig.capabilities; +>>>>>>> upstream/master:lib/src/main/java/org/asamk/signal/manager/Manager.java + +public class Manager implements Closeable { + + private final static Logger logger = LoggerFactory.getLogger(Manager.class); + + private final CertificateValidator certificateValidator; + + private final ServiceEnvironmentConfig serviceEnvironmentConfig; + private final String userAgent; + + private SignalAccount account; + private final SignalServiceAccountManager accountManager; + private final GroupsV2Api groupsV2Api; + private final GroupsV2Operations groupsV2Operations; + private final SignalServiceMessageReceiver messageReceiver; + private final ClientZkProfileOperations clientZkProfileOperations; + + private final ExecutorService executor = Executors.newCachedThreadPool(); + + private SignalServiceMessagePipe messagePipe = null; + private SignalServiceMessagePipe unidentifiedMessagePipe = null; + + private final UnidentifiedAccessHelper unidentifiedAccessHelper; + private final ProfileHelper profileHelper; + private final GroupHelper groupHelper; + private final PinHelper pinHelper; + private final AvatarStore avatarStore; + private final AttachmentStore attachmentStore; + + Manager( + SignalAccount account, + PathConfig pathConfig, + ServiceEnvironmentConfig serviceEnvironmentConfig, + String userAgent + ) { + this.account = account; + this.serviceEnvironmentConfig = serviceEnvironmentConfig; + this.certificateValidator = new CertificateValidator(serviceEnvironmentConfig.getUnidentifiedSenderTrustRoot()); + this.userAgent = userAgent; + this.groupsV2Operations = capabilities.isGv2() ? new GroupsV2Operations(ClientZkOperations.create( + serviceEnvironmentConfig.getSignalServiceConfiguration())) : null; + final SleepTimer timer = new UptimeSleepTimer(); + this.accountManager = new SignalServiceAccountManager(serviceEnvironmentConfig.getSignalServiceConfiguration(), + new DynamicCredentialsProvider(account.getUuid(), + account.getUsername(), + account.getPassword(), + account.getDeviceId()), + userAgent, + groupsV2Operations, + ServiceConfig.AUTOMATIC_NETWORK_RETRY, + timer); + this.groupsV2Api = accountManager.getGroupsV2Api(); + final var keyBackupService = accountManager.getKeyBackupService(ServiceConfig.getIasKeyStore(), + serviceEnvironmentConfig.getKeyBackupConfig().getEnclaveName(), + serviceEnvironmentConfig.getKeyBackupConfig().getServiceId(), + serviceEnvironmentConfig.getKeyBackupConfig().getMrenclave(), + 10); + + this.pinHelper = new PinHelper(keyBackupService); + this.clientZkProfileOperations = capabilities.isGv2() + ? ClientZkOperations.create(serviceEnvironmentConfig.getSignalServiceConfiguration()) + .getProfileOperations() + : null; + this.messageReceiver = new SignalServiceMessageReceiver(serviceEnvironmentConfig.getSignalServiceConfiguration(), + account.getUuid(), + account.getUsername(), + account.getPassword(), + account.getDeviceId(), + userAgent, + null, + timer, + clientZkProfileOperations, + ServiceConfig.AUTOMATIC_NETWORK_RETRY); + + this.account.setResolver(this::resolveSignalServiceAddress); + + this.unidentifiedAccessHelper = new UnidentifiedAccessHelper(account::getProfileKey, + account.getProfileStore()::getProfileKey, + this::getRecipientProfile, + this::getSenderCertificate); + this.profileHelper = new ProfileHelper(account.getProfileStore()::getProfileKey, + unidentifiedAccessHelper::getAccessFor, + unidentified -> unidentified ? getOrCreateUnidentifiedMessagePipe() : getOrCreateMessagePipe(), + () -> messageReceiver); + this.groupHelper = new GroupHelper(this::getRecipientProfileKeyCredential, + this::getRecipientProfile, + account::getSelfAddress, + groupsV2Operations, + groupsV2Api, + this::getGroupAuthForToday); + this.avatarStore = new AvatarStore(pathConfig.getAvatarsPath()); + this.attachmentStore = new AttachmentStore(pathConfig.getAttachmentsPath()); + } + + public String getUsername() { + return account.getUsername(); + } + + public SignalServiceAddress getSelfAddress() { + return account.getSelfAddress(); + } + + private IdentityKeyPair getIdentityKeyPair() { + return account.getSignalProtocolStore().getIdentityKeyPair(); + } + + public int getDeviceId() { + return account.getDeviceId(); + } + + public static Manager init( + String username, File settingsPath, ServiceEnvironment serviceEnvironment, String userAgent + ) throws IOException, NotRegisteredException { + var pathConfig = PathConfig.createDefault(settingsPath); + + if (!SignalAccount.userExists(pathConfig.getDataPath(), username)) { + throw new NotRegisteredException(); + } + + var account = SignalAccount.load(pathConfig.getDataPath(), username); + + if (!account.isRegistered()) { + throw new NotRegisteredException(); + } + + final var serviceEnvironmentConfig = ServiceConfig.getServiceEnvironmentConfig(serviceEnvironment, userAgent); + + return new Manager(account, pathConfig, serviceEnvironmentConfig, userAgent); + } + + public static List getAllLocalUsernames(File settingsPath) { + var pathConfig = PathConfig.createDefault(settingsPath); + final var dataPath = pathConfig.getDataPath(); + final var files = dataPath.listFiles(); + + if (files == null) { + return List.of(); + } + + return Arrays.stream(files) + .filter(File::isFile) + .map(File::getName) + .filter(file -> PhoneNumberFormatter.isValidNumber(file, null)) + .collect(Collectors.toList()); + } + + public void checkAccountState() throws IOException { + if (accountManager.getPreKeysCount() < ServiceConfig.PREKEY_MINIMUM_COUNT) { + refreshPreKeys(); + account.save(); + } + if (account.getUuid() == null) { + account.setUuid(accountManager.getOwnUuid()); + account.save(); + } + updateAccountAttributes(); + } + + /** + * This is used for checking a set of phone numbers for registration on Signal + * + * @param numbers The set of phone number in question + * @return A map of numbers to booleans. True if registered, false otherwise. Should never be null + * @throws IOException if its unable to get the contacts to check if they're registered + */ + public Map areUsersRegistered(Set numbers) throws IOException { + // Note "contactDetails" has no optionals. It only gives us info on users who are registered + var contactDetails = getRegisteredUsers(numbers); + + var registeredUsers = contactDetails.keySet(); + + return numbers.stream().collect(Collectors.toMap(x -> x, registeredUsers::contains)); + } + + public void updateAccountAttributes() throws IOException { + accountManager.setAccountAttributes(null, + account.getSignalProtocolStore().getLocalRegistrationId(), + true, + // set legacy pin only if no KBS master key is set + account.getPinMasterKey() == null ? account.getRegistrationLockPin() : null, + account.getPinMasterKey() == null ? null : account.getPinMasterKey().deriveRegistrationLock(), + account.getSelfUnidentifiedAccessKey(), + account.isUnrestrictedUnidentifiedAccess(), + capabilities, + account.isDiscoverableByPhoneNumber()); + } + + /** + * @param name if null, the previous name will be kept + * @param about if null, the previous about text will be kept + * @param aboutEmoji if null, the previous about emoji will be kept + * @param avatar if avatar is null the image from the local avatar store is used (if present), + * if it's Optional.absent(), the avatar will be removed + */ + public void setProfile(String name, String about, String aboutEmoji, Optional avatar) throws IOException { + var profileEntry = account.getProfileStore().getProfileEntry(getSelfAddress()); + var profile = profileEntry == null ? null : profileEntry.getProfile(); + var newProfile = new SignalProfile(profile == null ? null : profile.getIdentityKey(), + name != null ? name : profile == null || profile.getName() == null ? "" : profile.getName(), + about != null ? about : profile == null || profile.getAbout() == null ? "" : profile.getAbout(), + aboutEmoji != null + ? aboutEmoji + : profile == null || profile.getAboutEmoji() == null ? "" : profile.getAboutEmoji(), + profile == null ? null : profile.getUnidentifiedAccess(), + account.isUnrestrictedUnidentifiedAccess(), + profile == null ? null : profile.getCapabilities()); + + try (final var streamDetails = avatar == null + ? avatarStore.retrieveProfileAvatar(getSelfAddress()) + : avatar.isPresent() ? Utils.createStreamDetailsFromFile(avatar.get()) : null) { + accountManager.setVersionedProfile(account.getUuid(), + account.getProfileKey(), + newProfile.getName(), + newProfile.getAbout(), + newProfile.getAboutEmoji(), + streamDetails); + } + + if (avatar != null) { + if (avatar.isPresent()) { + avatarStore.storeProfileAvatar(getSelfAddress(), + outputStream -> IOUtils.copyFileToStream(avatar.get(), outputStream)); + } else { + avatarStore.deleteProfileAvatar(getSelfAddress()); + } + } + account.getProfileStore() + .updateProfile(getSelfAddress(), + account.getProfileKey(), + System.currentTimeMillis(), + newProfile, + profileEntry == null ? null : profileEntry.getProfileKeyCredential()); + + try { + sendSyncMessage(SignalServiceSyncMessage.forFetchLatest(SignalServiceSyncMessage.FetchType.LOCAL_PROFILE)); + } catch (UntrustedIdentityException ignored) { + } + } + + public void unregister() throws IOException { + // When setting an empty GCM id, the Signal-Server also sets the fetchesMessages property to false. + // If this is the master device, other users can't send messages to this number anymore. + // If this is a linked device, other users can still send messages, but this device doesn't receive them anymore. + accountManager.setGcmId(Optional.absent()); + accountManager.deleteAccount(); + + account.setRegistered(false); + account.save(); + } + + public List getLinkedDevices() throws IOException { + var devices = accountManager.getDevices(); + account.setMultiDevice(devices.size() > 1); + account.save(); + return devices; + } + + public void removeLinkedDevices(int deviceId) throws IOException { + accountManager.removeDevice(deviceId); + var devices = accountManager.getDevices(); + account.setMultiDevice(devices.size() > 1); + account.save(); + } + + public void addDeviceLink(URI linkUri) throws IOException, InvalidKeyException { + var info = DeviceLinkInfo.parseDeviceLinkUri(linkUri); + + addDevice(info.deviceIdentifier, info.deviceKey); + } + + private void addDevice(String deviceIdentifier, ECPublicKey deviceKey) throws IOException, InvalidKeyException { + var identityKeyPair = getIdentityKeyPair(); + var verificationCode = accountManager.getNewDeviceVerificationCode(); + + accountManager.addDevice(deviceIdentifier, + deviceKey, + identityKeyPair, + Optional.of(account.getProfileKey().serialize()), + verificationCode); + account.setMultiDevice(true); + account.save(); + } + + public void setRegistrationLockPin(Optional pin) throws IOException, UnauthenticatedResponseException { + if (!account.isMasterDevice()) { + throw new RuntimeException("Only master device can set a PIN"); + } + if (pin.isPresent()) { + final var masterKey = account.getPinMasterKey() != null + ? account.getPinMasterKey() + : KeyUtils.createMasterKey(); + + pinHelper.setRegistrationLockPin(pin.get(), masterKey); + + account.setRegistrationLockPin(pin.get()); + account.setPinMasterKey(masterKey); + } else { + // Remove legacy registration lock + accountManager.removeRegistrationLockV1(); + + // Remove KBS Pin + pinHelper.removeRegistrationLockPin(); + + account.setRegistrationLockPin(null); + account.setPinMasterKey(null); + } + account.save(); + } + + void refreshPreKeys() throws IOException { + var oneTimePreKeys = generatePreKeys(); + final var identityKeyPair = getIdentityKeyPair(); + var signedPreKeyRecord = generateSignedPreKey(identityKeyPair); + + accountManager.setPreKeys(identityKeyPair.getPublicKey(), signedPreKeyRecord, oneTimePreKeys); + } + + private List generatePreKeys() { + final var offset = account.getPreKeyIdOffset(); + + var records = KeyUtils.generatePreKeyRecords(offset, ServiceConfig.PREKEY_BATCH_SIZE); + account.addPreKeys(records); + account.save(); + + return records; + } + + private SignedPreKeyRecord generateSignedPreKey(IdentityKeyPair identityKeyPair) { + final var signedPreKeyId = account.getNextSignedPreKeyId(); + + var record = KeyUtils.generateSignedPreKeyRecord(identityKeyPair, signedPreKeyId); + account.addSignedPreKey(record); + account.save(); + + return record; + } + + private SignalServiceMessagePipe getOrCreateMessagePipe() { + if (messagePipe == null) { + messagePipe = messageReceiver.createMessagePipe(); + } + return messagePipe; + } + + private SignalServiceMessagePipe getOrCreateUnidentifiedMessagePipe() { + if (unidentifiedMessagePipe == null) { + unidentifiedMessagePipe = messageReceiver.createUnidentifiedMessagePipe(); + } + return unidentifiedMessagePipe; + } + + private SignalServiceMessageSender createMessageSender() { + return new SignalServiceMessageSender(serviceEnvironmentConfig.getSignalServiceConfiguration(), + account.getUuid(), + account.getUsername(), + account.getPassword(), + account.getDeviceId(), + account.getSignalProtocolStore(), + userAgent, + account.isMultiDevice(), + Optional.fromNullable(messagePipe), + Optional.fromNullable(unidentifiedMessagePipe), + Optional.absent(), + clientZkProfileOperations, + executor, + ServiceConfig.MAX_ENVELOPE_SIZE, + ServiceConfig.AUTOMATIC_NETWORK_RETRY); + } + + public SignalProfile getRecipientProfile( + SignalServiceAddress address + ) { + return getRecipientProfile(address, false); + } + + private SignalProfile getRecipientProfile( + SignalServiceAddress address, boolean force + ) { + var profileEntry = account.getProfileStore().getProfileEntry(address); + if (profileEntry == null) { + return null; + } + var now = new Date().getTime(); + // Profiles are cached for 24h before retrieving them again + if (!profileEntry.isRequestPending() && ( + force + || profileEntry.getProfile() == null + || now - profileEntry.getLastUpdateTimestamp() > 24 * 60 * 60 * 1000 + )) { + profileEntry.setRequestPending(true); + final SignalServiceProfile encryptedProfile; + try { + encryptedProfile = profileHelper.retrieveProfileSync(address, SignalServiceProfile.RequestType.PROFILE) + .getProfile(); + } catch (IOException e) { + logger.warn("Failed to retrieve profile, ignoring: {}", e.getMessage()); + return null; + } finally { + profileEntry.setRequestPending(false); + } + + final var profileKey = profileEntry.getProfileKey(); + final var profile = decryptProfileAndDownloadAvatar(address, profileKey, encryptedProfile); + account.getProfileStore() + .updateProfile(address, profileKey, now, profile, profileEntry.getProfileKeyCredential()); + return profile; + } + return profileEntry.getProfile(); + } + + private ProfileKeyCredential getRecipientProfileKeyCredential(SignalServiceAddress address) { + var profileEntry = account.getProfileStore().getProfileEntry(address); + if (profileEntry == null) { + return null; + } + if (profileEntry.getProfileKeyCredential() == null) { + ProfileAndCredential profileAndCredential; + try { + profileAndCredential = profileHelper.retrieveProfileSync(address, + SignalServiceProfile.RequestType.PROFILE_AND_CREDENTIAL); + } catch (IOException e) { + logger.warn("Failed to retrieve profile key credential, ignoring: {}", e.getMessage()); + return null; + } + + var now = new Date().getTime(); + final var profileKeyCredential = profileAndCredential.getProfileKeyCredential().orNull(); + final var profile = decryptProfileAndDownloadAvatar(address, + profileEntry.getProfileKey(), + profileAndCredential.getProfile()); + account.getProfileStore() + .updateProfile(address, profileEntry.getProfileKey(), now, profile, profileKeyCredential); + return profileKeyCredential; + } + return profileEntry.getProfileKeyCredential(); + } + + private SignalProfile decryptProfileAndDownloadAvatar( + final SignalServiceAddress address, final ProfileKey profileKey, final SignalServiceProfile encryptedProfile + ) { + if (encryptedProfile.getAvatar() != null) { + downloadProfileAvatar(address, encryptedProfile.getAvatar(), profileKey); + } + + return ProfileUtils.decryptProfile(profileKey, encryptedProfile); + } + + private Optional createGroupAvatarAttachment(GroupId groupId) throws IOException { + final var streamDetails = avatarStore.retrieveGroupAvatar(groupId); + if (streamDetails == null) { + return Optional.absent(); + } + + return Optional.of(AttachmentUtils.createAttachment(streamDetails, Optional.absent())); + } + + private Optional createContactAvatarAttachment(SignalServiceAddress address) throws IOException { + final var streamDetails = avatarStore.retrieveContactAvatar(address); + if (streamDetails == null) { + return Optional.absent(); + } + + return Optional.of(AttachmentUtils.createAttachment(streamDetails, Optional.absent())); + } + + private GroupInfo getGroupForSending(GroupId groupId) throws GroupNotFoundException, NotAGroupMemberException { + var g = getGroup(groupId); + if (g == null) { + throw new GroupNotFoundException(groupId); + } + if (!g.isMember(account.getSelfAddress())) { + throw new NotAGroupMemberException(groupId, g.getTitle()); + } + return g; + } + + private GroupInfo getGroupForUpdating(GroupId groupId) throws GroupNotFoundException, NotAGroupMemberException { + var g = getGroup(groupId); + if (g == null) { + throw new GroupNotFoundException(groupId); + } + if (!g.isMember(account.getSelfAddress()) && !g.isPendingMember(account.getSelfAddress())) { + throw new NotAGroupMemberException(groupId, g.getTitle()); + } + return g; + } + + public List getGroups() { + return account.getGroupStore().getGroups(); + } + + public Pair> sendGroupMessage( + String messageText, List attachments, GroupId groupId + ) throws IOException, GroupNotFoundException, AttachmentInvalidException, NotAGroupMemberException { + final var messageBuilder = SignalServiceDataMessage.newBuilder().withBody(messageText); + if (attachments != null) { + messageBuilder.withAttachments(AttachmentUtils.getSignalServiceAttachments(attachments)); + } + + return sendGroupMessage(messageBuilder, groupId); + } + + public Pair> sendGroupMessageReaction( + String emoji, boolean remove, String targetAuthor, long targetSentTimestamp, GroupId groupId + ) throws IOException, InvalidNumberException, NotAGroupMemberException, GroupNotFoundException { + var reaction = new SignalServiceDataMessage.Reaction(emoji, + remove, + canonicalizeAndResolveSignalServiceAddress(targetAuthor), + targetSentTimestamp); + final var messageBuilder = SignalServiceDataMessage.newBuilder().withReaction(reaction); + + return sendGroupMessage(messageBuilder, groupId); + } + + public Pair> sendGroupMessage( + SignalServiceDataMessage.Builder messageBuilder, GroupId groupId + ) throws IOException, GroupNotFoundException, NotAGroupMemberException { + final var g = getGroupForSending(groupId); + + GroupUtils.setGroupContext(messageBuilder, g); + messageBuilder.withExpiration(g.getMessageExpirationTime()); + + return sendMessage(messageBuilder, g.getMembersWithout(account.getSelfAddress())); + } + + public Pair> sendQuitGroupMessage(GroupId groupId) throws GroupNotFoundException, IOException, NotAGroupMemberException { + SignalServiceDataMessage.Builder messageBuilder; + + final var g = getGroupForUpdating(groupId); + if (g instanceof GroupInfoV1) { + var groupInfoV1 = (GroupInfoV1) g; + var group = SignalServiceGroup.newBuilder(SignalServiceGroup.Type.QUIT).withId(groupId.serialize()).build(); + messageBuilder = SignalServiceDataMessage.newBuilder().asGroupMessage(group); + groupInfoV1.removeMember(account.getSelfAddress()); + account.getGroupStore().updateGroup(groupInfoV1); + } else { + final var groupInfoV2 = (GroupInfoV2) g; + final var groupGroupChangePair = groupHelper.leaveGroup(groupInfoV2); + groupInfoV2.setGroup(groupGroupChangePair.first()); + messageBuilder = getGroupUpdateMessageBuilder(groupInfoV2, groupGroupChangePair.second().toByteArray()); + account.getGroupStore().updateGroup(groupInfoV2); + } + + return sendMessage(messageBuilder, g.getMembersWithout(account.getSelfAddress())); + } + + public Pair> updateGroup( + GroupId groupId, String name, List members, File avatarFile + ) throws IOException, GroupNotFoundException, AttachmentInvalidException, InvalidNumberException, NotAGroupMemberException { + return sendUpdateGroupMessage(groupId, + name, + members == null ? null : getSignalServiceAddresses(members), + avatarFile); + } + + private Pair> sendUpdateGroupMessage( + GroupId groupId, String name, Collection members, File avatarFile + ) throws IOException, GroupNotFoundException, AttachmentInvalidException, NotAGroupMemberException { + GroupInfo g; + SignalServiceDataMessage.Builder messageBuilder; + if (groupId == null) { + // Create new group + var gv2 = groupHelper.createGroupV2(name == null ? "" : name, + members == null ? List.of() : members, + avatarFile); + if (gv2 == null) { + var gv1 = new GroupInfoV1(GroupIdV1.createRandom()); + gv1.addMembers(List.of(account.getSelfAddress())); + updateGroupV1(gv1, name, members, avatarFile); + messageBuilder = getGroupUpdateMessageBuilder(gv1); + g = gv1; + } else { + if (avatarFile != null) { + avatarStore.storeGroupAvatar(gv2.getGroupId(), + outputStream -> IOUtils.copyFileToStream(avatarFile, outputStream)); + } + messageBuilder = getGroupUpdateMessageBuilder(gv2, null); + g = gv2; + } + } else { + var group = getGroupForUpdating(groupId); + if (group instanceof GroupInfoV2) { + final var groupInfoV2 = (GroupInfoV2) group; + + Pair> result = null; + if (groupInfoV2.isPendingMember(getSelfAddress())) { + var groupGroupChangePair = groupHelper.acceptInvite(groupInfoV2); + result = sendUpdateGroupMessage(groupInfoV2, + groupGroupChangePair.first(), + groupGroupChangePair.second()); + } + + if (members != null) { + final var newMembers = new HashSet<>(members); + newMembers.removeAll(group.getMembers() + .stream() + .map(this::resolveSignalServiceAddress) + .collect(Collectors.toSet())); + if (newMembers.size() > 0) { + var groupGroupChangePair = groupHelper.updateGroupV2(groupInfoV2, newMembers); + result = sendUpdateGroupMessage(groupInfoV2, + groupGroupChangePair.first(), + groupGroupChangePair.second()); + } + } + if (result == null || name != null || avatarFile != null) { + var groupGroupChangePair = groupHelper.updateGroupV2(groupInfoV2, name, avatarFile); + if (avatarFile != null) { + avatarStore.storeGroupAvatar(groupInfoV2.getGroupId(), + outputStream -> IOUtils.copyFileToStream(avatarFile, outputStream)); + } + result = sendUpdateGroupMessage(groupInfoV2, + groupGroupChangePair.first(), + groupGroupChangePair.second()); + } + + return new Pair<>(group.getGroupId(), result.second()); + } else { + var gv1 = (GroupInfoV1) group; + updateGroupV1(gv1, name, members, avatarFile); + messageBuilder = getGroupUpdateMessageBuilder(gv1); + g = gv1; + } + } + + account.getGroupStore().updateGroup(g); + + final var result = sendMessage(messageBuilder, g.getMembersIncludingPendingWithout(account.getSelfAddress())); + return new Pair<>(g.getGroupId(), result.second()); + } + + private void updateGroupV1( + final GroupInfoV1 g, + final String name, + final Collection members, + final File avatarFile + ) throws IOException { + if (name != null) { + g.name = name; + } + + if (members != null) { + final var newE164Members = new HashSet(); + for (var member : members) { + if (g.isMember(member) || !member.getNumber().isPresent()) { + continue; + } + newE164Members.add(member.getNumber().get()); + } + + final var registeredUsers = getRegisteredUsers(newE164Members); + if (registeredUsers.size() != newE164Members.size()) { + // Some of the new members are not registered on Signal + newE164Members.removeAll(registeredUsers.keySet()); + throw new IOException("Failed to add members " + + String.join(", ", newE164Members) + + " to group: Not registered on Signal"); + } + + g.addMembers(members); + } + + if (avatarFile != null) { + avatarStore.storeGroupAvatar(g.getGroupId(), + outputStream -> IOUtils.copyFileToStream(avatarFile, outputStream)); + } + } + + public Pair> joinGroup( + GroupInviteLinkUrl inviteLinkUrl + ) throws IOException, GroupLinkNotActiveException { + return sendJoinGroupMessage(inviteLinkUrl); + } + + private Pair> sendJoinGroupMessage( + GroupInviteLinkUrl inviteLinkUrl + ) throws IOException, GroupLinkNotActiveException { + final var groupJoinInfo = groupHelper.getDecryptedGroupJoinInfo(inviteLinkUrl.getGroupMasterKey(), + inviteLinkUrl.getPassword()); + final var groupChange = groupHelper.joinGroup(inviteLinkUrl.getGroupMasterKey(), + inviteLinkUrl.getPassword(), + groupJoinInfo); + final var group = getOrMigrateGroup(inviteLinkUrl.getGroupMasterKey(), + groupJoinInfo.getRevision() + 1, + groupChange.toByteArray()); + + if (group.getGroup() == null) { + // Only requested member, can't send update to group members + return new Pair<>(group.getGroupId(), List.of()); + } + + final var result = sendUpdateGroupMessage(group, group.getGroup(), groupChange); + + return new Pair<>(group.getGroupId(), result.second()); + } + + private static int currentTimeDays() { + return (int) TimeUnit.MILLISECONDS.toDays(System.currentTimeMillis()); + } + + private GroupsV2AuthorizationString getGroupAuthForToday( + final GroupSecretParams groupSecretParams + ) throws IOException { + final var today = currentTimeDays(); + // Returns credentials for the next 7 days + final var credentials = groupsV2Api.getCredentials(today); + // TODO cache credentials until they expire + var authCredentialResponse = credentials.get(today); + try { + return groupsV2Api.getGroupsV2AuthorizationString(account.getUuid(), + today, + groupSecretParams, + authCredentialResponse); + } catch (VerificationFailedException e) { + throw new IOException(e); + } + } + + private Pair> sendUpdateGroupMessage( + GroupInfoV2 group, DecryptedGroup newDecryptedGroup, GroupChange groupChange + ) throws IOException { + group.setGroup(newDecryptedGroup); + final var messageBuilder = getGroupUpdateMessageBuilder(group, groupChange.toByteArray()); + account.getGroupStore().updateGroup(group); + return sendMessage(messageBuilder, group.getMembersIncludingPendingWithout(account.getSelfAddress())); + } + + Pair> sendGroupInfoMessage( + GroupIdV1 groupId, SignalServiceAddress recipient + ) throws IOException, NotAGroupMemberException, GroupNotFoundException, AttachmentInvalidException { + GroupInfoV1 g; + var group = getGroupForSending(groupId); + if (!(group instanceof GroupInfoV1)) { + throw new RuntimeException("Received an invalid group request for a v2 group!"); + } + g = (GroupInfoV1) group; + + if (!g.isMember(recipient)) { + throw new NotAGroupMemberException(groupId, g.name); + } + + var messageBuilder = getGroupUpdateMessageBuilder(g); + + // Send group message only to the recipient who requested it + return sendMessage(messageBuilder, List.of(recipient)); + } + + private SignalServiceDataMessage.Builder getGroupUpdateMessageBuilder(GroupInfoV1 g) throws AttachmentInvalidException { + var group = SignalServiceGroup.newBuilder(SignalServiceGroup.Type.UPDATE) + .withId(g.getGroupId().serialize()) + .withName(g.name) + .withMembers(new ArrayList<>(g.getMembers())); + + try { + final var attachment = createGroupAvatarAttachment(g.getGroupId()); + if (attachment.isPresent()) { + group.withAvatar(attachment.get()); + } + } catch (IOException e) { + throw new AttachmentInvalidException(g.getGroupId().toBase64(), e); + } + + return SignalServiceDataMessage.newBuilder() + .asGroupMessage(group.build()) + .withExpiration(g.getMessageExpirationTime()); + } + + private SignalServiceDataMessage.Builder getGroupUpdateMessageBuilder(GroupInfoV2 g, byte[] signedGroupChange) { + var group = SignalServiceGroupV2.newBuilder(g.getMasterKey()) + .withRevision(g.getGroup().getRevision()) + .withSignedGroupChange(signedGroupChange); + return SignalServiceDataMessage.newBuilder() + .asGroupMessage(group.build()) + .withExpiration(g.getMessageExpirationTime()); + } + + Pair> sendGroupInfoRequest( + GroupIdV1 groupId, SignalServiceAddress recipient + ) throws IOException { + var group = SignalServiceGroup.newBuilder(SignalServiceGroup.Type.REQUEST_INFO).withId(groupId.serialize()); + + var messageBuilder = SignalServiceDataMessage.newBuilder().asGroupMessage(group.build()); + + // Send group info request message to the recipient who sent us a message with this groupId + return sendMessage(messageBuilder, List.of(recipient)); + } + + void sendReceipt( + SignalServiceAddress remoteAddress, long messageId + ) throws IOException, UntrustedIdentityException { + var receiptMessage = new SignalServiceReceiptMessage(SignalServiceReceiptMessage.Type.DELIVERY, + List.of(messageId), + System.currentTimeMillis()); + + createMessageSender().sendReceipt(remoteAddress, + unidentifiedAccessHelper.getAccessFor(remoteAddress), + receiptMessage); + } + + public Pair> sendMessage( + String messageText, List attachments, List recipients + ) throws IOException, AttachmentInvalidException, InvalidNumberException { + final var messageBuilder = SignalServiceDataMessage.newBuilder().withBody(messageText); + if (attachments != null) { + var attachmentStreams = AttachmentUtils.getSignalServiceAttachments(attachments); + + // Upload attachments here, so we only upload once even for multiple recipients + var messageSender = createMessageSender(); + var attachmentPointers = new ArrayList(attachmentStreams.size()); + for (var attachment : attachmentStreams) { + if (attachment.isStream()) { + attachmentPointers.add(messageSender.uploadAttachment(attachment.asStream())); + } else if (attachment.isPointer()) { + attachmentPointers.add(attachment.asPointer()); + } + } + + messageBuilder.withAttachments(attachmentPointers); + } + return sendMessage(messageBuilder, getSignalServiceAddresses(recipients)); + } + + public Pair sendSelfMessage( + String messageText, List attachments + ) throws IOException, AttachmentInvalidException { + final var messageBuilder = SignalServiceDataMessage.newBuilder().withBody(messageText); + if (attachments != null) { + messageBuilder.withAttachments(AttachmentUtils.getSignalServiceAttachments(attachments)); + } + return sendSelfMessage(messageBuilder); + } + + public Pair> sendRemoteDeleteMessage( + long targetSentTimestamp, List recipients + ) throws IOException, InvalidNumberException { + var delete = new SignalServiceDataMessage.RemoteDelete(targetSentTimestamp); + final var messageBuilder = SignalServiceDataMessage.newBuilder().withRemoteDelete(delete); + return sendMessage(messageBuilder, getSignalServiceAddresses(recipients)); + } + + public Pair> sendGroupRemoteDeleteMessage( + long targetSentTimestamp, GroupId groupId + ) throws IOException, NotAGroupMemberException, GroupNotFoundException { + var delete = new SignalServiceDataMessage.RemoteDelete(targetSentTimestamp); + final var messageBuilder = SignalServiceDataMessage.newBuilder().withRemoteDelete(delete); + return sendGroupMessage(messageBuilder, groupId); + } + + public Pair> sendMessageReaction( + String emoji, boolean remove, String targetAuthor, long targetSentTimestamp, List recipients + ) throws IOException, InvalidNumberException { + var reaction = new SignalServiceDataMessage.Reaction(emoji, + remove, + canonicalizeAndResolveSignalServiceAddress(targetAuthor), + targetSentTimestamp); + final var messageBuilder = SignalServiceDataMessage.newBuilder().withReaction(reaction); + return sendMessage(messageBuilder, getSignalServiceAddresses(recipients)); + } + + public Pair> sendEndSessionMessage(List recipients) throws IOException, InvalidNumberException { + var messageBuilder = SignalServiceDataMessage.newBuilder().asEndSessionMessage(); + + final var signalServiceAddresses = getSignalServiceAddresses(recipients); + try { + return sendMessage(messageBuilder, signalServiceAddresses); + } catch (Exception e) { + for (var address : signalServiceAddresses) { + handleEndSession(address); + } + account.save(); + throw e; + } + } + + public String getContactName(String number) throws InvalidNumberException { + var contact = account.getContactStore().getContact(canonicalizeAndResolveSignalServiceAddress(number)); + if (contact == null) { + return ""; + } else { + return contact.name; + } + } + + public void setContactName(String number, String name) throws InvalidNumberException { + final var address = canonicalizeAndResolveSignalServiceAddress(number); + var contact = account.getContactStore().getContact(address); + if (contact == null) { + contact = new ContactInfo(address); + } + contact.name = name; + account.getContactStore().updateContact(contact); + account.save(); + } + + public void setContactBlocked(String number, boolean blocked) throws InvalidNumberException { + setContactBlocked(canonicalizeAndResolveSignalServiceAddress(number), blocked); + } + + private void setContactBlocked(SignalServiceAddress address, boolean blocked) { + var contact = account.getContactStore().getContact(address); + if (contact == null) { + contact = new ContactInfo(address); + } + contact.blocked = blocked; + account.getContactStore().updateContact(contact); + account.save(); + } + + public void setGroupBlocked(final GroupId groupId, final boolean blocked) throws GroupNotFoundException { + var group = getGroup(groupId); + if (group == null) { + throw new GroupNotFoundException(groupId); + } + + group.setBlocked(blocked); + account.getGroupStore().updateGroup(group); + account.save(); + } + + /** + * Change the expiration timer for a contact + */ + public void setExpirationTimer(SignalServiceAddress address, int messageExpirationTimer) throws IOException { + var contact = account.getContactStore().getContact(address); + contact.messageExpirationTime = messageExpirationTimer; + account.getContactStore().updateContact(contact); + sendExpirationTimerUpdate(address); + account.save(); + } + + private void sendExpirationTimerUpdate(SignalServiceAddress address) throws IOException { + final var messageBuilder = SignalServiceDataMessage.newBuilder().asExpirationUpdate(); + sendMessage(messageBuilder, List.of(address)); + } + + /** + * Change the expiration timer for a contact + */ + public void setExpirationTimer( + String number, int messageExpirationTimer + ) throws IOException, InvalidNumberException { + var address = canonicalizeAndResolveSignalServiceAddress(number); + setExpirationTimer(address, messageExpirationTimer); + } + + /** + * Change the expiration timer for a group + */ + public void setExpirationTimer(GroupId groupId, int messageExpirationTimer) { + var g = getGroup(groupId); + if (g instanceof GroupInfoV1) { + var groupInfoV1 = (GroupInfoV1) g; + groupInfoV1.messageExpirationTime = messageExpirationTimer; + account.getGroupStore().updateGroup(groupInfoV1); + } else { + throw new RuntimeException("TODO Not implemented!"); + } + } + + /** + * Upload the sticker pack from path. + * + * @param path Path can be a path to a manifest.json file or to a zip file that contains a manifest.json file + * @return if successful, returns the URL to install the sticker pack in the signal app + */ + public String uploadStickerPack(File path) throws IOException, StickerPackInvalidException { + var manifest = StickerUtils.getSignalServiceStickerManifestUpload(path); + + var messageSender = createMessageSender(); + + var packKey = KeyUtils.createStickerUploadKey(); + var packId = messageSender.uploadStickerManifest(manifest, packKey); + + var sticker = new Sticker(Hex.fromStringCondensed(packId), packKey); + account.getStickerStore().updateSticker(sticker); + account.save(); + + try { + return new URI("https", + "signal.art", + "/addstickers/", + "pack_id=" + URLEncoder.encode(packId, StandardCharsets.UTF_8) + "&pack_key=" + URLEncoder.encode( + Hex.toStringCondensed(packKey), + StandardCharsets.UTF_8)).toString(); + } catch (URISyntaxException e) { + throw new AssertionError(e); + } + } + + void requestSyncGroups() throws IOException { + var r = SignalServiceProtos.SyncMessage.Request.newBuilder() + .setType(SignalServiceProtos.SyncMessage.Request.Type.GROUPS) + .build(); + var message = SignalServiceSyncMessage.forRequest(new RequestMessage(r)); + try { + sendSyncMessage(message); + } catch (UntrustedIdentityException e) { + throw new AssertionError(e); + } + } + + void requestSyncContacts() throws IOException { + var r = SignalServiceProtos.SyncMessage.Request.newBuilder() + .setType(SignalServiceProtos.SyncMessage.Request.Type.CONTACTS) + .build(); + var message = SignalServiceSyncMessage.forRequest(new RequestMessage(r)); + try { + sendSyncMessage(message); + } catch (UntrustedIdentityException e) { + throw new AssertionError(e); + } + } + + void requestSyncBlocked() throws IOException { + var r = SignalServiceProtos.SyncMessage.Request.newBuilder() + .setType(SignalServiceProtos.SyncMessage.Request.Type.BLOCKED) + .build(); + var message = SignalServiceSyncMessage.forRequest(new RequestMessage(r)); + try { + sendSyncMessage(message); + } catch (UntrustedIdentityException e) { + throw new AssertionError(e); + } + } + + void requestSyncConfiguration() throws IOException { + var r = SignalServiceProtos.SyncMessage.Request.newBuilder() + .setType(SignalServiceProtos.SyncMessage.Request.Type.CONFIGURATION) + .build(); + var message = SignalServiceSyncMessage.forRequest(new RequestMessage(r)); + try { + sendSyncMessage(message); + } catch (UntrustedIdentityException e) { + throw new AssertionError(e); + } + } + + void requestSyncKeys() throws IOException { + var r = SignalServiceProtos.SyncMessage.Request.newBuilder() + .setType(SignalServiceProtos.SyncMessage.Request.Type.KEYS) + .build(); + var message = SignalServiceSyncMessage.forRequest(new RequestMessage(r)); + try { + sendSyncMessage(message); + } catch (UntrustedIdentityException e) { + throw new AssertionError(e); + } + } + + private byte[] getSenderCertificate() { + // TODO support UUID capable sender certificates + // byte[] certificate = accountManager.getSenderCertificateForPhoneNumberPrivacy(); + byte[] certificate; + try { + certificate = accountManager.getSenderCertificate(); + } catch (IOException e) { + logger.warn("Failed to get sender certificate, ignoring: {}", e.getMessage()); + return null; + } + // TODO cache for a day + return certificate; + } + + private void sendSyncMessage(SignalServiceSyncMessage message) throws IOException, UntrustedIdentityException { + var messageSender = createMessageSender(); + try { + messageSender.sendMessage(message, unidentifiedAccessHelper.getAccessForSync()); + } catch (UntrustedIdentityException e) { + if (e.getIdentityKey() != null) { + account.getSignalProtocolStore() + .saveIdentity(resolveSignalServiceAddress(e.getIdentifier()), + e.getIdentityKey(), + TrustLevel.UNTRUSTED); + } + throw e; + } + } + + private Collection getSignalServiceAddresses(Collection numbers) throws InvalidNumberException { + final var signalServiceAddresses = new HashSet(numbers.size()); + final var addressesMissingUuid = new HashSet(); + + for (var number : numbers) { + final var resolvedAddress = canonicalizeAndResolveSignalServiceAddress(number); + if (resolvedAddress.getUuid().isPresent()) { + signalServiceAddresses.add(resolvedAddress); + } else { + addressesMissingUuid.add(resolvedAddress); + } + } + + final var numbersMissingUuid = addressesMissingUuid.stream() + .map(a -> a.getNumber().get()) + .collect(Collectors.toSet()); + Map registeredUsers; + try { + registeredUsers = getRegisteredUsers(numbersMissingUuid); + } catch (IOException e) { + logger.warn("Failed to resolve uuids from server, ignoring: {}", e.getMessage()); + registeredUsers = Map.of(); + } + + for (var address : addressesMissingUuid) { + final var number = address.getNumber().get(); + if (registeredUsers.containsKey(number)) { + final var newAddress = resolveSignalServiceAddress(new SignalServiceAddress(registeredUsers.get(number), + number)); + signalServiceAddresses.add(newAddress); + } else { + signalServiceAddresses.add(address); + } + } + + return signalServiceAddresses; + } + + private Map getRegisteredUsers(final Set numbersMissingUuid) throws IOException { + try { + return accountManager.getRegisteredUsers(ServiceConfig.getIasKeyStore(), + numbersMissingUuid, + serviceEnvironmentConfig.getCdsMrenclave()); + } catch (Quote.InvalidQuoteFormatException | UnauthenticatedQuoteException | SignatureException | UnauthenticatedResponseException | InvalidKeyException e) { + throw new IOException(e); + } + } + + private Pair> sendMessage( + SignalServiceDataMessage.Builder messageBuilder, Collection recipients + ) throws IOException { + recipients = recipients.stream().map(this::resolveSignalServiceAddress).collect(Collectors.toSet()); + final var timestamp = System.currentTimeMillis(); + messageBuilder.withTimestamp(timestamp); + getOrCreateMessagePipe(); + getOrCreateUnidentifiedMessagePipe(); + SignalServiceDataMessage message = null; + try { + message = messageBuilder.build(); + if (message.getGroupContext().isPresent()) { + try { + var messageSender = createMessageSender(); + final var isRecipientUpdate = false; + var result = messageSender.sendMessage(new ArrayList<>(recipients), + unidentifiedAccessHelper.getAccessFor(recipients), + isRecipientUpdate, + message); + for (var r : result) { + if (r.getIdentityFailure() != null) { + account.getSignalProtocolStore() + .saveIdentity(r.getAddress(), + r.getIdentityFailure().getIdentityKey(), + TrustLevel.UNTRUSTED); + } + } + return new Pair<>(timestamp, result); + } catch (UntrustedIdentityException e) { + if (e.getIdentityKey() != null) { + account.getSignalProtocolStore() + .saveIdentity(resolveSignalServiceAddress(e.getIdentifier()), + e.getIdentityKey(), + TrustLevel.UNTRUSTED); + } + return new Pair<>(timestamp, List.of()); + } + } else { + // Send to all individually, so sync messages are sent correctly + messageBuilder.withProfileKey(account.getProfileKey().serialize()); + var results = new ArrayList(recipients.size()); + for (var address : recipients) { + final var contact = account.getContactStore().getContact(address); + final var expirationTime = contact != null ? contact.messageExpirationTime : 0; + messageBuilder.withExpiration(expirationTime); + message = messageBuilder.build(); + results.add(sendMessage(address, message)); + } + return new Pair<>(timestamp, results); + } + } finally { + if (message != null && message.isEndSession()) { + for (var recipient : recipients) { + handleEndSession(recipient); + } + } + account.save(); + } + } + + private Pair sendSelfMessage( + SignalServiceDataMessage.Builder messageBuilder + ) throws IOException { + final var timestamp = System.currentTimeMillis(); + messageBuilder.withTimestamp(timestamp); + getOrCreateMessagePipe(); + getOrCreateUnidentifiedMessagePipe(); + try { + final var address = getSelfAddress(); + + final var contact = account.getContactStore().getContact(address); + final var expirationTime = contact != null ? contact.messageExpirationTime : 0; + messageBuilder.withExpiration(expirationTime); + + var message = messageBuilder.build(); + final var result = sendSelfMessage(message); + return new Pair<>(timestamp, result); + } finally { + account.save(); + } + } + + private SendMessageResult sendSelfMessage(SignalServiceDataMessage message) throws IOException { + var messageSender = createMessageSender(); + + var recipient = account.getSelfAddress(); + + final var unidentifiedAccess = unidentifiedAccessHelper.getAccessFor(recipient); + var transcript = new SentTranscriptMessage(Optional.of(recipient), + message.getTimestamp(), + message, + message.getExpiresInSeconds(), + Map.of(recipient, unidentifiedAccess.isPresent()), + false); + var syncMessage = SignalServiceSyncMessage.forSentTranscript(transcript); + + try { + var startTime = System.currentTimeMillis(); + messageSender.sendMessage(syncMessage, unidentifiedAccess); + return SendMessageResult.success(recipient, + unidentifiedAccess.isPresent(), + false, + System.currentTimeMillis() - startTime); + } catch (UntrustedIdentityException e) { + if (e.getIdentityKey() != null) { + account.getSignalProtocolStore() + .saveIdentity(resolveSignalServiceAddress(e.getIdentifier()), + e.getIdentityKey(), + TrustLevel.UNTRUSTED); + } + return SendMessageResult.identityFailure(recipient, e.getIdentityKey()); + } + } + + private SendMessageResult sendMessage( + SignalServiceAddress address, SignalServiceDataMessage message + ) throws IOException { + var messageSender = createMessageSender(); + + try { + return messageSender.sendMessage(address, unidentifiedAccessHelper.getAccessFor(address), message); + } catch (UntrustedIdentityException e) { + if (e.getIdentityKey() != null) { + account.getSignalProtocolStore() + .saveIdentity(resolveSignalServiceAddress(e.getIdentifier()), + e.getIdentityKey(), + TrustLevel.UNTRUSTED); + } + return SendMessageResult.identityFailure(address, e.getIdentityKey()); + } + } + + private SignalServiceContent decryptMessage(SignalServiceEnvelope envelope) throws InvalidMetadataMessageException, ProtocolInvalidMessageException, ProtocolDuplicateMessageException, ProtocolLegacyMessageException, ProtocolInvalidKeyIdException, InvalidMetadataVersionException, ProtocolInvalidVersionException, ProtocolNoSessionException, ProtocolInvalidKeyException, SelfSendException, UnsupportedDataMessageException, org.whispersystems.libsignal.UntrustedIdentityException { + var cipher = new SignalServiceCipher(account.getSelfAddress(), + account.getSignalProtocolStore(), + certificateValidator); + try { + return cipher.decrypt(envelope); + } catch (ProtocolUntrustedIdentityException e) { + if (e.getCause() instanceof org.whispersystems.libsignal.UntrustedIdentityException) { + var identityException = (org.whispersystems.libsignal.UntrustedIdentityException) e.getCause(); + final var untrustedIdentity = identityException.getUntrustedIdentity(); + if (untrustedIdentity != null) { + account.getSignalProtocolStore() + .saveIdentity(resolveSignalServiceAddress(identityException.getName()), + untrustedIdentity, + TrustLevel.UNTRUSTED); + } + throw identityException; + } + throw new AssertionError(e); + } + } + + private void handleEndSession(SignalServiceAddress source) { + account.getSignalProtocolStore().deleteAllSessions(source); + } + + private List handleSignalServiceDataMessage( + SignalServiceDataMessage message, + boolean isSync, + SignalServiceAddress source, + SignalServiceAddress destination, + boolean ignoreAttachments + ) { + var actions = new ArrayList(); + if (message.getGroupContext().isPresent()) { + if (message.getGroupContext().get().getGroupV1().isPresent()) { + var groupInfo = message.getGroupContext().get().getGroupV1().get(); + var groupId = GroupId.v1(groupInfo.getGroupId()); + var group = getGroup(groupId); + if (group == null || group instanceof GroupInfoV1) { + var groupV1 = (GroupInfoV1) group; + switch (groupInfo.getType()) { + case UPDATE: { + if (groupV1 == null) { + groupV1 = new GroupInfoV1(groupId); + } + + if (groupInfo.getAvatar().isPresent()) { + var avatar = groupInfo.getAvatar().get(); + downloadGroupAvatar(avatar, groupV1.getGroupId()); + } + + if (groupInfo.getName().isPresent()) { + groupV1.name = groupInfo.getName().get(); + } + + if (groupInfo.getMembers().isPresent()) { + groupV1.addMembers(groupInfo.getMembers() + .get() + .stream() + .map(this::resolveSignalServiceAddress) + .collect(Collectors.toSet())); + } + + account.getGroupStore().updateGroup(groupV1); + break; + } + case DELIVER: + if (groupV1 == null && !isSync) { + actions.add(new SendGroupInfoRequestAction(source, groupId)); + } + break; + case QUIT: { + if (groupV1 != null) { + groupV1.removeMember(source); + account.getGroupStore().updateGroup(groupV1); + } + break; + } + case REQUEST_INFO: + if (groupV1 != null && !isSync) { + actions.add(new SendGroupInfoAction(source, groupV1.getGroupId())); + } + break; + } + } else { + // Received a group v1 message for a v2 group + } + } + if (message.getGroupContext().get().getGroupV2().isPresent()) { + final var groupContext = message.getGroupContext().get().getGroupV2().get(); + final var groupMasterKey = groupContext.getMasterKey(); + + getOrMigrateGroup(groupMasterKey, + groupContext.getRevision(), + groupContext.hasSignedGroupChange() ? groupContext.getSignedGroupChange() : null); + } + } + + final var conversationPartnerAddress = isSync ? destination : source; + if (conversationPartnerAddress != null && message.isEndSession()) { + handleEndSession(conversationPartnerAddress); + } + if (message.isExpirationUpdate() || message.getBody().isPresent()) { + if (message.getGroupContext().isPresent()) { + if (message.getGroupContext().get().getGroupV1().isPresent()) { + var groupInfo = message.getGroupContext().get().getGroupV1().get(); + var group = account.getGroupStore().getOrCreateGroupV1(GroupId.v1(groupInfo.getGroupId())); + if (group != null) { + if (group.messageExpirationTime != message.getExpiresInSeconds()) { + group.messageExpirationTime = message.getExpiresInSeconds(); + account.getGroupStore().updateGroup(group); + } + } + } else if (message.getGroupContext().get().getGroupV2().isPresent()) { + // disappearing message timer already stored in the DecryptedGroup + } + } else if (conversationPartnerAddress != null) { + var contact = account.getContactStore().getContact(conversationPartnerAddress); + if (contact == null) { + contact = new ContactInfo(conversationPartnerAddress); + } + if (contact.messageExpirationTime != message.getExpiresInSeconds()) { + contact.messageExpirationTime = message.getExpiresInSeconds(); + account.getContactStore().updateContact(contact); + } + } + } + if (!ignoreAttachments) { + if (message.getAttachments().isPresent()) { + for (var attachment : message.getAttachments().get()) { + downloadAttachment(attachment); + } + } + if (message.getSharedContacts().isPresent()) { + for (var contact : message.getSharedContacts().get()) { + if (contact.getAvatar().isPresent()) { + downloadAttachment(contact.getAvatar().get().getAttachment()); + } + } + } + } + if (message.getProfileKey().isPresent() && message.getProfileKey().get().length == 32) { + final ProfileKey profileKey; + try { + profileKey = new ProfileKey(message.getProfileKey().get()); + } catch (InvalidInputException e) { + throw new AssertionError(e); + } + if (source.matches(account.getSelfAddress())) { + this.account.setProfileKey(profileKey); + } + this.account.getProfileStore().storeProfileKey(source, profileKey); + } + if (message.getPreviews().isPresent()) { + final var previews = message.getPreviews().get(); + for (var preview : previews) { + if (preview.getImage().isPresent()) { + downloadAttachment(preview.getImage().get()); + } + } + } + if (message.getQuote().isPresent()) { + final var quote = message.getQuote().get(); + + for (var quotedAttachment : quote.getAttachments()) { + final var thumbnail = quotedAttachment.getThumbnail(); + if (thumbnail != null) { + downloadAttachment(thumbnail); + } + } + } + if (message.getSticker().isPresent()) { + final var messageSticker = message.getSticker().get(); + var sticker = account.getStickerStore().getSticker(messageSticker.getPackId()); + if (sticker == null) { + sticker = new Sticker(messageSticker.getPackId(), messageSticker.getPackKey()); + account.getStickerStore().updateSticker(sticker); + } + } + return actions; + } + + private GroupInfoV2 getOrMigrateGroup( + final GroupMasterKey groupMasterKey, final int revision, final byte[] signedGroupChange + ) { + final var groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupMasterKey); + + var groupId = GroupUtils.getGroupIdV2(groupSecretParams); + var groupInfo = getGroup(groupId); + final GroupInfoV2 groupInfoV2; + if (groupInfo instanceof GroupInfoV1) { + // Received a v2 group message for a v1 group, we need to locally migrate the group + account.getGroupStore().deleteGroup(groupInfo.getGroupId()); + groupInfoV2 = new GroupInfoV2(groupId, groupMasterKey); + logger.info("Locally migrated group {} to group v2, id: {}", + groupInfo.getGroupId().toBase64(), + groupInfoV2.getGroupId().toBase64()); + } else if (groupInfo instanceof GroupInfoV2) { + groupInfoV2 = (GroupInfoV2) groupInfo; + } else { + groupInfoV2 = new GroupInfoV2(groupId, groupMasterKey); + } + + if (groupInfoV2.getGroup() == null || groupInfoV2.getGroup().getRevision() < revision) { + DecryptedGroup group = null; + if (signedGroupChange != null + && groupInfoV2.getGroup() != null + && groupInfoV2.getGroup().getRevision() + 1 == revision) { + group = groupHelper.getUpdatedDecryptedGroup(groupInfoV2.getGroup(), signedGroupChange, groupMasterKey); + } + if (group == null) { + group = groupHelper.getDecryptedGroup(groupSecretParams); + } + if (group != null) { + storeProfileKeysFromMembers(group); + final var avatar = group.getAvatar(); + if (avatar != null && !avatar.isEmpty()) { + downloadGroupAvatar(groupId, groupSecretParams, avatar); + } + } + groupInfoV2.setGroup(group); + account.getGroupStore().updateGroup(groupInfoV2); + } + + return groupInfoV2; + } + + private void storeProfileKeysFromMembers(final DecryptedGroup group) { + for (var member : group.getMembersList()) { + final var address = resolveSignalServiceAddress(new SignalServiceAddress(UuidUtil.parseOrThrow(member.getUuid() + .toByteArray()), null)); + try { + account.getProfileStore() + .storeProfileKey(address, new ProfileKey(member.getProfileKey().toByteArray())); + } catch (InvalidInputException ignored) { + } + } + } + + private void retryFailedReceivedMessages(ReceiveMessageHandler handler, boolean ignoreAttachments) { + for (var cachedMessage : account.getMessageCache().getCachedMessages()) { + retryFailedReceivedMessage(handler, ignoreAttachments, cachedMessage); + } + } + + private void retryFailedReceivedMessage( + final ReceiveMessageHandler handler, final boolean ignoreAttachments, final CachedMessage cachedMessage + ) { + var envelope = cachedMessage.loadEnvelope(); + if (envelope == null) { + return; + } + SignalServiceContent content = null; + if (!envelope.isReceipt()) { + try { + content = decryptMessage(envelope); + } catch (org.whispersystems.libsignal.UntrustedIdentityException e) { + return; + } catch (Exception er) { + // All other errors are not recoverable, so delete the cached message + cachedMessage.delete(); + return; + } + var actions = handleMessage(envelope, content, ignoreAttachments); + for (var action : actions) { + try { + action.execute(this); + } catch (Throwable e) { + logger.warn("Message action failed.", e); + } + } + } + account.save(); + handler.handleMessage(envelope, content, null); + cachedMessage.delete(); + } + + public void receiveMessagesAndReadStdin(long timeout, TimeUnit unit, boolean returnOnTimeout, boolean ignoreAttachments, ReceiveMessageHandler handler) throws IOException { + retryFailedReceivedMessages(handler, ignoreAttachments); + final SignalServiceMessageReceiver messageReceiver = getMessageReceiver(); + + Set queuedActions = null; + + if (messagePipe == null) { + messagePipe = messageReceiver.createMessagePipe(); + } + + boolean hasCaughtUpWithOldMessages = false; + + while (true) { + SignalServiceEnvelope envelope; + SignalServiceContent content = null; + Exception exception = null; + final long now = new Date().getTime(); + try { + Optional result = messagePipe.readOrEmpty(timeout, unit, envelope1 -> { + // store message on disk, before acknowledging receipt to the server + try { + String source = envelope1.getSourceE164().isPresent() ? envelope1.getSourceE164().get() : ""; + File cacheFile = getMessageCacheFile(source, now, envelope1.getTimestamp()); + Utils.storeEnvelope(envelope1, cacheFile); + } catch (IOException e) { + System.err.println("Failed to store encrypted message in disk cache, ignoring: " + e.getMessage()); + } + }); + if (result.isPresent()) { + envelope = result.get(); + } else { + // Received indicator that server queue is empty + hasCaughtUpWithOldMessages = true; + + if (queuedActions != null) { + for (HandleAction action : queuedActions) { + try { + action.execute(this); + } catch (Throwable e) { + e.printStackTrace(); + } + } + queuedActions.clear(); + queuedActions = null; + } + + // Continue to wait another timeout for new messages + continue; + } + } catch (TimeoutException e) { + if (returnOnTimeout) + return; + continue; + } catch (InvalidVersionException e) { + System.err.println("Ignoring error: " + e.getMessage()); + continue; + } + if (envelope.hasSource()) { + // Store uuid if we don't have it already + SignalServiceAddress source = envelope.getSourceAddress(); + resolveSignalServiceAddress(source); + } + if (!envelope.isReceipt()) { + try { + content = decryptMessage(envelope); + } catch (Exception e) { + exception = e; + } + List actions = handleMessage(envelope, content, ignoreAttachments); + if (hasCaughtUpWithOldMessages) { + for (HandleAction action : actions) { + try { + action.execute(this); + } catch (Throwable e) { + e.printStackTrace(); + } + } + } else { + if (queuedActions == null) { + queuedActions = new HashSet<>(); + } + queuedActions.addAll(actions); + } + } + account.save(); + if (!isMessageBlocked(envelope, content)) { + handler.handleMessage(envelope, content, exception); + } + if (!(exception instanceof org.whispersystems.libsignal.UntrustedIdentityException)) { + File cacheFile = null; + try { + String source = envelope.getSourceE164().isPresent() ? envelope.getSourceE164().get() : ""; + cacheFile = getMessageCacheFile(source, now, envelope.getTimestamp()); + Files.delete(cacheFile.toPath()); + // Try to delete directory if empty + new File(getMessageCachePath()).delete(); + } catch (IOException e) { + System.err.println("Failed to delete cached message file “" + cacheFile + "”: " + e.getMessage()); + } + } + } + } + public void receiveMessages( + long timeout, + TimeUnit unit, + boolean returnOnTimeout, + boolean ignoreAttachments, + ReceiveMessageHandler handler + ) throws IOException { + retryFailedReceivedMessages(handler, ignoreAttachments); + + Set queuedActions = null; + + final var messagePipe = getOrCreateMessagePipe(); + + var hasCaughtUpWithOldMessages = false; + + while (true) { + SignalServiceEnvelope envelope; + SignalServiceContent content = null; + Exception exception = null; + final CachedMessage[] cachedMessage = {null}; + try { + var result = messagePipe.readOrEmpty(timeout, unit, envelope1 -> { + // store message on disk, before acknowledging receipt to the server + cachedMessage[0] = account.getMessageCache().cacheMessage(envelope1); + }); + if (result.isPresent()) { + envelope = result.get(); + } else { + // Received indicator that server queue is empty + hasCaughtUpWithOldMessages = true; + + if (queuedActions != null) { + for (var action : queuedActions) { + try { + action.execute(this); + } catch (Throwable e) { + logger.warn("Message action failed.", e); + } + } + account.save(); + queuedActions.clear(); + queuedActions = null; + } + + // Continue to wait another timeout for new messages + continue; + } + } catch (TimeoutException e) { + if (returnOnTimeout) return; + continue; + } + + if (envelope.hasSource()) { + // Store uuid if we don't have it already + var source = envelope.getSourceAddress(); + resolveSignalServiceAddress(source); + } + if (!envelope.isReceipt()) { + try { + content = decryptMessage(envelope); + } catch (Exception e) { + exception = e; + } + var actions = handleMessage(envelope, content, ignoreAttachments); + if (hasCaughtUpWithOldMessages) { + for (var action : actions) { + try { + action.execute(this); + } catch (Throwable e) { + logger.warn("Message action failed.", e); + } + } + } else { + if (queuedActions == null) { + queuedActions = new HashSet<>(); + } + queuedActions.addAll(actions); + } + } + account.save(); + if (isMessageBlocked(envelope, content)) { + logger.info("Ignoring a message from blocked user/group: {}", envelope.getTimestamp()); + } else if (isNotAGroupMember(envelope, content)) { + logger.info("Ignoring a message from a non group member: {}", envelope.getTimestamp()); + } else { + handler.handleMessage(envelope, content, exception); + } + if (!(exception instanceof org.whispersystems.libsignal.UntrustedIdentityException)) { + if (cachedMessage[0] != null) { + cachedMessage[0].delete(); + } + } + } + } + + private boolean isMessageBlocked( + SignalServiceEnvelope envelope, SignalServiceContent content + ) { + SignalServiceAddress source; + if (!envelope.isUnidentifiedSender() && envelope.hasSource()) { + source = envelope.getSourceAddress(); + } else if (content != null) { + source = content.getSender(); + } else { + return false; + } + var sourceContact = account.getContactStore().getContact(source); + if (sourceContact != null && sourceContact.blocked) { + return true; + } + + if (content != null && content.getDataMessage().isPresent()) { + var message = content.getDataMessage().get(); + if (message.getGroupContext().isPresent()) { + var groupId = GroupUtils.getGroupId(message.getGroupContext().get()); + var group = getGroup(groupId); + if (group != null && group.isBlocked()) { + return true; + } + } + } + return false; + } + + private boolean isNotAGroupMember( + SignalServiceEnvelope envelope, SignalServiceContent content + ) { + SignalServiceAddress source; + if (!envelope.isUnidentifiedSender() && envelope.hasSource()) { + source = envelope.getSourceAddress(); + } else if (content != null) { + source = content.getSender(); + } else { + return false; + } + + if (content != null && content.getDataMessage().isPresent()) { + var message = content.getDataMessage().get(); + if (message.getGroupContext().isPresent()) { + if (message.getGroupContext().get().getGroupV1().isPresent()) { + var groupInfo = message.getGroupContext().get().getGroupV1().get(); + if (groupInfo.getType() == SignalServiceGroup.Type.QUIT) { + return false; + } + } + var groupId = GroupUtils.getGroupId(message.getGroupContext().get()); + var group = getGroup(groupId); + if (group != null && !group.isMember(source)) { + return true; + } + } + } + return false; + } + + private List handleMessage( + SignalServiceEnvelope envelope, SignalServiceContent content, boolean ignoreAttachments + ) { + var actions = new ArrayList(); + if (content != null) { + final SignalServiceAddress sender; + if (!envelope.isUnidentifiedSender() && envelope.hasSource()) { + sender = envelope.getSourceAddress(); + } else { + sender = content.getSender(); + } + // Store uuid if we don't have it already + resolveSignalServiceAddress(sender); + + if (content.getDataMessage().isPresent()) { + var message = content.getDataMessage().get(); + + if (content.isNeedsReceipt()) { + actions.add(new SendReceiptAction(sender, message.getTimestamp())); + } + + actions.addAll(handleSignalServiceDataMessage(message, + false, + sender, + account.getSelfAddress(), + ignoreAttachments)); + } + if (content.getSyncMessage().isPresent()) { + account.setMultiDevice(true); + var syncMessage = content.getSyncMessage().get(); + if (syncMessage.getSent().isPresent()) { + var message = syncMessage.getSent().get(); + final var destination = message.getDestination().orNull(); + actions.addAll(handleSignalServiceDataMessage(message.getMessage(), + true, + sender, + destination, + ignoreAttachments)); + } + if (syncMessage.getRequest().isPresent()) { + var rm = syncMessage.getRequest().get(); + if (rm.isContactsRequest()) { + actions.add(SendSyncContactsAction.create()); + } + if (rm.isGroupsRequest()) { + actions.add(SendSyncGroupsAction.create()); + } + if (rm.isBlockedListRequest()) { + actions.add(SendSyncBlockedListAction.create()); + } + // TODO Handle rm.isConfigurationRequest(); rm.isKeysRequest(); + } + if (syncMessage.getGroups().isPresent()) { + File tmpFile = null; + try { + tmpFile = IOUtils.createTempFile(); + final var groupsMessage = syncMessage.getGroups().get(); + try (var attachmentAsStream = retrieveAttachmentAsStream(groupsMessage.asPointer(), tmpFile)) { + var s = new DeviceGroupsInputStream(attachmentAsStream); + DeviceGroup g; + while ((g = s.read()) != null) { + var syncGroup = account.getGroupStore().getOrCreateGroupV1(GroupId.v1(g.getId())); + if (syncGroup != null) { + if (g.getName().isPresent()) { + syncGroup.name = g.getName().get(); + } + syncGroup.addMembers(g.getMembers() + .stream() + .map(this::resolveSignalServiceAddress) + .collect(Collectors.toSet())); + if (!g.isActive()) { + syncGroup.removeMember(account.getSelfAddress()); + } else { + // Add ourself to the member set as it's marked as active + syncGroup.addMembers(List.of(account.getSelfAddress())); + } + syncGroup.blocked = g.isBlocked(); + if (g.getColor().isPresent()) { + syncGroup.color = g.getColor().get(); + } + + if (g.getAvatar().isPresent()) { + downloadGroupAvatar(g.getAvatar().get(), syncGroup.getGroupId()); + } + syncGroup.inboxPosition = g.getInboxPosition().orNull(); + syncGroup.archived = g.isArchived(); + account.getGroupStore().updateGroup(syncGroup); + } + } + } + } catch (Exception e) { + logger.warn("Failed to handle received sync groups “{}”, ignoring: {}", + tmpFile, + e.getMessage()); + } finally { + if (tmpFile != null) { + try { + Files.delete(tmpFile.toPath()); + } catch (IOException e) { + logger.warn("Failed to delete received groups temp file “{}”, ignoring: {}", + tmpFile, + e.getMessage()); + } + } + } + } + if (syncMessage.getBlockedList().isPresent()) { + final var blockedListMessage = syncMessage.getBlockedList().get(); + for (var address : blockedListMessage.getAddresses()) { + setContactBlocked(resolveSignalServiceAddress(address), true); + } + for (var groupId : blockedListMessage.getGroupIds() + .stream() + .map(GroupId::unknownVersion) + .collect(Collectors.toSet())) { + try { + setGroupBlocked(groupId, true); + } catch (GroupNotFoundException e) { + logger.warn("BlockedListMessage contained groupID that was not found in GroupStore: {}", + groupId.toBase64()); + } + } + } + if (syncMessage.getContacts().isPresent()) { + File tmpFile = null; + try { + tmpFile = IOUtils.createTempFile(); + final var contactsMessage = syncMessage.getContacts().get(); + try (var attachmentAsStream = retrieveAttachmentAsStream(contactsMessage.getContactsStream() + .asPointer(), tmpFile)) { + var s = new DeviceContactsInputStream(attachmentAsStream); + if (contactsMessage.isComplete()) { + account.getContactStore().clear(); + } + DeviceContact c; + while ((c = s.read()) != null) { + if (c.getAddress().matches(account.getSelfAddress()) && c.getProfileKey().isPresent()) { + account.setProfileKey(c.getProfileKey().get()); + } + final var address = resolveSignalServiceAddress(c.getAddress()); + var contact = account.getContactStore().getContact(address); + if (contact == null) { + contact = new ContactInfo(address); + } + if (c.getName().isPresent()) { + contact.name = c.getName().get(); + } + if (c.getColor().isPresent()) { + contact.color = c.getColor().get(); + } + if (c.getProfileKey().isPresent()) { + account.getProfileStore().storeProfileKey(address, c.getProfileKey().get()); + } + if (c.getVerified().isPresent()) { + final var verifiedMessage = c.getVerified().get(); + account.getSignalProtocolStore() + .setIdentityTrustLevel(verifiedMessage.getDestination(), + verifiedMessage.getIdentityKey(), + TrustLevel.fromVerifiedState(verifiedMessage.getVerified())); + } + if (c.getExpirationTimer().isPresent()) { + contact.messageExpirationTime = c.getExpirationTimer().get(); + } + contact.blocked = c.isBlocked(); + contact.inboxPosition = c.getInboxPosition().orNull(); + contact.archived = c.isArchived(); + account.getContactStore().updateContact(contact); + + if (c.getAvatar().isPresent()) { + downloadContactAvatar(c.getAvatar().get(), contact.getAddress()); + } + } + } + } catch (Exception e) { + logger.warn("Failed to handle received sync contacts “{}”, ignoring: {}", + tmpFile, + e.getMessage()); + } finally { + if (tmpFile != null) { + try { + Files.delete(tmpFile.toPath()); + } catch (IOException e) { + logger.warn("Failed to delete received contacts temp file “{}”, ignoring: {}", + tmpFile, + e.getMessage()); + } + } + } + } + if (syncMessage.getVerified().isPresent()) { + final var verifiedMessage = syncMessage.getVerified().get(); + account.getSignalProtocolStore() + .setIdentityTrustLevel(resolveSignalServiceAddress(verifiedMessage.getDestination()), + verifiedMessage.getIdentityKey(), + TrustLevel.fromVerifiedState(verifiedMessage.getVerified())); + } + if (syncMessage.getStickerPackOperations().isPresent()) { + final var stickerPackOperationMessages = syncMessage.getStickerPackOperations().get(); + for (var m : stickerPackOperationMessages) { + if (!m.getPackId().isPresent()) { + continue; + } + var sticker = account.getStickerStore().getSticker(m.getPackId().get()); + if (sticker == null) { + if (!m.getPackKey().isPresent()) { + continue; + } + sticker = new Sticker(m.getPackId().get(), m.getPackKey().get()); + } + sticker.setInstalled(!m.getType().isPresent() + || m.getType().get() == StickerPackOperationMessage.Type.INSTALL); + account.getStickerStore().updateSticker(sticker); + } + } + if (syncMessage.getFetchType().isPresent()) { + switch (syncMessage.getFetchType().get()) { + case LOCAL_PROFILE: + getRecipientProfile(getSelfAddress(), true); + case STORAGE_MANIFEST: + // TODO + } + } + if (syncMessage.getKeys().isPresent()) { + final var keysMessage = syncMessage.getKeys().get(); + if (keysMessage.getStorageService().isPresent()) { + final var storageKey = keysMessage.getStorageService().get(); + account.setStorageKey(storageKey); + } + } + if (syncMessage.getConfiguration().isPresent()) { + // TODO + } + } + } + return actions; + } + + private void downloadContactAvatar(SignalServiceAttachment avatar, SignalServiceAddress address) { + try { + avatarStore.storeContactAvatar(address, outputStream -> retrieveAttachment(avatar, outputStream)); + } catch (IOException e) { + logger.warn("Failed to download avatar for contact {}, ignoring: {}", address, e.getMessage()); + } + } + + private void downloadGroupAvatar(SignalServiceAttachment avatar, GroupId groupId) { + try { + avatarStore.storeGroupAvatar(groupId, outputStream -> retrieveAttachment(avatar, outputStream)); + } catch (IOException e) { + logger.warn("Failed to download avatar for group {}, ignoring: {}", groupId.toBase64(), e.getMessage()); + } + } + + private void downloadGroupAvatar(GroupId groupId, GroupSecretParams groupSecretParams, String cdnKey) { + try { + avatarStore.storeGroupAvatar(groupId, + outputStream -> retrieveGroupV2Avatar(groupSecretParams, cdnKey, outputStream)); + } catch (IOException e) { + logger.warn("Failed to download avatar for group {}, ignoring: {}", groupId.toBase64(), e.getMessage()); + } + } + + private void downloadProfileAvatar( + SignalServiceAddress address, String avatarPath, ProfileKey profileKey + ) { + try { + avatarStore.storeProfileAvatar(address, + outputStream -> retrieveProfileAvatar(avatarPath, profileKey, outputStream)); + } catch (Throwable e) { + logger.warn("Failed to download profile avatar, ignoring: {}", e.getMessage()); + } + } + + public File getAttachmentFile(SignalServiceAttachmentRemoteId attachmentId) { + return attachmentStore.getAttachmentFile(attachmentId); + } + + private void downloadAttachment(final SignalServiceAttachment attachment) { + if (!attachment.isPointer()) { + logger.warn("Invalid state, can't store an attachment stream."); + } + + var pointer = attachment.asPointer(); + if (pointer.getPreview().isPresent()) { + final var preview = pointer.getPreview().get(); + try { + attachmentStore.storeAttachmentPreview(pointer.getRemoteId(), + outputStream -> outputStream.write(preview, 0, preview.length)); + } catch (IOException e) { + logger.warn("Failed to download attachment preview, ignoring: {}", e.getMessage()); + } + } + + try { + attachmentStore.storeAttachment(pointer.getRemoteId(), + outputStream -> retrieveAttachmentPointer(pointer, outputStream)); + } catch (IOException e) { + logger.warn("Failed to download attachment ({}), ignoring: {}", pointer.getRemoteId(), e.getMessage()); + } + } + + private void retrieveGroupV2Avatar( + GroupSecretParams groupSecretParams, String cdnKey, OutputStream outputStream + ) throws IOException { + var groupOperations = groupsV2Operations.forGroup(groupSecretParams); + + var tmpFile = IOUtils.createTempFile(); + try (InputStream input = messageReceiver.retrieveGroupsV2ProfileAvatar(cdnKey, + tmpFile, + ServiceConfig.AVATAR_DOWNLOAD_FAILSAFE_MAX_SIZE)) { + var encryptedData = IOUtils.readFully(input); + + var decryptedData = groupOperations.decryptAvatar(encryptedData); + outputStream.write(decryptedData); + } finally { + try { + Files.delete(tmpFile.toPath()); + } catch (IOException e) { + logger.warn("Failed to delete received group avatar temp file “{}”, ignoring: {}", + tmpFile, + e.getMessage()); + } + } + } + + private void retrieveProfileAvatar( + String avatarPath, ProfileKey profileKey, OutputStream outputStream + ) throws IOException { + var tmpFile = IOUtils.createTempFile(); + try (var input = messageReceiver.retrieveProfileAvatar(avatarPath, + tmpFile, + profileKey, + ServiceConfig.AVATAR_DOWNLOAD_FAILSAFE_MAX_SIZE)) { + // Use larger buffer size to prevent AssertionError: Need: 12272 but only have: 8192 ... + IOUtils.copyStream(input, outputStream, (int) ServiceConfig.AVATAR_DOWNLOAD_FAILSAFE_MAX_SIZE); + } finally { + try { + Files.delete(tmpFile.toPath()); + } catch (IOException e) { + logger.warn("Failed to delete received profile avatar temp file “{}”, ignoring: {}", + tmpFile, + e.getMessage()); + } + } + } + + private void retrieveAttachment( + final SignalServiceAttachment attachment, final OutputStream outputStream + ) throws IOException { + if (attachment.isPointer()) { + var pointer = attachment.asPointer(); + retrieveAttachmentPointer(pointer, outputStream); + } else { + var stream = attachment.asStream(); + IOUtils.copyStream(stream.getInputStream(), outputStream); + } + } + + private void retrieveAttachmentPointer( + SignalServiceAttachmentPointer pointer, OutputStream outputStream + ) throws IOException { + var tmpFile = IOUtils.createTempFile(); + try (var input = retrieveAttachmentAsStream(pointer, tmpFile)) { + IOUtils.copyStream(input, outputStream); + } catch (MissingConfigurationException | InvalidMessageException e) { + throw new IOException(e); + } finally { + try { + Files.delete(tmpFile.toPath()); + } catch (IOException e) { + logger.warn("Failed to delete received attachment temp file “{}”, ignoring: {}", + tmpFile, + e.getMessage()); + } + } + } + + private InputStream retrieveAttachmentAsStream( + SignalServiceAttachmentPointer pointer, File tmpFile + ) throws IOException, InvalidMessageException, MissingConfigurationException { + return messageReceiver.retrieveAttachment(pointer, tmpFile, ServiceConfig.MAX_ATTACHMENT_SIZE); + } + + void sendGroups() throws IOException, UntrustedIdentityException { + var groupsFile = IOUtils.createTempFile(); + + try { + try (OutputStream fos = new FileOutputStream(groupsFile)) { + var out = new DeviceGroupsOutputStream(fos); + for (var record : getGroups()) { + if (record instanceof GroupInfoV1) { + var groupInfo = (GroupInfoV1) record; + out.write(new DeviceGroup(groupInfo.getGroupId().serialize(), + Optional.fromNullable(groupInfo.name), + new ArrayList<>(groupInfo.getMembers()), + createGroupAvatarAttachment(groupInfo.getGroupId()), + groupInfo.isMember(account.getSelfAddress()), + Optional.of(groupInfo.messageExpirationTime), + Optional.fromNullable(groupInfo.color), + groupInfo.blocked, + Optional.fromNullable(groupInfo.inboxPosition), + groupInfo.archived)); + } + } + } + + if (groupsFile.exists() && groupsFile.length() > 0) { + try (var groupsFileStream = new FileInputStream(groupsFile)) { + var attachmentStream = SignalServiceAttachment.newStreamBuilder() + .withStream(groupsFileStream) + .withContentType("application/octet-stream") + .withLength(groupsFile.length()) + .build(); + + sendSyncMessage(SignalServiceSyncMessage.forGroups(attachmentStream)); + } + } + } finally { + try { + Files.delete(groupsFile.toPath()); + } catch (IOException e) { + logger.warn("Failed to delete groups temp file “{}”, ignoring: {}", groupsFile, e.getMessage()); + } + } + } + + public void sendContacts() throws IOException, UntrustedIdentityException { + var contactsFile = IOUtils.createTempFile(); + + try { + try (OutputStream fos = new FileOutputStream(contactsFile)) { + var out = new DeviceContactsOutputStream(fos); + for (var record : account.getContactStore().getContacts()) { + VerifiedMessage verifiedMessage = null; + var currentIdentity = account.getSignalProtocolStore().getIdentity(record.getAddress()); + if (currentIdentity != null) { + verifiedMessage = new VerifiedMessage(record.getAddress(), + currentIdentity.getIdentityKey(), + currentIdentity.getTrustLevel().toVerifiedState(), + currentIdentity.getDateAdded().getTime()); + } + + var profileKey = account.getProfileStore().getProfileKey(record.getAddress()); + out.write(new DeviceContact(record.getAddress(), + Optional.fromNullable(record.name), + createContactAvatarAttachment(record.getAddress()), + Optional.fromNullable(record.color), + Optional.fromNullable(verifiedMessage), + Optional.fromNullable(profileKey), + record.blocked, + Optional.of(record.messageExpirationTime), + Optional.fromNullable(record.inboxPosition), + record.archived)); + } + + if (account.getProfileKey() != null) { + // Send our own profile key as well + out.write(new DeviceContact(account.getSelfAddress(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.of(account.getProfileKey()), + false, + Optional.absent(), + Optional.absent(), + false)); + } + } + + if (contactsFile.exists() && contactsFile.length() > 0) { + try (var contactsFileStream = new FileInputStream(contactsFile)) { + var attachmentStream = SignalServiceAttachment.newStreamBuilder() + .withStream(contactsFileStream) + .withContentType("application/octet-stream") + .withLength(contactsFile.length()) + .build(); + + sendSyncMessage(SignalServiceSyncMessage.forContacts(new ContactsMessage(attachmentStream, true))); + } + } + } finally { + try { + Files.delete(contactsFile.toPath()); + } catch (IOException e) { + logger.warn("Failed to delete contacts temp file “{}”, ignoring: {}", contactsFile, e.getMessage()); + } + } + } + + void sendBlockedList() throws IOException, UntrustedIdentityException { + var addresses = new ArrayList(); + for (var record : account.getContactStore().getContacts()) { + if (record.blocked) { + addresses.add(record.getAddress()); + } + } + var groupIds = new ArrayList(); + for (var record : getGroups()) { + if (record.isBlocked()) { + groupIds.add(record.getGroupId().serialize()); + } + } + sendSyncMessage(SignalServiceSyncMessage.forBlocked(new BlockedListMessage(addresses, groupIds))); + } + + private void sendVerifiedMessage( + SignalServiceAddress destination, IdentityKey identityKey, TrustLevel trustLevel + ) throws IOException, UntrustedIdentityException { + var verifiedMessage = new VerifiedMessage(destination, + identityKey, + trustLevel.toVerifiedState(), + System.currentTimeMillis()); + sendSyncMessage(SignalServiceSyncMessage.forVerified(verifiedMessage)); + } + + public List getContacts() { + return account.getContactStore().getContacts(); + } + + public String getContactOrProfileName(String number) { + final var address = Utils.getSignalServiceAddressFromIdentifier(number); + + final var contact = account.getContactStore().getContact(address); + if (contact != null && !Util.isEmpty(contact.name)) { + return contact.name; + } + + final var profileEntry = account.getProfileStore().getProfileEntry(address); + if (profileEntry != null && profileEntry.getProfile() != null) { + return profileEntry.getProfile().getDisplayName(); + } + return null; + } + + public GroupInfo getGroup(GroupId groupId) { + final var group = account.getGroupStore().getGroup(groupId); + if (group instanceof GroupInfoV2 && ((GroupInfoV2) group).getGroup() == null) { + final var groupSecretParams = GroupSecretParams.deriveFromMasterKey(((GroupInfoV2) group).getMasterKey()); + ((GroupInfoV2) group).setGroup(groupHelper.getDecryptedGroup(groupSecretParams)); + account.getGroupStore().updateGroup(group); + } + return group; + } + + public List getIdentities() { + return account.getSignalProtocolStore().getIdentities(); + } + + public List getIdentities(String number) throws InvalidNumberException { + return account.getSignalProtocolStore().getIdentities(canonicalizeAndResolveSignalServiceAddress(number)); + } + + /** + * Trust this the identity with this fingerprint + * + * @param name username of the identity + * @param fingerprint Fingerprint + */ + public boolean trustIdentityVerified(String name, byte[] fingerprint) throws InvalidNumberException { + var address = canonicalizeAndResolveSignalServiceAddress(name); + return trustIdentity(address, (identityKey) -> Arrays.equals(identityKey.serialize(), fingerprint)); + } + + /** + * Trust this the identity with this safety number + * + * @param name username of the identity + * @param safetyNumber Safety number + */ + public boolean trustIdentityVerifiedSafetyNumber(String name, String safetyNumber) throws InvalidNumberException { + var address = canonicalizeAndResolveSignalServiceAddress(name); + return trustIdentity(address, (identityKey) -> safetyNumber.equals(computeSafetyNumber(address, identityKey))); + } + + private boolean trustIdentity(SignalServiceAddress address, Function verifier) { + var ids = account.getSignalProtocolStore().getIdentities(address); + if (ids == null) { + return false; + } + + IdentityInfo foundIdentity = null; + + for (var id : ids) { + if (verifier.apply(id.getIdentityKey())) { + foundIdentity = id; + break; + } + } + + if (foundIdentity == null) { + return false; + } + + account.getSignalProtocolStore() + .setIdentityTrustLevel(address, foundIdentity.getIdentityKey(), TrustLevel.TRUSTED_VERIFIED); + try { + sendVerifiedMessage(address, foundIdentity.getIdentityKey(), TrustLevel.TRUSTED_VERIFIED); + } catch (IOException | UntrustedIdentityException e) { + logger.warn("Failed to send verification sync message: {}", e.getMessage()); + } + + // Successfully trusted the new identity, now remove all other identities for that number + for (var id : ids) { + if (id == foundIdentity) { + continue; + } + account.getSignalProtocolStore().removeIdentity(address, id.getIdentityKey()); + } + + account.save(); + return true; + } + + /** + * Trust all keys of this identity without verification + * + * @param name username of the identity + */ + public boolean trustIdentityAllKeys(String name) { + var address = resolveSignalServiceAddress(name); + var ids = account.getSignalProtocolStore().getIdentities(address); + if (ids == null) { + return false; + } + for (var id : ids) { + if (id.getTrustLevel() == TrustLevel.UNTRUSTED) { + account.getSignalProtocolStore() + .setIdentityTrustLevel(address, id.getIdentityKey(), TrustLevel.TRUSTED_UNVERIFIED); + try { + sendVerifiedMessage(address, id.getIdentityKey(), TrustLevel.TRUSTED_UNVERIFIED); + } catch (IOException | UntrustedIdentityException e) { + logger.warn("Failed to send verification sync message: {}", e.getMessage()); + } + } + } + account.save(); + return true; + } + + public String computeSafetyNumber( + SignalServiceAddress theirAddress, IdentityKey theirIdentityKey + ) { + return Utils.computeSafetyNumber(ServiceConfig.capabilities.isUuid(), + account.getSelfAddress(), + getIdentityKeyPair().getPublicKey(), + theirAddress, + theirIdentityKey); + } + + public SignalServiceAddress canonicalizeAndResolveSignalServiceAddress(String identifier) throws InvalidNumberException { + var canonicalizedNumber = UuidUtil.isUuid(identifier) + ? identifier + : PhoneNumberFormatter.formatNumber(identifier, account.getUsername()); + return resolveSignalServiceAddress(canonicalizedNumber); + } + + public SignalServiceAddress resolveSignalServiceAddress(String identifier) { + var address = Utils.getSignalServiceAddressFromIdentifier(identifier); + + return resolveSignalServiceAddress(address); + } + + public SignalServiceAddress resolveSignalServiceAddress(SignalServiceAddress address) { + if (address.matches(account.getSelfAddress())) { + return account.getSelfAddress(); + } + + return account.getRecipientStore().resolveServiceAddress(address); + } + + @Override + public void close() throws IOException { + close(true); + } + + void close(boolean closeAccount) throws IOException { + executor.shutdown(); + + if (messagePipe != null) { + messagePipe.shutdown(); + messagePipe = null; + } + + if (unidentifiedMessagePipe != null) { + unidentifiedMessagePipe.shutdown(); + unidentifiedMessagePipe = null; + } + + if (closeAccount && account != null) { + account.close(); + } + account = null; + } + + public interface ReceiveMessageHandler { + + void handleMessage(SignalServiceEnvelope envelope, SignalServiceContent decryptedContent, Throwable e); + } +} diff --git a/lib/src/main/java/org/asamk/signal/manager/NotRegisteredException.java b/lib/src/main/java/org/asamk/signal/manager/NotRegisteredException.java new file mode 100644 index 00000000..c1b35a1c --- /dev/null +++ b/lib/src/main/java/org/asamk/signal/manager/NotRegisteredException.java @@ -0,0 +1,8 @@ +package org.asamk.signal.manager; + +public class NotRegisteredException extends Exception { + + public NotRegisteredException() { + super("User is not registered."); + } +} diff --git a/lib/src/main/java/org/asamk/signal/manager/PathConfig.java b/lib/src/main/java/org/asamk/signal/manager/PathConfig.java new file mode 100644 index 00000000..d96034df --- /dev/null +++ b/lib/src/main/java/org/asamk/signal/manager/PathConfig.java @@ -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; + } +} diff --git a/lib/src/main/java/org/asamk/signal/manager/ProvisioningManager.java b/lib/src/main/java/org/asamk/signal/manager/ProvisioningManager.java new file mode 100644 index 00000000..c8869212 --- /dev/null +++ b/lib/src/main/java/org/asamk/signal/manager/ProvisioningManager.java @@ -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 . + */ +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(); + } + } + } +} diff --git a/lib/src/main/java/org/asamk/signal/manager/RegistrationManager.java b/lib/src/main/java/org/asamk/signal/manager/RegistrationManager.java new file mode 100644 index 00000000..aad731a0 --- /dev/null +++ b/lib/src/main/java/org/asamk/signal/manager/RegistrationManager.java @@ -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 . + */ +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; + } + } +} diff --git a/lib/src/main/java/org/asamk/signal/manager/StickerPackInvalidException.java b/lib/src/main/java/org/asamk/signal/manager/StickerPackInvalidException.java new file mode 100644 index 00000000..52869acd --- /dev/null +++ b/lib/src/main/java/org/asamk/signal/manager/StickerPackInvalidException.java @@ -0,0 +1,8 @@ +package org.asamk.signal.manager; + +public class StickerPackInvalidException extends Exception { + + public StickerPackInvalidException(String message) { + super(message); + } +} diff --git a/lib/src/main/java/org/asamk/signal/manager/TrustLevel.java b/lib/src/main/java/org/asamk/signal/manager/TrustLevel.java new file mode 100644 index 00000000..c9fa7a5e --- /dev/null +++ b/lib/src/main/java/org/asamk/signal/manager/TrustLevel.java @@ -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); + } +} diff --git a/lib/src/main/java/org/asamk/signal/manager/UserAlreadyExists.java b/lib/src/main/java/org/asamk/signal/manager/UserAlreadyExists.java new file mode 100644 index 00000000..d506f0c6 --- /dev/null +++ b/lib/src/main/java/org/asamk/signal/manager/UserAlreadyExists.java @@ -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; + } +} diff --git a/lib/src/main/java/org/asamk/signal/manager/config/IasTrustStore.java b/lib/src/main/java/org/asamk/signal/manager/config/IasTrustStore.java new file mode 100644 index 00000000..4d70809b --- /dev/null +++ b/lib/src/main/java/org/asamk/signal/manager/config/IasTrustStore.java @@ -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"; + } +} diff --git a/lib/src/main/java/org/asamk/signal/manager/config/KeyBackupConfig.java b/lib/src/main/java/org/asamk/signal/manager/config/KeyBackupConfig.java new file mode 100644 index 00000000..60173c1d --- /dev/null +++ b/lib/src/main/java/org/asamk/signal/manager/config/KeyBackupConfig.java @@ -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; + } +} diff --git a/lib/src/main/java/org/asamk/signal/manager/config/LiveConfig.java b/lib/src/main/java/org/asamk/signal/manager/config/LiveConfig.java new file mode 100644 index 00000000..4298547d --- /dev/null +++ b/lib/src/main/java/org/asamk/signal/manager/config/LiveConfig.java @@ -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 = Optional.absent(); + private final static Optional proxy = Optional.absent(); + + private final static byte[] zkGroupServerPublicParams = Base64.getDecoder() + .decode("AMhf5ywVwITZMsff/eCyudZx9JDmkkkbV6PInzG4p8x3VqVJSFiMvnvlEKWuRob/1eaIetR31IYeAbm0NdOuHH8Qi+Rexi1wLlpzIo1gstHWBfZzy1+qHRV5A4TqPp15YzBPm0WSggW6PbSn+F4lf57VCnHF7p8SvzAA2ZZJPYJURt8X7bbg+H3i+PEjH9DXItNEqs2sNcug37xZQDLm7X0="); + + static SignalServiceConfiguration createDefaultServiceConfiguration( + final List 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() { + } +} diff --git a/lib/src/main/java/org/asamk/signal/manager/config/SandboxConfig.java b/lib/src/main/java/org/asamk/signal/manager/config/SandboxConfig.java new file mode 100644 index 00000000..9ca9dc8b --- /dev/null +++ b/lib/src/main/java/org/asamk/signal/manager/config/SandboxConfig.java @@ -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 = Optional.absent(); + private final static Optional proxy = Optional.absent(); + + private final static byte[] zkGroupServerPublicParams = Base64.getDecoder() + .decode("ABSY21VckQcbSXVNCGRYJcfWHiAMZmpTtTELcDmxgdFbtp/bWsSxZdMKzfCp8rvIs8ocCU3B37fT3r4Mi5qAemeGeR2X+/YmOGR5ofui7tD5mDQfstAI9i+4WpMtIe8KC3wU5w3Inq3uNWVmoGtpKndsNfwJrCg0Hd9zmObhypUnSkfYn2ooMOOnBpfdanRtrvetZUayDMSC5iSRcXKpdls="); + + static SignalServiceConfiguration createDefaultServiceConfiguration( + final List 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() { + } +} diff --git a/lib/src/main/java/org/asamk/signal/manager/config/ServiceConfig.java b/lib/src/main/java/org/asamk/signal/manager/config/ServiceConfig.java new file mode 100644 index 00000000..4cf86537 --- /dev/null +++ b/lib/src/main/java/org/asamk/signal/manager/config/ServiceConfig.java @@ -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"); + } + } +} diff --git a/lib/src/main/java/org/asamk/signal/manager/config/ServiceEnvironment.java b/lib/src/main/java/org/asamk/signal/manager/config/ServiceEnvironment.java new file mode 100644 index 00000000..142a2dd4 --- /dev/null +++ b/lib/src/main/java/org/asamk/signal/manager/config/ServiceEnvironment.java @@ -0,0 +1,6 @@ +package org.asamk.signal.manager.config; + +public enum ServiceEnvironment { + LIVE, + SANDBOX, +} diff --git a/lib/src/main/java/org/asamk/signal/manager/config/ServiceEnvironmentConfig.java b/lib/src/main/java/org/asamk/signal/manager/config/ServiceEnvironmentConfig.java new file mode 100644 index 00000000..e64472a0 --- /dev/null +++ b/lib/src/main/java/org/asamk/signal/manager/config/ServiceEnvironmentConfig.java @@ -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; + } +} diff --git a/lib/src/main/java/org/asamk/signal/manager/config/WhisperTrustStore.java b/lib/src/main/java/org/asamk/signal/manager/config/WhisperTrustStore.java new file mode 100644 index 00000000..7add1d79 --- /dev/null +++ b/lib/src/main/java/org/asamk/signal/manager/config/WhisperTrustStore.java @@ -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"; + } +} diff --git a/lib/src/main/java/org/asamk/signal/manager/groups/GroupId.java b/lib/src/main/java/org/asamk/signal/manager/groups/GroupId.java new file mode 100644 index 00000000..9ecb9630 --- /dev/null +++ b/lib/src/main/java/org/asamk/signal/manager/groups/GroupId.java @@ -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); + } +} diff --git a/lib/src/main/java/org/asamk/signal/manager/groups/GroupIdFormatException.java b/lib/src/main/java/org/asamk/signal/manager/groups/GroupIdFormatException.java new file mode 100644 index 00000000..8050da22 --- /dev/null +++ b/lib/src/main/java/org/asamk/signal/manager/groups/GroupIdFormatException.java @@ -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); + } +} diff --git a/lib/src/main/java/org/asamk/signal/manager/groups/GroupIdV1.java b/lib/src/main/java/org/asamk/signal/manager/groups/GroupIdV1.java new file mode 100644 index 00000000..237a34b6 --- /dev/null +++ b/lib/src/main/java/org/asamk/signal/manager/groups/GroupIdV1.java @@ -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); + } +} diff --git a/lib/src/main/java/org/asamk/signal/manager/groups/GroupIdV2.java b/lib/src/main/java/org/asamk/signal/manager/groups/GroupIdV2.java new file mode 100644 index 00000000..913a9e93 --- /dev/null +++ b/lib/src/main/java/org/asamk/signal/manager/groups/GroupIdV2.java @@ -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); + } +} diff --git a/lib/src/main/java/org/asamk/signal/manager/groups/GroupInviteLinkUrl.java b/lib/src/main/java/org/asamk/signal/manager/groups/GroupInviteLinkUrl.java new file mode 100644 index 00000000..dd9dd2d2 --- /dev/null +++ b/lib/src/main/java/org/asamk/signal/manager/groups/GroupInviteLinkUrl.java @@ -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); + } + } +} diff --git a/lib/src/main/java/org/asamk/signal/manager/groups/GroupLinkPassword.java b/lib/src/main/java/org/asamk/signal/manager/groups/GroupLinkPassword.java new file mode 100644 index 00000000..7edc7afb --- /dev/null +++ b/lib/src/main/java/org/asamk/signal/manager/groups/GroupLinkPassword.java @@ -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); + } +} diff --git a/lib/src/main/java/org/asamk/signal/manager/groups/GroupNotFoundException.java b/lib/src/main/java/org/asamk/signal/manager/groups/GroupNotFoundException.java new file mode 100644 index 00000000..0fc0c444 --- /dev/null +++ b/lib/src/main/java/org/asamk/signal/manager/groups/GroupNotFoundException.java @@ -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()); + } +} diff --git a/lib/src/main/java/org/asamk/signal/manager/groups/GroupUtils.java b/lib/src/main/java/org/asamk/signal/manager/groups/GroupUtils.java new file mode 100644 index 00000000..c1db77bf --- /dev/null +++ b/lib/src/main/java/org/asamk/signal/manager/groups/GroupUtils.java @@ -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); + } + } +} diff --git a/lib/src/main/java/org/asamk/signal/manager/groups/NotAGroupMemberException.java b/lib/src/main/java/org/asamk/signal/manager/groups/NotAGroupMemberException.java new file mode 100644 index 00000000..08cbcacd --- /dev/null +++ b/lib/src/main/java/org/asamk/signal/manager/groups/NotAGroupMemberException.java @@ -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() + ")"); + } +} diff --git a/lib/src/main/java/org/asamk/signal/manager/helper/GroupAuthorizationProvider.java b/lib/src/main/java/org/asamk/signal/manager/helper/GroupAuthorizationProvider.java new file mode 100644 index 00000000..d26ebb06 --- /dev/null +++ b/lib/src/main/java/org/asamk/signal/manager/helper/GroupAuthorizationProvider.java @@ -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; +} diff --git a/lib/src/main/java/org/asamk/signal/manager/helper/GroupHelper.java b/lib/src/main/java/org/asamk/signal/manager/helper/GroupHelper.java new file mode 100644 index 00000000..c76075be --- /dev/null +++ b/lib/src/main/java/org/asamk/signal/manager/helper/GroupHelper.java @@ -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 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 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 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 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 updateGroupV2( + GroupInfoV2 groupInfoV2, Set 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 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 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 revokeInvites( + GroupInfoV2 groupInfoV2, Set 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 ejectMembers(GroupInfoV2 groupInfoV2, Set uuids) throws IOException { + final var groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupInfoV2.getMasterKey()); + final var groupOperations = groupsV2Operations.forGroup(groupSecretParams); + return commitChange(groupInfoV2, groupOperations.createRemoveMembersChange(uuids)); + } + + private Pair 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; + } +} diff --git a/lib/src/main/java/org/asamk/signal/manager/helper/MessagePipeProvider.java b/lib/src/main/java/org/asamk/signal/manager/helper/MessagePipeProvider.java new file mode 100644 index 00000000..7739928c --- /dev/null +++ b/lib/src/main/java/org/asamk/signal/manager/helper/MessagePipeProvider.java @@ -0,0 +1,8 @@ +package org.asamk.signal.manager.helper; + +import org.whispersystems.signalservice.api.SignalServiceMessagePipe; + +public interface MessagePipeProvider { + + SignalServiceMessagePipe getMessagePipe(boolean unidentified); +} diff --git a/lib/src/main/java/org/asamk/signal/manager/helper/MessageReceiverProvider.java b/lib/src/main/java/org/asamk/signal/manager/helper/MessageReceiverProvider.java new file mode 100644 index 00000000..9a18a5e4 --- /dev/null +++ b/lib/src/main/java/org/asamk/signal/manager/helper/MessageReceiverProvider.java @@ -0,0 +1,8 @@ +package org.asamk.signal.manager.helper; + +import org.whispersystems.signalservice.api.SignalServiceMessageReceiver; + +public interface MessageReceiverProvider { + + SignalServiceMessageReceiver getMessageReceiver(); +} diff --git a/lib/src/main/java/org/asamk/signal/manager/helper/PinHelper.java b/lib/src/main/java/org/asamk/signal/manager/helper/PinHelper.java new file mode 100644 index 00000000..cf98c4c9 --- /dev/null +++ b/lib/src/main/java/org/asamk/signal/manager/helper/PinHelper.java @@ -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); + } + } +} diff --git a/lib/src/main/java/org/asamk/signal/manager/helper/ProfileHelper.java b/lib/src/main/java/org/asamk/signal/manager/helper/ProfileHelper.java new file mode 100644 index 00000000..5411bb06 --- /dev/null +++ b/lib/src/main/java/org/asamk/signal/manager/helper/ProfileHelper.java @@ -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 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 getPipeRetrievalFuture( + SignalServiceAddress address, + Optional profileKey, + Optional 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 getSocketRetrievalFuture( + SignalServiceAddress address, + Optional profileKey, + Optional 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 getUnidentifiedAccess(SignalServiceAddress recipient) { + var unidentifiedAccess = unidentifiedAccessProvider.getAccessFor(recipient); + + if (unidentifiedAccess.isPresent()) { + return unidentifiedAccess.get().getTargetUnidentifiedAccess(); + } + + return Optional.absent(); + } +} diff --git a/lib/src/main/java/org/asamk/signal/manager/helper/ProfileKeyCredentialProvider.java b/lib/src/main/java/org/asamk/signal/manager/helper/ProfileKeyCredentialProvider.java new file mode 100644 index 00000000..ebb728c1 --- /dev/null +++ b/lib/src/main/java/org/asamk/signal/manager/helper/ProfileKeyCredentialProvider.java @@ -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); +} diff --git a/lib/src/main/java/org/asamk/signal/manager/helper/ProfileKeyProvider.java b/lib/src/main/java/org/asamk/signal/manager/helper/ProfileKeyProvider.java new file mode 100644 index 00000000..9172710e --- /dev/null +++ b/lib/src/main/java/org/asamk/signal/manager/helper/ProfileKeyProvider.java @@ -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); +} diff --git a/lib/src/main/java/org/asamk/signal/manager/helper/ProfileProvider.java b/lib/src/main/java/org/asamk/signal/manager/helper/ProfileProvider.java new file mode 100644 index 00000000..c16b5e0d --- /dev/null +++ b/lib/src/main/java/org/asamk/signal/manager/helper/ProfileProvider.java @@ -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); +} diff --git a/lib/src/main/java/org/asamk/signal/manager/helper/SelfAddressProvider.java b/lib/src/main/java/org/asamk/signal/manager/helper/SelfAddressProvider.java new file mode 100644 index 00000000..3591064f --- /dev/null +++ b/lib/src/main/java/org/asamk/signal/manager/helper/SelfAddressProvider.java @@ -0,0 +1,8 @@ +package org.asamk.signal.manager.helper; + +import org.whispersystems.signalservice.api.push.SignalServiceAddress; + +public interface SelfAddressProvider { + + SignalServiceAddress getSelfAddress(); +} diff --git a/lib/src/main/java/org/asamk/signal/manager/helper/SelfProfileKeyProvider.java b/lib/src/main/java/org/asamk/signal/manager/helper/SelfProfileKeyProvider.java new file mode 100644 index 00000000..8fa51835 --- /dev/null +++ b/lib/src/main/java/org/asamk/signal/manager/helper/SelfProfileKeyProvider.java @@ -0,0 +1,8 @@ +package org.asamk.signal.manager.helper; + +import org.signal.zkgroup.profiles.ProfileKey; + +public interface SelfProfileKeyProvider { + + ProfileKey getProfileKey(); +} diff --git a/lib/src/main/java/org/asamk/signal/manager/helper/UnidentifiedAccessHelper.java b/lib/src/main/java/org/asamk/signal/manager/helper/UnidentifiedAccessHelper.java new file mode 100644 index 00000000..a3b8e3b5 --- /dev/null +++ b/lib/src/main/java/org/asamk/signal/manager/helper/UnidentifiedAccessHelper.java @@ -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 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> getAccessFor(Collection recipients) { + return recipients.stream().map(this::getAccessFor).collect(Collectors.toList()); + } + + public Optional 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); + } +} diff --git a/lib/src/main/java/org/asamk/signal/manager/helper/UnidentifiedAccessProvider.java b/lib/src/main/java/org/asamk/signal/manager/helper/UnidentifiedAccessProvider.java new file mode 100644 index 00000000..a4b65a6f --- /dev/null +++ b/lib/src/main/java/org/asamk/signal/manager/helper/UnidentifiedAccessProvider.java @@ -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 getAccessFor(SignalServiceAddress address); +} diff --git a/lib/src/main/java/org/asamk/signal/manager/helper/UnidentifiedAccessSenderCertificateProvider.java b/lib/src/main/java/org/asamk/signal/manager/helper/UnidentifiedAccessSenderCertificateProvider.java new file mode 100644 index 00000000..b0597346 --- /dev/null +++ b/lib/src/main/java/org/asamk/signal/manager/helper/UnidentifiedAccessSenderCertificateProvider.java @@ -0,0 +1,6 @@ +package org.asamk.signal.manager.helper; + +public interface UnidentifiedAccessSenderCertificateProvider { + + byte[] getSenderCertificate(); +} diff --git a/lib/src/main/java/org/asamk/signal/manager/storage/SignalAccount.java b/lib/src/main/java/org/asamk/signal/manager/storage/SignalAccount.java new file mode 100644 index 00000000..d4e2a253 --- /dev/null +++ b/lib/src/main/java/org/asamk/signal/manager/storage/SignalAccount.java @@ -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 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 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(); + } + } +} diff --git a/lib/src/main/java/org/asamk/signal/manager/storage/contacts/ContactInfo.java b/lib/src/main/java/org/asamk/signal/manager/storage/contacts/ContactInfo.java new file mode 100644 index 00000000..4dd132f7 --- /dev/null +++ b/lib/src/main/java/org/asamk/signal/manager/storage/contacts/ContactInfo.java @@ -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); + } +} diff --git a/lib/src/main/java/org/asamk/signal/manager/storage/contacts/JsonContactsStore.java b/lib/src/main/java/org/asamk/signal/manager/storage/contacts/JsonContactsStore.java new file mode 100644 index 00000000..b80dfe95 --- /dev/null +++ b/lib/src/main/java/org/asamk/signal/manager/storage/contacts/JsonContactsStore.java @@ -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 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 getContacts() { + return new ArrayList<>(contacts); + } + + /** + * Remove all contacts from the store + */ + public void clear() { + contacts.clear(); + } +} diff --git a/lib/src/main/java/org/asamk/signal/manager/storage/groups/GroupInfo.java b/lib/src/main/java/org/asamk/signal/manager/storage/groups/GroupInfo.java new file mode 100644 index 00000000..68af2b80 --- /dev/null +++ b/lib/src/main/java/org/asamk/signal/manager/storage/groups/GroupInfo.java @@ -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 getMembers(); + + @JsonIgnore + public Set getPendingMembers() { + return Set.of(); + } + + @JsonIgnore + public Set getRequestingMembers() { + return Set.of(); + } + + @JsonIgnore + public abstract boolean isBlocked(); + + @JsonIgnore + public abstract void setBlocked(boolean blocked); + + @JsonIgnore + public abstract int getMessageExpirationTime(); + + @JsonIgnore + public Set getMembersWithout(SignalServiceAddress address) { + return getMembers().stream().filter(member -> !member.matches(address)).collect(Collectors.toSet()); + } + + @JsonIgnore + public Set 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; + } +} diff --git a/lib/src/main/java/org/asamk/signal/manager/storage/groups/GroupInfoV1.java b/lib/src/main/java/org/asamk/signal/manager/storage/groups/GroupInfoV1.java new file mode 100644 index 00000000..970ec5c3 --- /dev/null +++ b/lib/src/main/java/org/asamk/signal/manager/storage/groups/GroupInfoV1.java @@ -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 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 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 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 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> { + + @Override + public void serialize( + final Set 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> { + + @Override + public Set deserialize( + JsonParser jsonParser, DeserializationContext deserializationContext + ) throws IOException { + var addresses = new HashSet(); + 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; + } + } +} diff --git a/lib/src/main/java/org/asamk/signal/manager/storage/groups/GroupInfoV2.java b/lib/src/main/java/org/asamk/signal/manager/storage/groups/GroupInfoV2.java new file mode 100644 index 00000000..2092c03a --- /dev/null +++ b/lib/src/main/java/org/asamk/signal/manager/storage/groups/GroupInfoV2.java @@ -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 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 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 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; + } +} diff --git a/lib/src/main/java/org/asamk/signal/manager/storage/groups/JsonGroupStore.java b/lib/src/main/java/org/asamk/signal/manager/storage/groups/JsonGroupStore.java new file mode 100644 index 00000000..8e37895a --- /dev/null +++ b/lib/src/main/java/org/asamk/signal/manager/storage/groups/JsonGroupStore.java @@ -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 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 getGroups() { + final var groups = this.groups.values(); + for (var group : groups) { + loadDecryptedGroup(group); + } + return new ArrayList<>(groups); + } + + private static class GroupsSerializer extends JsonSerializer> { + + @Override + public void serialize( + final Map 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> { + + @Override + public Map deserialize( + JsonParser jsonParser, DeserializationContext deserializationContext + ) throws IOException { + var groups = new HashMap(); + 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; + } + } +} diff --git a/lib/src/main/java/org/asamk/signal/manager/storage/messageCache/CachedMessage.java b/lib/src/main/java/org/asamk/signal/manager/storage/messageCache/CachedMessage.java new file mode 100644 index 00000000..404153b3 --- /dev/null +++ b/lib/src/main/java/org/asamk/signal/manager/storage/messageCache/CachedMessage.java @@ -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()); + } + } +} diff --git a/lib/src/main/java/org/asamk/signal/manager/storage/messageCache/MessageCache.java b/lib/src/main/java/org/asamk/signal/manager/storage/messageCache/MessageCache.java new file mode 100644 index 00000000..6d604300 --- /dev/null +++ b/lib/src/main/java/org/asamk/signal/manager/storage/messageCache/MessageCache.java @@ -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 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); + } +} diff --git a/lib/src/main/java/org/asamk/signal/manager/storage/profiles/ProfileStore.java b/lib/src/main/java/org/asamk/signal/manager/storage/profiles/ProfileStore.java new file mode 100644 index 00000000..dc69a7ee --- /dev/null +++ b/lib/src/main/java/org/asamk/signal/manager/storage/profiles/ProfileStore.java @@ -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 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> { + + @Override + public List deserialize( + JsonParser jsonParser, DeserializationContext deserializationContext + ) throws IOException { + JsonNode node = jsonParser.getCodec().readTree(jsonParser); + + var addresses = new ArrayList(); + + 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> { + + @Override + public void serialize( + List 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(); + } + } +} diff --git a/lib/src/main/java/org/asamk/signal/manager/storage/profiles/SignalProfile.java b/lib/src/main/java/org/asamk/signal/manager/storage/profiles/SignalProfile.java new file mode 100644 index 00000000..45201e18 --- /dev/null +++ b/lib/src/main/java/org/asamk/signal/manager/storage/profiles/SignalProfile.java @@ -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; + } +} diff --git a/lib/src/main/java/org/asamk/signal/manager/storage/profiles/SignalProfileEntry.java b/lib/src/main/java/org/asamk/signal/manager/storage/profiles/SignalProfileEntry.java new file mode 100644 index 00000000..a81fbcb5 --- /dev/null +++ b/lib/src/main/java/org/asamk/signal/manager/storage/profiles/SignalProfileEntry.java @@ -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; + } +} diff --git a/lib/src/main/java/org/asamk/signal/manager/storage/protocol/IdentityInfo.java b/lib/src/main/java/org/asamk/signal/manager/storage/protocol/IdentityInfo.java new file mode 100644 index 00000000..652bf524 --- /dev/null +++ b/lib/src/main/java/org/asamk/signal/manager/storage/protocol/IdentityInfo.java @@ -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(); + } +} diff --git a/lib/src/main/java/org/asamk/signal/manager/storage/protocol/JsonIdentityKeyStore.java b/lib/src/main/java/org/asamk/signal/manager/storage/protocol/JsonIdentityKeyStore.java new file mode 100644 index 00000000..561138c6 --- /dev/null +++ b/lib/src/main/java/org/asamk/signal/manager/storage/protocol/JsonIdentityKeyStore.java @@ -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 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 getIdentities() { + // TODO deep copy + return identities; + } + + public List getIdentities(SignalServiceAddress serviceAddress) { + var identities = new ArrayList(); + for (var identity : this.identities) { + if (identity.address.matches(serviceAddress)) { + identities.add(identity); + } + } + return identities; + } + + public static class JsonIdentityKeyStoreDeserializer extends JsonDeserializer { + + @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 { + + @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(); + } + } +} diff --git a/lib/src/main/java/org/asamk/signal/manager/storage/protocol/JsonPreKeyStore.java b/lib/src/main/java/org/asamk/signal/manager/storage/protocol/JsonPreKeyStore.java new file mode 100644 index 00000000..9ff0d8ea --- /dev/null +++ b/lib/src/main/java/org/asamk/signal/manager/storage/protocol/JsonPreKeyStore.java @@ -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 store = new HashMap<>(); + + public JsonPreKeyStore() { + + } + + private void addPreKeys(Map 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 { + + @Override + public JsonPreKeyStore deserialize( + JsonParser jsonParser, DeserializationContext deserializationContext + ) throws IOException { + JsonNode node = jsonParser.getCodec().readTree(jsonParser); + + var preKeyMap = new HashMap(); + 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 { + + @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(); + } + } +} diff --git a/lib/src/main/java/org/asamk/signal/manager/storage/protocol/JsonSessionStore.java b/lib/src/main/java/org/asamk/signal/manager/storage/protocol/JsonSessionStore.java new file mode 100644 index 00000000..1b5384a4 --- /dev/null +++ b/lib/src/main/java/org/asamk/signal/manager/storage/protocol/JsonSessionStore.java @@ -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 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 getSessions() { + return sessions; + } + + @Override + public synchronized List getSubDeviceSessions(String name) { + var serviceAddress = resolveSignalServiceAddress(name); + + var deviceIds = new LinkedList(); + 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 { + + @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 { + + @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(); + } + } + +} diff --git a/lib/src/main/java/org/asamk/signal/manager/storage/protocol/JsonSignalProtocolStore.java b/lib/src/main/java/org/asamk/signal/manager/storage/protocol/JsonSignalProtocolStore.java new file mode 100644 index 00000000..d47f7a1a --- /dev/null +++ b/lib/src/main/java/org/asamk/signal/manager/storage/protocol/JsonSignalProtocolStore.java @@ -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 getIdentities() { + return identityKeyStore.getIdentities(); + } + + public List 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 getSessions() { + return sessionStore.getSessions(); + } + + @Override + public List 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 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); + } +} diff --git a/lib/src/main/java/org/asamk/signal/manager/storage/protocol/JsonSignedPreKeyStore.java b/lib/src/main/java/org/asamk/signal/manager/storage/protocol/JsonSignedPreKeyStore.java new file mode 100644 index 00000000..655e372a --- /dev/null +++ b/lib/src/main/java/org/asamk/signal/manager/storage/protocol/JsonSignedPreKeyStore.java @@ -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 store = new HashMap<>(); + + public JsonSignedPreKeyStore() { + + } + + private void addSignedPreKeys(Map 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 loadSignedPreKeys() { + try { + var results = new LinkedList(); + + 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 { + + @Override + public JsonSignedPreKeyStore deserialize( + JsonParser jsonParser, DeserializationContext deserializationContext + ) throws IOException { + JsonNode node = jsonParser.getCodec().readTree(jsonParser); + + var preKeyMap = new HashMap(); + 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 { + + @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(); + } + } +} diff --git a/lib/src/main/java/org/asamk/signal/manager/storage/protocol/RecipientStore.java b/lib/src/main/java/org/asamk/signal/manager/storage/protocol/RecipientStore.java new file mode 100644 index 00000000..93cca233 --- /dev/null +++ b/lib/src/main/java/org/asamk/signal/manager/storage/protocol/RecipientStore.java @@ -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 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> { + + @Override + public Set deserialize( + JsonParser jsonParser, DeserializationContext deserializationContext + ) throws IOException { + JsonNode node = jsonParser.getCodec().readTree(jsonParser); + + var addresses = new HashSet(); + + 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> { + + @Override + public void serialize( + Set 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(); + } + } +} diff --git a/lib/src/main/java/org/asamk/signal/manager/storage/protocol/SessionInfo.java b/lib/src/main/java/org/asamk/signal/manager/storage/protocol/SessionInfo.java new file mode 100644 index 00000000..802b896b --- /dev/null +++ b/lib/src/main/java/org/asamk/signal/manager/storage/protocol/SessionInfo.java @@ -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; + } +} diff --git a/lib/src/main/java/org/asamk/signal/manager/storage/protocol/SignalServiceAddressResolver.java b/lib/src/main/java/org/asamk/signal/manager/storage/protocol/SignalServiceAddressResolver.java new file mode 100644 index 00000000..86eea05e --- /dev/null +++ b/lib/src/main/java/org/asamk/signal/manager/storage/protocol/SignalServiceAddressResolver.java @@ -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); +} diff --git a/lib/src/main/java/org/asamk/signal/manager/storage/stickers/Sticker.java b/lib/src/main/java/org/asamk/signal/manager/storage/stickers/Sticker.java new file mode 100644 index 00000000..54e95d0a --- /dev/null +++ b/lib/src/main/java/org/asamk/signal/manager/storage/stickers/Sticker.java @@ -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; + } +} diff --git a/lib/src/main/java/org/asamk/signal/manager/storage/stickers/StickerStore.java b/lib/src/main/java/org/asamk/signal/manager/storage/stickers/StickerStore.java new file mode 100644 index 00000000..8d227575 --- /dev/null +++ b/lib/src/main/java/org/asamk/signal/manager/storage/stickers/StickerStore.java @@ -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 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> { + + @Override + public void serialize( + final Map 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> { + + @Override + public Map deserialize( + JsonParser jsonParser, DeserializationContext deserializationContext + ) throws IOException { + var stickers = new HashMap(); + 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; + } + } +} diff --git a/lib/src/main/java/org/asamk/signal/manager/storage/threads/LegacyJsonThreadStore.java b/lib/src/main/java/org/asamk/signal/manager/storage/threads/LegacyJsonThreadStore.java new file mode 100644 index 00000000..81810713 --- /dev/null +++ b/lib/src/main/java/org/asamk/signal/manager/storage/threads/LegacyJsonThreadStore.java @@ -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 threads = new HashMap<>(); + + public List getThreads() { + return new ArrayList<>(threads.values()); + } + + private static class MapToListSerializer extends JsonSerializer> { + + @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> { + + @Override + public Map deserialize( + JsonParser jsonParser, DeserializationContext deserializationContext + ) throws IOException { + var threads = new HashMap(); + JsonNode node = jsonParser.getCodec().readTree(jsonParser); + for (var n : node) { + var t = jsonProcessor.treeToValue(n, ThreadInfo.class); + threads.put(t.id, t); + } + + return threads; + } + } +} diff --git a/lib/src/main/java/org/asamk/signal/manager/storage/threads/ThreadInfo.java b/lib/src/main/java/org/asamk/signal/manager/storage/threads/ThreadInfo.java new file mode 100644 index 00000000..b81a0051 --- /dev/null +++ b/lib/src/main/java/org/asamk/signal/manager/storage/threads/ThreadInfo.java @@ -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; +} diff --git a/lib/src/main/java/org/asamk/signal/manager/util/AttachmentUtils.java b/lib/src/main/java/org/asamk/signal/manager/util/AttachmentUtils.java new file mode 100644 index 00000000..1909711d --- /dev/null +++ b/lib/src/main/java/org/asamk/signal/manager/util/AttachmentUtils.java @@ -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 getSignalServiceAttachments(List attachments) throws AttachmentInvalidException { + List 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 name + ) { + // TODO mabybe add a parameter to set the voiceNote, borderless, preview, width, height and caption option + final var uploadTimestamp = System.currentTimeMillis(); + Optional preview = Optional.absent(); + Optional caption = Optional.absent(); + Optional blurHash = Optional.absent(); + final Optional resumableUploadSpec = Optional.absent(); + return new SignalServiceAttachmentStream(streamDetails.getStream(), + streamDetails.getContentType(), + streamDetails.getLength(), + name, + false, + false, + preview, + 0, + 0, + uploadTimestamp, + caption, + blurHash, + null, + null, + resumableUploadSpec); + } +} diff --git a/lib/src/main/java/org/asamk/signal/manager/util/IOUtils.java b/lib/src/main/java/org/asamk/signal/manager/util/IOUtils.java new file mode 100644 index 00000000..3cc708d8 --- /dev/null +++ b/lib/src/main/java/org/asamk/signal/manager/util/IOUtils.java @@ -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 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 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); + } + } +} diff --git a/lib/src/main/java/org/asamk/signal/manager/util/KeyUtils.java b/lib/src/main/java/org/asamk/signal/manager/util/KeyUtils.java new file mode 100644 index 00000000..5167331a --- /dev/null +++ b/lib/src/main/java/org/asamk/signal/manager/util/KeyUtils.java @@ -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 generatePreKeyRecords(final int offset, final int batchSize) { + var records = new ArrayList(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; + } +} diff --git a/lib/src/main/java/org/asamk/signal/manager/util/MessageCacheUtils.java b/lib/src/main/java/org/asamk/signal/manager/util/MessageCacheUtils.java new file mode 100644 index 00000000..05ff976e --- /dev/null +++ b/lib/src/main/java/org/asamk/signal/manager/util/MessageCacheUtils.java @@ -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 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()); + } + } + } +} diff --git a/lib/src/main/java/org/asamk/signal/manager/util/PinHashing.java b/lib/src/main/java/org/asamk/signal/manager/util/PinHashing.java new file mode 100644 index 00000000..5dc5a5bc --- /dev/null +++ b/lib/src/main/java/org/asamk/signal/manager/util/PinHashing.java @@ -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; + }); + } +} diff --git a/lib/src/main/java/org/asamk/signal/manager/util/ProfileUtils.java b/lib/src/main/java/org/asamk/signal/manager/util/ProfileUtils.java new file mode 100644 index 00000000..63820b51 --- /dev/null +++ b/lib/src/main/java/org/asamk/signal/manager/util/ProfileUtils.java @@ -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; + } + } +} diff --git a/lib/src/main/java/org/asamk/signal/manager/util/StickerUtils.java b/lib/src/main/java/org/asamk/signal/manager/util/StickerUtils.java new file mode 100644 index 00000000..2fa8bc97 --- /dev/null +++ b/lib/src/main/java/org/asamk/signal/manager/util/StickerUtils.java @@ -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(pack.stickers.size()); + for (var sticker : pack.stickers) { + if (sticker.file == null) { + throw new StickerPackInvalidException("Must set a 'file' field on each sticker."); + } + + Pair 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 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 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()); + } + } + +} diff --git a/lib/src/main/java/org/asamk/signal/manager/util/Utils.java b/lib/src/main/java/org/asamk/signal/manager/util/Utils.java new file mode 100644 index 00000000..2963a996 --- /dev/null +++ b/lib/src/main/java/org/asamk/signal/manager/util/Utils.java @@ -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; + } +} diff --git a/lib/src/main/resources/org/asamk/signal/manager/config/ias.store b/lib/src/main/resources/org/asamk/signal/manager/config/ias.store new file mode 100644 index 00000000..e0b8ec8c Binary files /dev/null and b/lib/src/main/resources/org/asamk/signal/manager/config/ias.store differ diff --git a/lib/src/main/resources/org/asamk/signal/manager/config/whisper.store b/lib/src/main/resources/org/asamk/signal/manager/config/whisper.store new file mode 100644 index 00000000..664ca956 Binary files /dev/null and b/lib/src/main/resources/org/asamk/signal/manager/config/whisper.store differ diff --git a/man/signal-cli-dbus.5.adoc b/man/signal-cli-dbus.5.adoc new file mode 100755 index 00000000..ece2460f --- /dev/null +++ b/man/signal-cli-dbus.5.adoc @@ -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[/_] org.asamk.Signal. [string:] [array::] + +Note: when daemon was started without explicit `-u USERNAME`, the `dbus-send` command requires adding the phone number in `/org/asamk/Signal/_`. + +== 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, arg2, ...) -> return + +Where is according to DBus specification: + +* : String +* : Byte Array +* : Array of Byte Arrays +* : String Array +* : Boolean (0|1) +* : 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 + + +== Methods + +updateGroup(groupId, newName, members, avatar) -> groupId:: +* 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, about , aboutEmoji , avatar, remove) -> <>:: +* 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, block) -> <>:: +* 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, block) -> <>:: +* 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) -> <>:: +* inviteURI : String starting with https://signal.group which is generated when you share a group link via Signal App + +Exceptions: Failure + +quitGroup(groupId) -> <>:: +* 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) -> active:: +* 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) -> <>:: +* recipients : Array of phone numbers + +Exceptions: Failure, InvalidNumber, UntrustedIdentity + +sendGroupMessage(message, attachments, groupId) -> timestamp:: +* 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, attachments) -> timestamp:: +* 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, attachments, recipient) -> timestamp:: +sendMessage(message, attachments, recipients) -> timestamp:: +* 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, remove, targetAuthor, targetSentTimestamp, groupId) -> timestamp:: +* 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, remove, targetAuthor, targetSentTimestamp, recipient) -> timestamp:: +sendMessageReaction(emoji, remove, targetAuthor, targetSentTimestamp, recipients) -> timestamp:: +* 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, groupId) -> timestamp:: +* 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, recipient) -> timestamp:: +sendRemoteDeleteMessage(targetSentTimestamp, recipients) -> timestamp:: +* 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) -> name:: +* 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,name<>) -> <>:: +* number : Phone number +* name : Name to be set in contacts (in local storage with signal-cli) + +getGroupIds() -> groupList:: +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) -> groupName:: +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) -> members:: +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:: +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) -> numbers:: +* 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) -> state:: +* number : Phone number +* state : 1=blocked, 0=not blocked + +Exceptions: None, for unknown numbers 0 (false) is returned + +isGroupBlocked(groupId) -> state:: +* 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:: +* version : Version string of signal-cli + +isRegistred -> result:: +* result : Currently always returns 1=true + +== Signals + +SyncMessageReceived (timestamp, sender, destination, groupId,message, attachments):: +The sync message is received when the user sends a message from a linked device. + +ReceiptReceived (timestamp, sender):: +* 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, sender, groupId, message, attachments):: +* 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 , who is assisted by other open source contributors. +For more information about signal-cli development, see +. diff --git a/man/signal-cli.1.adoc b/man/signal-cli.1.adoc index b5c22167..f6145385 100644 --- a/man/signal-cli.1.adoc +++ b/man/signal-cli.1.adoc @@ -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*: diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 00000000..eb34b67b --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,2 @@ +rootProject.name = "signal-cli" +include("lib") diff --git a/src/main/java/org/asamk/Signal.java b/src/main/java/org/asamk/Signal.java index 317c70f2..0093ab9b 100644 --- a/src/main/java/org/asamk/Signal.java +++ b/src/main/java/org/asamk/Signal.java @@ -15,17 +15,45 @@ public interface Signal extends DBusInterface { long sendMessage( String message, List attachments, String recipient - ) throws Error.AttachmentInvalid, Error.Failure, Error.InvalidNumber; + ) throws Error.AttachmentInvalid, Error.Failure, Error.InvalidNumber, Error.UntrustedIdentity; long sendMessage( String message, List attachments, List recipients - ) throws Error.AttachmentInvalid, Error.Failure, Error.InvalidNumber, Error.UnregisteredUser, Error.UntrustedIdentity; + ) throws Error.AttachmentInvalid, Error.Failure, Error.InvalidNumber, Error.UntrustedIdentity; - void sendEndSessionMessage(List recipients) throws Error.Failure, Error.InvalidNumber, Error.UnregisteredUser, Error.UntrustedIdentity; + long sendRemoteDeleteMessage( + long targetSentTimestamp, String recipient + ) throws Error.Failure, Error.InvalidNumber; + + long sendRemoteDeleteMessage( + long targetSentTimestamp, List recipients + ) throws Error.Failure, Error.InvalidNumber; + + long sendGroupRemoteDeleteMessage( + long targetSentTimestamp, byte[] groupId + ) throws Error.Failure, Error.GroupNotFound; + + long sendMessageReaction( + String emoji, boolean remove, String targetAuthor, long targetSentTimestamp, String recipient + ) throws Error.InvalidNumber, Error.Failure; + + long sendMessageReaction( + String emoji, boolean remove, String targetAuthor, long targetSentTimestamp, List recipients + ) throws Error.InvalidNumber, Error.Failure; + + long sendNoteToSelfMessage( + String message, List attachments + ) throws Error.AttachmentInvalid, Error.Failure; + + void sendEndSessionMessage(List recipients) throws Error.Failure, Error.InvalidNumber, Error.UntrustedIdentity; long sendGroupMessage( String message, List attachments, byte[] groupId - ) throws Error.GroupNotFound, Error.Failure, Error.AttachmentInvalid, Error.UnregisteredUser, Error.UntrustedIdentity; + ) throws Error.GroupNotFound, Error.Failure, Error.AttachmentInvalid; + + long sendGroupMessageReaction( + String emoji, boolean remove, String targetAuthor, long targetSentTimestamp, byte[] groupId + ) throws Error.GroupNotFound, Error.Failure, Error.InvalidNumber; String getContactName(String number) throws Error.InvalidNumber; @@ -43,10 +71,30 @@ public interface Signal extends DBusInterface { byte[] updateGroup( byte[] groupId, String name, List members, String avatar - ) throws Error.AttachmentInvalid, Error.Failure, Error.InvalidNumber, Error.GroupNotFound, Error.UnregisteredUser, Error.UntrustedIdentity; + ) throws Error.AttachmentInvalid, Error.Failure, Error.InvalidNumber, Error.GroupNotFound; boolean isRegistered(); + void updateProfile( + String name, String about, String aboutEmoji, String avatarPath, boolean removeAvatar + ) throws Error.Failure; + + String version(); + + List listNumbers(); + + List getContactNumber(final String name) throws Error.Failure; + + void quitGroup(final byte[] groupId) throws Error.GroupNotFound, Error.Failure; + + boolean isContactBlocked(final String number); + + boolean isGroupBlocked(final byte[] groupId); + + boolean isMember(final byte[] groupId); + + void joinGroup(final String groupLink) throws Error.Failure; + class MessageReceived extends DBusSignal { private final long timestamp; @@ -194,13 +242,6 @@ public interface Signal extends DBusInterface { } } - class UnregisteredUser extends DBusExecutionException { - - public UnregisteredUser(final String message) { - super(message); - } - } - class UntrustedIdentity extends DBusExecutionException { public UntrustedIdentity(final String message) { diff --git a/src/main/java/org/asamk/signal/App.java b/src/main/java/org/asamk/signal/App.java new file mode 100644 index 00000000..873e96bd --- /dev/null +++ b/src/main/java/org/asamk/signal/App.java @@ -0,0 +1,322 @@ +package org.asamk.signal; + +import net.sourceforge.argparse4j.ArgumentParsers; +import net.sourceforge.argparse4j.impl.Arguments; +import net.sourceforge.argparse4j.inf.ArgumentParser; +import net.sourceforge.argparse4j.inf.Namespace; + +import org.asamk.Signal; +import org.asamk.signal.commands.Command; +import org.asamk.signal.commands.Commands; +import org.asamk.signal.commands.DbusCommand; +import org.asamk.signal.commands.ExtendedDbusCommand; +import org.asamk.signal.commands.LocalCommand; +import org.asamk.signal.commands.MultiLocalCommand; +import org.asamk.signal.commands.ProvisioningCommand; +import org.asamk.signal.commands.RegistrationCommand; +import org.asamk.signal.commands.exceptions.CommandException; +import org.asamk.signal.commands.exceptions.UnexpectedErrorException; +import org.asamk.signal.commands.exceptions.UserErrorException; +import org.asamk.signal.manager.Manager; +import org.asamk.signal.manager.NotRegisteredException; +import org.asamk.signal.manager.ProvisioningManager; +import org.asamk.signal.manager.RegistrationManager; +import org.asamk.signal.manager.config.ServiceConfig; +import org.asamk.signal.manager.config.ServiceEnvironment; +import org.asamk.signal.util.IOUtils; +import org.freedesktop.dbus.connections.impl.DBusConnection; +import org.freedesktop.dbus.exceptions.DBusException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.whispersystems.signalservice.api.util.PhoneNumberFormatter; + +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +public class App { + + private final static Logger logger = LoggerFactory.getLogger(App.class); + + private final Namespace ns; + + static ArgumentParser buildArgumentParser() { + var parser = ArgumentParsers.newFor("signal-cli") + .build() + .defaultHelp(true) + .description("Commandline interface for Signal.") + .version(BaseConfig.PROJECT_NAME + " " + BaseConfig.PROJECT_VERSION); + + parser.addArgument("-v", "--version").help("Show package version.").action(Arguments.version()); + parser.addArgument("--verbose") + .help("Raise log level and include lib signal logs.") + .action(Arguments.storeTrue()); + parser.addArgument("--config") + .help("Set the path, where to store the config (Default: $XDG_DATA_HOME/signal-cli , $HOME/.local/share/signal-cli)."); + + parser.addArgument("-u", "--username").help("Specify your phone number, that will be used for verification."); + + var mut = parser.addMutuallyExclusiveGroup(); + mut.addArgument("--dbus").help("Make request via user dbus.").action(Arguments.storeTrue()); + mut.addArgument("--dbus-system").help("Make request via system dbus.").action(Arguments.storeTrue()); + + parser.addArgument("-o", "--output") + .help("Choose to output in plain text or JSON") + .type(Arguments.enumStringType(OutputType.class)) + .setDefault(OutputType.PLAIN_TEXT); + + var subparsers = parser.addSubparsers().title("subcommands").dest("command"); + + final var commands = Commands.getCommands(); + for (var entry : commands.entrySet()) { + var subparser = subparsers.addParser(entry.getKey()); + entry.getValue().attachToSubparser(subparser); + } + + return parser; + } + + public App(final Namespace ns) { + this.ns = ns; + } + + public void init() throws CommandException { + var commandKey = ns.getString("command"); + var command = Commands.getCommand(commandKey); + if (command == null) { + throw new UserErrorException("Command not implemented!"); + } + + OutputType outputType = ns.get("output"); + if (!command.getSupportedOutputTypes().contains(outputType)) { + throw new UserErrorException("Command doesn't support output type " + outputType.toString()); + } + + var username = ns.getString("username"); + + final boolean useDbus = ns.getBoolean("dbus"); + final boolean useDbusSystem = ns.getBoolean("dbus_system"); + if (useDbus || useDbusSystem) { + // If username is null, it will connect to the default object path + initDbusClient(command, username, useDbusSystem); + return; + } + + final File dataPath; + var config = ns.getString("config"); + if (config != null) { + dataPath = new File(config); + } else { + dataPath = getDefaultDataPath(); + } + + final var serviceEnvironment = ServiceEnvironment.LIVE; + + if (!ServiceConfig.getCapabilities().isGv2()) { + logger.warn("WARNING: Support for new group V2 is disabled," + + " because the required native library dependency is missing: libzkgroup"); + } + + if (!ServiceConfig.isSignalClientAvailable()) { + throw new UserErrorException("Missing required native library dependency: libsignal-client"); + } + + if (command instanceof ProvisioningCommand) { + if (username != null) { + throw new UserErrorException("You cannot specify a username (phone number) when linking"); + } + + handleProvisioningCommand((ProvisioningCommand) command, dataPath, serviceEnvironment); + return; + } + + if (username == null) { + var usernames = Manager.getAllLocalUsernames(dataPath); + + if (command instanceof MultiLocalCommand) { + handleMultiLocalCommand((MultiLocalCommand) command, dataPath, serviceEnvironment, usernames); + return; + } + + if (usernames.size() == 0) { + throw new UserErrorException("No local users found, you first need to register or link an account"); + } else if (usernames.size() > 1) { + throw new UserErrorException( + "Multiple users found, you need to specify a username (phone number) with -u"); + } + + username = usernames.get(0); + } else if (!PhoneNumberFormatter.isValidNumber(username, null)) { + throw new UserErrorException("Invalid username (phone number), make sure you include the country code."); + } + + if (command instanceof RegistrationCommand) { + handleRegistrationCommand((RegistrationCommand) command, username, dataPath, serviceEnvironment); + return; + } + + if (!(command instanceof LocalCommand)) { + throw new UserErrorException("Command only works via dbus"); + } + + handleLocalCommand((LocalCommand) command, username, dataPath, serviceEnvironment); + } + + private void handleProvisioningCommand( + final ProvisioningCommand command, final File dataPath, final ServiceEnvironment serviceEnvironment + ) throws CommandException { + var pm = ProvisioningManager.init(dataPath, serviceEnvironment, BaseConfig.USER_AGENT); + command.handleCommand(ns, pm); + } + + private void handleRegistrationCommand( + final RegistrationCommand command, + final String username, + final File dataPath, + final ServiceEnvironment serviceEnvironment + ) throws CommandException { + final RegistrationManager manager; + try { + manager = RegistrationManager.init(username, dataPath, serviceEnvironment, BaseConfig.USER_AGENT); + } catch (Throwable e) { + throw new UnexpectedErrorException("Error loading or creating state file: " + + e.getMessage() + + " (" + + e.getClass().getSimpleName() + + ")"); + } + try (var m = manager) { + command.handleCommand(ns, m); + } catch (IOException e) { + logger.warn("Cleanup failed", e); + } + } + + private void handleLocalCommand( + final LocalCommand command, + final String username, + final File dataPath, + final ServiceEnvironment serviceEnvironment + ) throws CommandException { + try (var m = loadManager(username, dataPath, serviceEnvironment)) { + command.handleCommand(ns, m); + } catch (IOException e) { + logger.warn("Cleanup failed", e); + } + } + + private void handleMultiLocalCommand( + final MultiLocalCommand command, + final File dataPath, + final ServiceEnvironment serviceEnvironment, + final List usernames + ) throws CommandException { + final var managers = new ArrayList(); + for (String u : usernames) { + try { + managers.add(loadManager(u, dataPath, serviceEnvironment)); + } catch (CommandException e) { + logger.warn("Ignoring {}: {}", u, e.getMessage()); + } + } + + command.handleCommand(ns, managers); + + for (var m : managers) { + try { + m.close(); + } catch (IOException e) { + logger.warn("Cleanup failed", e); + } + } + } + + private Manager loadManager( + final String username, final File dataPath, final ServiceEnvironment serviceEnvironment + ) throws CommandException { + Manager manager; + try { + manager = Manager.init(username, dataPath, serviceEnvironment, BaseConfig.USER_AGENT); + } catch (NotRegisteredException e) { + throw new UserErrorException("User " + username + " is not registered."); + } catch (Throwable e) { + throw new UnexpectedErrorException("Error loading state file for user " + + username + + ": " + + e.getMessage() + + " (" + + e.getClass().getSimpleName() + + ")"); + } + + try { + manager.checkAccountState(); + } catch (IOException e) { + throw new UnexpectedErrorException("Error while checking account " + username + ": " + e.getMessage()); + } + + return manager; + } + + private void initDbusClient( + final Command command, final String username, final boolean systemBus + ) throws CommandException { + try { + DBusConnection.DBusBusType busType; + if (systemBus) { + busType = DBusConnection.DBusBusType.SYSTEM; + } else { + busType = DBusConnection.DBusBusType.SESSION; + } + try (var dBusConn = DBusConnection.getConnection(busType)) { + var ts = dBusConn.getRemoteObject(DbusConfig.getBusname(), + DbusConfig.getObjectPath(username), + Signal.class); + + handleCommand(command, ts, dBusConn); + } + } catch (DBusException | IOException e) { + logger.error("Dbus client failed", e); + throw new UnexpectedErrorException("Dbus client failed"); + } + } + + private void handleCommand(Command command, Signal ts, DBusConnection dBusConn) throws CommandException { + if (command instanceof ExtendedDbusCommand) { + ((ExtendedDbusCommand) command).handleCommand(ns, ts, dBusConn); + } else if (command instanceof DbusCommand) { + ((DbusCommand) command).handleCommand(ns, ts); + } else { + throw new UserErrorException("Command is not yet implemented via dbus"); + } + } + + /** + * Uses $XDG_DATA_HOME/signal-cli if it exists, or if none of the legacy directories exist: + * - $HOME/.config/signal + * - $HOME/.config/textsecure + * + * @return the data directory to be used by signal-cli. + */ + private static File getDefaultDataPath() { + var dataPath = new File(IOUtils.getDataHomeDir(), "signal-cli"); + if (dataPath.exists()) { + return dataPath; + } + + var configPath = new File(System.getProperty("user.home"), ".config"); + + var legacySettingsPath = new File(configPath, "signal"); + if (legacySettingsPath.exists()) { + return legacySettingsPath; + } + + legacySettingsPath = new File(configPath, "textsecure"); + if (legacySettingsPath.exists()) { + return legacySettingsPath; + } + + return dataPath; + } +} diff --git a/src/main/java/org/asamk/signal/DbusConfig.java b/src/main/java/org/asamk/signal/DbusConfig.java index c0d23175..eb457c39 100644 --- a/src/main/java/org/asamk/signal/DbusConfig.java +++ b/src/main/java/org/asamk/signal/DbusConfig.java @@ -2,6 +2,22 @@ package org.asamk.signal; public class DbusConfig { - public static final String SIGNAL_BUSNAME = "org.asamk.Signal"; - public static final String SIGNAL_OBJECTPATH = "/org/asamk/Signal"; + private static final String SIGNAL_BUSNAME = "org.asamk.Signal"; + private static final String SIGNAL_OBJECT_BASE_PATH = "/org/asamk/Signal"; + + public static String getBusname() { + return SIGNAL_BUSNAME; + } + + public static String getObjectPath() { + return getObjectPath(null); + } + + public static String getObjectPath(String username) { + if (username == null) { + return SIGNAL_OBJECT_BASE_PATH; + } + + return SIGNAL_OBJECT_BASE_PATH + "/" + username.replace('+', '_'); + } } diff --git a/src/main/java/org/asamk/signal/JsonDbusReceiveMessageHandler.java b/src/main/java/org/asamk/signal/JsonDbusReceiveMessageHandler.java index 50eb9f9b..e0bd793d 100644 --- a/src/main/java/org/asamk/signal/JsonDbusReceiveMessageHandler.java +++ b/src/main/java/org/asamk/signal/JsonDbusReceiveMessageHandler.java @@ -1,19 +1,14 @@ package org.asamk.signal; import org.asamk.Signal; -import org.asamk.signal.manager.GroupUtils; import org.asamk.signal.manager.Manager; +import org.asamk.signal.manager.groups.GroupUtils; import org.freedesktop.dbus.connections.impl.DBusConnection; import org.freedesktop.dbus.exceptions.DBusException; -import org.whispersystems.signalservice.api.messages.SignalServiceAttachment; import org.whispersystems.signalservice.api.messages.SignalServiceContent; import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage; import org.whispersystems.signalservice.api.messages.SignalServiceEnvelope; import org.whispersystems.signalservice.api.messages.SignalServiceGroup; -import org.whispersystems.signalservice.api.messages.SignalServiceReceiptMessage; -import org.whispersystems.signalservice.api.messages.multidevice.SentTranscriptMessage; -import org.whispersystems.signalservice.api.messages.multidevice.SignalServiceSyncMessage; -import org.whispersystems.signalservice.api.push.SignalServiceAddress; import java.util.ArrayList; import java.util.List; @@ -46,11 +41,11 @@ public class JsonDbusReceiveMessageHandler extends JsonReceiveMessageHandler { e.printStackTrace(); } } else if (content != null) { - final SignalServiceAddress sender = !envelope.isUnidentifiedSender() && envelope.hasSource() + final var sender = !envelope.isUnidentifiedSender() && envelope.hasSource() ? envelope.getSourceAddress() : content.getSender(); if (content.getReceiptMessage().isPresent()) { - final SignalServiceReceiptMessage receiptMessage = content.getReceiptMessage().get(); + final var receiptMessage = content.getReceiptMessage().get(); if (receiptMessage.isDeliveryReceipt()) { for (long timestamp : receiptMessage.getTimestamps()) { try { @@ -63,9 +58,9 @@ public class JsonDbusReceiveMessageHandler extends JsonReceiveMessageHandler { } } } else if (content.getDataMessage().isPresent()) { - SignalServiceDataMessage message = content.getDataMessage().get(); + var message = content.getDataMessage().get(); - byte[] groupId = getGroupId(message); + var groupId = getGroupId(message); if (!message.isEndSession() && ( groupId == null || message.getGroupContext().get().getGroupV1Type() == null @@ -83,15 +78,15 @@ public class JsonDbusReceiveMessageHandler extends JsonReceiveMessageHandler { } } } else if (content.getSyncMessage().isPresent()) { - SignalServiceSyncMessage sync_message = content.getSyncMessage().get(); + var sync_message = content.getSyncMessage().get(); if (sync_message.getSent().isPresent()) { - SentTranscriptMessage transcript = sync_message.getSent().get(); + var transcript = sync_message.getSent().get(); if (transcript.getDestination().isPresent() || transcript.getMessage() .getGroupContext() .isPresent()) { - SignalServiceDataMessage message = transcript.getMessage(); - byte[] groupId = getGroupId(message); + var message = transcript.getMessage(); + var groupId = getGroupId(message); try { conn.sendMessage(new Signal.SyncMessageReceived(objectPath, @@ -118,9 +113,9 @@ public class JsonDbusReceiveMessageHandler extends JsonReceiveMessageHandler { } static private List getAttachments(SignalServiceDataMessage message, Manager m) { - List attachments = new ArrayList<>(); + var attachments = new ArrayList(); if (message.getAttachments().isPresent()) { - for (SignalServiceAttachment attachment : message.getAttachments().get()) { + for (var attachment : message.getAttachments().get()) { if (attachment.isPointer()) { attachments.add(m.getAttachmentFile(attachment.asPointer().getRemoteId()).getAbsolutePath()); } diff --git a/src/main/java/org/asamk/signal/JsonReceiveMessageHandler.java b/src/main/java/org/asamk/signal/JsonReceiveMessageHandler.java index 363fc304..c8e1c24f 100644 --- a/src/main/java/org/asamk/signal/JsonReceiveMessageHandler.java +++ b/src/main/java/org/asamk/signal/JsonReceiveMessageHandler.java @@ -1,47 +1,37 @@ package org.asamk.signal; -import com.fasterxml.jackson.annotation.JsonAutoDetect; -import com.fasterxml.jackson.annotation.PropertyAccessor; -import com.fasterxml.jackson.core.JsonGenerator; -import com.fasterxml.jackson.databind.DeserializationFeature; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.node.ObjectNode; - import org.asamk.signal.json.JsonError; import org.asamk.signal.json.JsonMessageEnvelope; import org.asamk.signal.manager.Manager; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.whispersystems.signalservice.api.messages.SignalServiceContent; import org.whispersystems.signalservice.api.messages.SignalServiceEnvelope; -import java.io.IOException; +import java.util.HashMap; public class JsonReceiveMessageHandler implements Manager.ReceiveMessageHandler { - final Manager m; - private final ObjectMapper jsonProcessor; + private final static Logger logger = LoggerFactory.getLogger(JsonReceiveMessageHandler.class); + + protected final Manager m; + private final JsonWriter jsonWriter; public JsonReceiveMessageHandler(Manager m) { this.m = m; - this.jsonProcessor = new ObjectMapper(); - jsonProcessor.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY); // disable autodetect - jsonProcessor.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); - jsonProcessor.disable(JsonGenerator.Feature.AUTO_CLOSE_TARGET); + jsonWriter = new JsonWriter(System.out); } @Override public void handleMessage(SignalServiceEnvelope envelope, SignalServiceContent content, Throwable exception) { - ObjectNode result = jsonProcessor.createObjectNode(); + final var object = new HashMap(); if (exception != null) { - result.putPOJO("error", new JsonError(exception)); + object.put("error", new JsonError(exception)); } if (envelope != null) { - result.putPOJO("envelope", new JsonMessageEnvelope(envelope, content, m)); - } - try { - jsonProcessor.writeValue(System.out, result); - System.out.println(); - } catch (IOException e) { - e.printStackTrace(); + object.put("envelope", new JsonMessageEnvelope(envelope, content, m)); } + + jsonWriter.write(object); } } diff --git a/src/main/java/org/asamk/signal/JsonWriter.java b/src/main/java/org/asamk/signal/JsonWriter.java new file mode 100644 index 00000000..e7549adf --- /dev/null +++ b/src/main/java/org/asamk/signal/JsonWriter.java @@ -0,0 +1,43 @@ +package org.asamk.signal; + +import com.fasterxml.jackson.annotation.JsonAutoDetect; +import com.fasterxml.jackson.annotation.PropertyAccessor; +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.io.BufferedWriter; +import java.io.IOException; +import java.io.OutputStream; +import java.io.OutputStreamWriter; +import java.io.Writer; +import java.nio.charset.StandardCharsets; + +public class JsonWriter { + + private final Writer writer; + private final ObjectMapper objectMapper; + + public JsonWriter(final OutputStream writer) { + this.writer = new BufferedWriter(new OutputStreamWriter(writer, StandardCharsets.UTF_8)); + + objectMapper = new ObjectMapper(); + objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.PUBLIC_ONLY); + objectMapper.disable(JsonGenerator.Feature.AUTO_CLOSE_TARGET); + } + + public void write(final Object object) { + try { + try { + objectMapper.writeValue(writer, object); + } catch (JsonProcessingException e) { + // Some issue with json serialization, probably caused by a bug + throw new AssertionError(e); + } + writer.write(System.lineSeparator()); + writer.flush(); + } catch (IOException e) { + throw new AssertionError(e); + } + } +} diff --git a/src/main/java/org/asamk/signal/Main.java b/src/main/java/org/asamk/signal/Main.java index 6204778d..775b5223 100644 --- a/src/main/java/org/asamk/signal/Main.java +++ b/src/main/java/org/asamk/signal/Main.java @@ -1,5 +1,5 @@ /* - Copyright (C) 2015-2020 AsamK and contributors + 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 @@ -18,282 +18,89 @@ package org.asamk.signal; import net.sourceforge.argparse4j.ArgumentParsers; import net.sourceforge.argparse4j.impl.Arguments; -import net.sourceforge.argparse4j.inf.ArgumentParser; import net.sourceforge.argparse4j.inf.ArgumentParserException; -import net.sourceforge.argparse4j.inf.MutuallyExclusiveGroup; import net.sourceforge.argparse4j.inf.Namespace; -import net.sourceforge.argparse4j.inf.Subparser; -import net.sourceforge.argparse4j.inf.Subparsers; -import org.asamk.Signal; -import org.asamk.signal.commands.Command; -import org.asamk.signal.commands.Commands; -import org.asamk.signal.commands.DbusCommand; -import org.asamk.signal.commands.ExtendedDbusCommand; -import org.asamk.signal.commands.LocalCommand; -import org.asamk.signal.commands.ProvisioningCommand; -import org.asamk.signal.dbus.DbusSignalImpl; -import org.asamk.signal.manager.Manager; -import org.asamk.signal.manager.ProvisioningManager; -import org.asamk.signal.manager.ServiceConfig; -import org.asamk.signal.util.IOUtils; +import org.asamk.signal.commands.exceptions.CommandException; +import org.asamk.signal.commands.exceptions.IOErrorException; +import org.asamk.signal.commands.exceptions.UnexpectedErrorException; +import org.asamk.signal.commands.exceptions.UntrustedKeyErrorException; +import org.asamk.signal.commands.exceptions.UserErrorException; +import org.asamk.signal.manager.LibSignalLogger; import org.asamk.signal.util.SecurityProvider; import org.bouncycastle.jce.provider.BouncyCastleProvider; -import org.freedesktop.dbus.connections.impl.DBusConnection; -import org.freedesktop.dbus.exceptions.DBusException; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.whispersystems.signalservice.api.push.exceptions.AuthorizationFailedException; -import org.whispersystems.signalservice.api.util.PhoneNumberFormatter; -import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration; -import java.io.File; -import java.io.IOException; import java.security.Security; -import java.util.Map; public class Main { - final static Logger logger = LoggerFactory.getLogger(Main.class); - public static void main(String[] args) { installSecurityProviderWorkaround(); - Namespace ns = parseArgs(args); - if (ns == null) { - System.exit(1); - } + // Configuring the logger needs to happen before any logger is initialized + configureLogging(isVerbose(args)); - int res = init(ns); - System.exit(res); + var parser = App.buildArgumentParser(); + + var ns = parser.parseArgsOrFail(args); + + int status = 0; + try { + new App(ns).init(); + } catch (CommandException e) { + System.err.println(e.getMessage()); + status = getStatusForError(e); + } + System.exit(status); } - public static void installSecurityProviderWorkaround() { + private static void installSecurityProviderWorkaround() { // Register our own security provider Security.insertProviderAt(new SecurityProvider(), 1); Security.addProvider(new BouncyCastleProvider()); } - public static int init(Namespace ns) { - if (ns.getBoolean("dbus") || ns.getBoolean("dbus_system")) { - return initDbusClient(ns, ns.getBoolean("dbus_system")); - } - - final String username = ns.getString("username"); - - final File dataPath; - String config = ns.getString("config"); - if (config != null) { - dataPath = new File(config); - } else { - dataPath = getDefaultDataPath(); - } - - final SignalServiceConfiguration serviceConfiguration = ServiceConfig.createDefaultServiceConfiguration( - BaseConfig.USER_AGENT); - - if (!ServiceConfig.getCapabilities().isGv2()) { - logger.warn("WARNING: Support for new group V2 is disabled," - + " because the required native library dependency is missing: libzkgroup"); - } - - if (username == null) { - ProvisioningManager pm = new ProvisioningManager(dataPath, serviceConfiguration, BaseConfig.USER_AGENT); - return handleCommands(ns, pm); - } - - Manager manager; - try { - manager = Manager.init(username, dataPath, serviceConfiguration, BaseConfig.USER_AGENT); - } catch (Throwable e) { - logger.error("Error loading state file: {}", e.getMessage()); - return 2; - } - - try (Manager m = manager) { - try { - m.checkAccountState(); - } catch (AuthorizationFailedException e) { - if (!"register".equals(ns.getString("command"))) { - // Register command should still be possible, if current authorization fails - System.err.println("Authorization failed, was the number registered elsewhere?"); - return 2; - } - } catch (IOException e) { - logger.error("Error while checking account: {}", e.getMessage()); - return 2; - } - - return handleCommands(ns, m); - } catch (IOException e) { - logger.error("Cleanup failed", e); - return 3; - } - } - - private static int initDbusClient(final Namespace ns, final boolean systemBus) { - try { - DBusConnection.DBusBusType busType; - if (systemBus) { - busType = DBusConnection.DBusBusType.SYSTEM; - } else { - busType = DBusConnection.DBusBusType.SESSION; - } - try (DBusConnection dBusConn = DBusConnection.getConnection(busType)) { - Signal ts = dBusConn.getRemoteObject(DbusConfig.SIGNAL_BUSNAME, - DbusConfig.SIGNAL_OBJECTPATH, - Signal.class); - - return handleCommands(ns, ts, dBusConn); - } - } catch (DBusException | IOException e) { - logger.error("Dbus client failed", e); - return 3; - } - } - - private static int handleCommands(Namespace ns, Signal ts, DBusConnection dBusConn) { - String commandKey = ns.getString("command"); - final Map commands = Commands.getCommands(); - if (commands.containsKey(commandKey)) { - Command command = commands.get(commandKey); - - if (command instanceof ExtendedDbusCommand) { - return ((ExtendedDbusCommand) command).handleCommand(ns, ts, dBusConn); - } else if (command instanceof DbusCommand) { - return ((DbusCommand) command).handleCommand(ns, ts); - } else { - System.err.println(commandKey + " is not yet implemented via dbus"); - return 1; - } - } - return 0; - } - - private static int handleCommands(Namespace ns, ProvisioningManager pm) { - String commandKey = ns.getString("command"); - final Map commands = Commands.getCommands(); - if (commands.containsKey(commandKey)) { - Command command = commands.get(commandKey); - - if (command instanceof ProvisioningCommand) { - return ((ProvisioningCommand) command).handleCommand(ns, pm); - } else { - System.err.println(commandKey + " only works with a username"); - return 1; - } - } - return 0; - } - - private static int handleCommands(Namespace ns, Manager m) { - String commandKey = ns.getString("command"); - final Map commands = Commands.getCommands(); - if (commands.containsKey(commandKey)) { - Command command = commands.get(commandKey); - - if (command instanceof LocalCommand) { - return ((LocalCommand) command).handleCommand(ns, m); - } else if (command instanceof DbusCommand) { - return ((DbusCommand) command).handleCommand(ns, new DbusSignalImpl(m)); - } else if (command instanceof ExtendedDbusCommand) { - System.err.println(commandKey + " only works via dbus"); - } - return 1; - } - return 0; - } - - /** - * Uses $XDG_DATA_HOME/signal-cli if it exists, or if none of the legacy directories exist: - * - $HOME/.config/signal - * - $HOME/.config/textsecure - * - * @return the data directory to be used by signal-cli. - */ - private static File getDefaultDataPath() { - File dataPath = new File(IOUtils.getDataHomeDir(), "signal-cli"); - if (dataPath.exists()) { - return dataPath; - } - - File configPath = new File(System.getProperty("user.home"), ".config"); - - File legacySettingsPath = new File(configPath, "signal"); - if (legacySettingsPath.exists()) { - return legacySettingsPath; - } - - legacySettingsPath = new File(configPath, "textsecure"); - if (legacySettingsPath.exists()) { - return legacySettingsPath; - } - - return dataPath; - } - - private static Namespace parseArgs(String[] args) { - ArgumentParser parser = buildArgumentParser(); + private static boolean isVerbose(String[] args) { + var parser = ArgumentParsers.newFor("signal-cli").build().defaultHelp(false); + parser.addArgument("--verbose").action(Arguments.storeTrue()); Namespace ns; try { - ns = parser.parseArgs(args); + ns = parser.parseKnownArgs(args, null); } catch (ArgumentParserException e) { - parser.handleError(e); - return null; + return false; } - if ("link".equals(ns.getString("command"))) { - if (ns.getString("username") != null) { - parser.printUsage(); - System.err.println("You cannot specify a username (phone number) when linking"); - System.exit(2); - } - } else if (!ns.getBoolean("dbus") && !ns.getBoolean("dbus_system")) { - if (ns.getString("username") == null) { - parser.printUsage(); - System.err.println("You need to specify a username (phone number)"); - System.exit(2); - } - if (!PhoneNumberFormatter.isValidNumber(ns.getString("username"), null)) { - System.err.println("Invalid username (phone number), make sure you include the country code."); - System.exit(2); - } - } - if (ns.getList("recipient") != null && !ns.getList("recipient").isEmpty() && ns.getString("group") != null) { - System.err.println("You cannot specify recipients by phone number and groups at the same time"); - System.exit(2); - } - return ns; + return ns.getBoolean("verbose"); } - private static ArgumentParser buildArgumentParser() { - ArgumentParser parser = ArgumentParsers.newFor("signal-cli") - .build() - .defaultHelp(true) - .description("Commandline interface for Signal.") - .version(BaseConfig.PROJECT_NAME + " " + BaseConfig.PROJECT_VERSION); - - parser.addArgument("-v", "--version").help("Show package version.").action(Arguments.version()); - parser.addArgument("--config") - .help("Set the path, where to store the config (Default: $XDG_DATA_HOME/signal-cli , $HOME/.local/share/signal-cli)."); - - MutuallyExclusiveGroup mut = parser.addMutuallyExclusiveGroup(); - mut.addArgument("-u", "--username").help("Specify your phone number, that will be used for verification."); - mut.addArgument("--dbus").help("Make request via user dbus.").action(Arguments.storeTrue()); - mut.addArgument("--dbus-system").help("Make request via system dbus.").action(Arguments.storeTrue()); - - Subparsers subparsers = parser.addSubparsers() - .title("subcommands") - .dest("command") - .description("valid subcommands") - .help("additional help"); - - final Map commands = Commands.getCommands(); - for (Map.Entry entry : commands.entrySet()) { - Subparser subparser = subparsers.addParser(entry.getKey()); - entry.getValue().attachToSubparser(subparser); + private static void configureLogging(final boolean verbose) { + if (verbose) { + System.setProperty("org.slf4j.simpleLogger.defaultLogLevel", "debug"); + System.setProperty("org.slf4j.simpleLogger.showThreadName", "true"); + System.setProperty("org.slf4j.simpleLogger.showShortLogName", "false"); + System.setProperty("org.slf4j.simpleLogger.showDateTime", "true"); + System.setProperty("org.slf4j.simpleLogger.dateTimeFormat", "yyyy-MM-dd'T'HH:mm:ss.SSSXX"); + LibSignalLogger.initLogger(); + } else { + System.setProperty("org.slf4j.simpleLogger.defaultLogLevel", "info"); + System.setProperty("org.slf4j.simpleLogger.showThreadName", "false"); + System.setProperty("org.slf4j.simpleLogger.showShortLogName", "true"); + System.setProperty("org.slf4j.simpleLogger.showDateTime", "false"); + } + } + + private static int getStatusForError(final CommandException e) { + if (e instanceof UserErrorException) { + return 1; + } else if (e instanceof UnexpectedErrorException) { + return 2; + } else if (e instanceof IOErrorException) { + return 3; + } else if (e instanceof UntrustedKeyErrorException) { + return 4; + } else { + return 2; } - return parser; } } diff --git a/src/main/java/org/asamk/signal/OutputType.java b/src/main/java/org/asamk/signal/OutputType.java new file mode 100644 index 00000000..383d635f --- /dev/null +++ b/src/main/java/org/asamk/signal/OutputType.java @@ -0,0 +1,16 @@ +package org.asamk.signal; + +public enum OutputType { + PLAIN_TEXT { + @Override + public String toString() { + return "plain-text"; + } + }, + JSON { + @Override + public String toString() { + return "json"; + } + }, +} diff --git a/src/main/java/org/asamk/signal/PlainTextWriter.java b/src/main/java/org/asamk/signal/PlainTextWriter.java new file mode 100644 index 00000000..de738de9 --- /dev/null +++ b/src/main/java/org/asamk/signal/PlainTextWriter.java @@ -0,0 +1,21 @@ +package org.asamk.signal; + +public interface PlainTextWriter { + + void println(String format, Object... args); + + PlainTextWriter indentedWriter(); + + default void println() { + println(""); + } + + default void indent(final WriterConsumer subWriter) { + subWriter.consume(indentedWriter()); + } + + interface WriterConsumer { + + void consume(PlainTextWriter writer); + } +} diff --git a/src/main/java/org/asamk/signal/PlainTextWriterImpl.java b/src/main/java/org/asamk/signal/PlainTextWriterImpl.java new file mode 100644 index 00000000..bb18b7f3 --- /dev/null +++ b/src/main/java/org/asamk/signal/PlainTextWriterImpl.java @@ -0,0 +1,75 @@ +package org.asamk.signal; + +import org.slf4j.helpers.MessageFormatter; + +import java.io.BufferedWriter; +import java.io.IOException; +import java.io.OutputStream; +import java.io.OutputStreamWriter; +import java.io.Writer; + +public final class PlainTextWriterImpl implements PlainTextWriter { + + private final Writer writer; + + private PlainTextWriter indentedWriter; + + public PlainTextWriterImpl(final OutputStream outputStream) { + this.writer = new BufferedWriter(new OutputStreamWriter(outputStream)); + } + + @Override + public void println(String format, Object... args) { + final var message = MessageFormatter.arrayFormat(format, args).getMessage(); + + try { + writer.write(message); + writer.write(System.lineSeparator()); + writer.flush(); + } catch (IOException e) { + throw new AssertionError(e); + } + } + + @Override + public PlainTextWriter indentedWriter() { + if (indentedWriter == null) { + indentedWriter = new IndentedPlainTextWriter(this, writer); + } + return indentedWriter; + } + + private static final class IndentedPlainTextWriter implements PlainTextWriter { + + private final static int INDENTATION = 2; + + private final String spaces = " ".repeat(INDENTATION); + private final PlainTextWriter plainTextWriter; + private final Writer writer; + + private PlainTextWriter indentedWriter; + + private IndentedPlainTextWriter(final PlainTextWriter plainTextWriter, final Writer writer) { + this.plainTextWriter = plainTextWriter; + this.writer = writer; + } + + @Override + public void println(final String format, final Object... args) { + try { + writer.write(spaces); + } catch (IOException e) { + throw new AssertionError(e); + } + plainTextWriter.println(format, args); + } + + @Override + public PlainTextWriter indentedWriter() { + if (indentedWriter == null) { + indentedWriter = new IndentedPlainTextWriter(this, writer); + } + return indentedWriter; + } + } +} diff --git a/src/main/java/org/asamk/signal/ReceiveMessageHandler.java b/src/main/java/org/asamk/signal/ReceiveMessageHandler.java index 99010e13..91df4b06 100644 --- a/src/main/java/org/asamk/signal/ReceiveMessageHandler.java +++ b/src/main/java/org/asamk/signal/ReceiveMessageHandler.java @@ -1,45 +1,27 @@ package org.asamk.signal; -import org.asamk.signal.manager.GroupId; -import org.asamk.signal.manager.GroupUtils; import org.asamk.signal.manager.Manager; -import org.asamk.signal.storage.contacts.ContactInfo; -import org.asamk.signal.storage.groups.GroupInfo; +import org.asamk.signal.manager.groups.GroupId; +import org.asamk.signal.manager.groups.GroupUtils; import org.asamk.signal.util.DateUtils; import org.asamk.signal.util.Util; +import org.slf4j.helpers.MessageFormatter; +import org.whispersystems.libsignal.UntrustedIdentityException; import org.whispersystems.signalservice.api.messages.SignalServiceAttachment; -import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentPointer; import org.whispersystems.signalservice.api.messages.SignalServiceContent; import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage; import org.whispersystems.signalservice.api.messages.SignalServiceEnvelope; -import org.whispersystems.signalservice.api.messages.SignalServiceGroup; import org.whispersystems.signalservice.api.messages.SignalServiceGroupContext; -import org.whispersystems.signalservice.api.messages.SignalServiceGroupV2; import org.whispersystems.signalservice.api.messages.SignalServiceReceiptMessage; import org.whispersystems.signalservice.api.messages.SignalServiceTypingMessage; -import org.whispersystems.signalservice.api.messages.calls.AnswerMessage; -import org.whispersystems.signalservice.api.messages.calls.BusyMessage; -import org.whispersystems.signalservice.api.messages.calls.HangupMessage; -import org.whispersystems.signalservice.api.messages.calls.IceUpdateMessage; -import org.whispersystems.signalservice.api.messages.calls.OfferMessage; import org.whispersystems.signalservice.api.messages.calls.SignalServiceCallMessage; -import org.whispersystems.signalservice.api.messages.multidevice.BlockedListMessage; -import org.whispersystems.signalservice.api.messages.multidevice.ConfigurationMessage; -import org.whispersystems.signalservice.api.messages.multidevice.ContactsMessage; -import org.whispersystems.signalservice.api.messages.multidevice.KeysMessage; -import org.whispersystems.signalservice.api.messages.multidevice.MessageRequestResponseMessage; -import org.whispersystems.signalservice.api.messages.multidevice.ReadMessage; -import org.whispersystems.signalservice.api.messages.multidevice.SentTranscriptMessage; import org.whispersystems.signalservice.api.messages.multidevice.SignalServiceSyncMessage; -import org.whispersystems.signalservice.api.messages.multidevice.StickerPackOperationMessage; -import org.whispersystems.signalservice.api.messages.multidevice.VerifiedMessage; -import org.whispersystems.signalservice.api.messages.multidevice.ViewOnceOpenMessage; import org.whispersystems.signalservice.api.messages.shared.SharedContact; import org.whispersystems.signalservice.api.push.SignalServiceAddress; -import org.whispersystems.util.Base64; -import java.io.File; -import java.util.List; +import java.io.IOException; +import java.util.Base64; +import java.util.stream.Collectors; public class ReceiveMessageHandler implements Manager.ReceiveMessageHandler { @@ -51,472 +33,645 @@ public class ReceiveMessageHandler implements Manager.ReceiveMessageHandler { @Override public void handleMessage(SignalServiceEnvelope envelope, SignalServiceContent content, Throwable exception) { - if (!envelope.isUnidentifiedSender() && envelope.hasSource()) { - SignalServiceAddress source = envelope.getSourceAddress(); - ContactInfo sourceContact = m.getContact(source.getLegacyIdentifier()); - System.out.println(String.format("Envelope from: %s (device: %d)", - (sourceContact == null ? "" : "“" + sourceContact.name + "” ") + source.getLegacyIdentifier(), - envelope.getSourceDevice())); + try { + printMessage(envelope, content, exception); + } catch (IOException e) { + e.printStackTrace(); + } + } + + private void printMessage( + SignalServiceEnvelope envelope, SignalServiceContent content, Throwable exception + ) throws IOException { + PlainTextWriter writer = new PlainTextWriterImpl(System.out); + + if (envelope.hasSource()) { + var source = envelope.getSourceAddress(); + writer.println("Envelope from: {} (device: {})", formatContact(source), envelope.getSourceDevice()); if (source.getRelay().isPresent()) { - System.out.println("Relayed by: " + source.getRelay().get()); + writer.println("Relayed by: {}", source.getRelay().get()); } } else { - System.out.println("Envelope from: unknown source"); + writer.println("Envelope from: unknown source"); } - System.out.println("Timestamp: " + DateUtils.formatTimestamp(envelope.getTimestamp())); + writer.println("Timestamp: {}", DateUtils.formatTimestamp(envelope.getTimestamp())); if (envelope.isUnidentifiedSender()) { - System.out.println("Sent by unidentified/sealed sender"); + writer.println("Sent by unidentified/sealed sender"); } if (envelope.isReceipt()) { - System.out.println("Got receipt."); + writer.println("Got receipt."); } else if (envelope.isSignalMessage() || envelope.isPreKeySignalMessage() || envelope.isUnidentifiedSender()) { if (exception != null) { - if (exception instanceof org.whispersystems.libsignal.UntrustedIdentityException) { - org.whispersystems.libsignal.UntrustedIdentityException e = (org.whispersystems.libsignal.UntrustedIdentityException) exception; - System.out.println( + if (exception instanceof UntrustedIdentityException) { + var e = (UntrustedIdentityException) exception; + writer.println( "The user’s key is untrusted, either the user has reinstalled Signal or a third party sent this message."); - System.out.println("Use 'signal-cli -u " - + m.getUsername() - + " listIdentities -n " - + e.getName() - + "', verify the key and run 'signal-cli -u " - + m.getUsername() - + " trust -v \"FINGER_PRINT\" " - + e.getName() - + "' to mark it as trusted"); - System.out.println("If you don't care about security, use 'signal-cli -u " - + m.getUsername() - + " trust -a " - + e.getName() - + "' to trust it without verification"); + final var recipientName = m.resolveSignalServiceAddress(e.getName()).getLegacyIdentifier(); + writer.println( + "Use 'signal-cli -u {} listIdentities -n {}', verify the key and run 'signal-cli -u {} trust -v \"FINGER_PRINT\" {}' to mark it as trusted", + m.getUsername(), + recipientName, + m.getUsername(), + recipientName); + writer.println( + "If you don't care about security, use 'signal-cli -u {} trust -a {}' to trust it without verification", + m.getUsername(), + recipientName); } else { - System.out.println("Exception: " + exception.getMessage() + " (" + exception.getClass() - .getSimpleName() + ")"); + writer.println("Exception: {} ({})", exception.getMessage(), exception.getClass().getSimpleName()); } } if (content == null) { - System.out.println("Failed to decrypt message."); + writer.println("Failed to decrypt message."); } else { - ContactInfo sourceContact = m.getContact(content.getSender().getLegacyIdentifier()); - System.out.println(String.format("Sender: %s (device: %d)", - (sourceContact == null ? "" : "“" + sourceContact.name + "” ") + content.getSender() - .getLegacyIdentifier(), - content.getSenderDevice())); + writer.println("Sender: {} (device: {})", + formatContact(content.getSender()), + content.getSenderDevice()); + writer.println("Server timestamps: received: {} delivered: {}", + DateUtils.formatTimestamp(content.getServerReceivedTimestamp()), + DateUtils.formatTimestamp(content.getServerDeliveredTimestamp())); + if (content.getDataMessage().isPresent()) { - SignalServiceDataMessage message = content.getDataMessage().get(); - handleSignalServiceDataMessage(message); + var message = content.getDataMessage().get(); + printDataMessage(writer, message); } if (content.getSyncMessage().isPresent()) { - System.out.println("Received a sync message"); - SignalServiceSyncMessage syncMessage = content.getSyncMessage().get(); - - if (syncMessage.getContacts().isPresent()) { - final ContactsMessage contactsMessage = syncMessage.getContacts().get(); - if (contactsMessage.isComplete()) { - System.out.println("Received complete sync contacts"); - } else { - System.out.println("Received sync contacts"); - } - printAttachment(contactsMessage.getContactsStream()); - } - if (syncMessage.getGroups().isPresent()) { - System.out.println("Received sync groups"); - printAttachment(syncMessage.getGroups().get()); - } - if (syncMessage.getRead().isPresent()) { - System.out.println("Received sync read messages list"); - for (ReadMessage rm : syncMessage.getRead().get()) { - ContactInfo fromContact = m.getContact(rm.getSender().getLegacyIdentifier()); - System.out.println("From: " - + (fromContact == null ? "" : "“" + fromContact.name + "” ") - + rm.getSender().getLegacyIdentifier() - + " Message timestamp: " - + DateUtils.formatTimestamp(rm.getTimestamp())); - } - } - if (syncMessage.getRequest().isPresent()) { - System.out.println("Received sync request"); - if (syncMessage.getRequest().get().isContactsRequest()) { - System.out.println(" - contacts request"); - } - if (syncMessage.getRequest().get().isGroupsRequest()) { - System.out.println(" - groups request"); - } - if (syncMessage.getRequest().get().isBlockedListRequest()) { - System.out.println(" - blocked list request"); - } - if (syncMessage.getRequest().get().isConfigurationRequest()) { - System.out.println(" - configuration request"); - } - if (syncMessage.getRequest().get().isKeysRequest()) { - System.out.println(" - keys request"); - } - } - if (syncMessage.getSent().isPresent()) { - System.out.println("Received sync sent message"); - final SentTranscriptMessage sentTranscriptMessage = syncMessage.getSent().get(); - String to; - if (sentTranscriptMessage.getDestination().isPresent()) { - String dest = sentTranscriptMessage.getDestination().get().getLegacyIdentifier(); - ContactInfo destContact = m.getContact(dest); - to = (destContact == null ? "" : "“" + destContact.name + "” ") + dest; - } else if (sentTranscriptMessage.getRecipients().size() > 0) { - StringBuilder toBuilder = new StringBuilder(); - for (SignalServiceAddress dest : sentTranscriptMessage.getRecipients()) { - ContactInfo destContact = m.getContact(dest.getLegacyIdentifier()); - toBuilder.append(destContact == null ? "" : "“" + destContact.name + "” ") - .append(dest.getLegacyIdentifier()) - .append(" "); - } - to = toBuilder.toString(); - } else { - to = "Unknown"; - } - System.out.println("To: " + to + " , Message timestamp: " + DateUtils.formatTimestamp( - sentTranscriptMessage.getTimestamp())); - if (sentTranscriptMessage.getExpirationStartTimestamp() > 0) { - System.out.println("Expiration started at: " + DateUtils.formatTimestamp( - sentTranscriptMessage.getExpirationStartTimestamp())); - } - SignalServiceDataMessage message = sentTranscriptMessage.getMessage(); - handleSignalServiceDataMessage(message); - } - if (syncMessage.getBlockedList().isPresent()) { - System.out.println("Received sync message with block list"); - System.out.println("Blocked numbers:"); - final BlockedListMessage blockedList = syncMessage.getBlockedList().get(); - for (SignalServiceAddress address : blockedList.getAddresses()) { - System.out.println(" - " + address.getLegacyIdentifier()); - } - } - if (syncMessage.getVerified().isPresent()) { - System.out.println("Received sync message with verified identities:"); - final VerifiedMessage verifiedMessage = syncMessage.getVerified().get(); - System.out.println(" - " - + verifiedMessage.getDestination() - + ": " - + verifiedMessage.getVerified()); - String safetyNumber = Util.formatSafetyNumber(m.computeSafetyNumber(verifiedMessage.getDestination(), - verifiedMessage.getIdentityKey())); - System.out.println(" " + safetyNumber); - } - if (syncMessage.getConfiguration().isPresent()) { - System.out.println("Received sync message with configuration:"); - final ConfigurationMessage configurationMessage = syncMessage.getConfiguration().get(); - if (configurationMessage.getReadReceipts().isPresent()) { - System.out.println(" - Read receipts: " + ( - configurationMessage.getReadReceipts().get() ? "enabled" : "disabled" - )); - } - if (configurationMessage.getLinkPreviews().isPresent()) { - System.out.println(" - Link previews: " + ( - configurationMessage.getLinkPreviews().get() ? "enabled" : "disabled" - )); - } - if (configurationMessage.getTypingIndicators().isPresent()) { - System.out.println(" - Typing indicators: " + ( - configurationMessage.getTypingIndicators().get() ? "enabled" : "disabled" - )); - } - if (configurationMessage.getUnidentifiedDeliveryIndicators().isPresent()) { - System.out.println(" - Unidentified Delivery Indicators: " + ( - configurationMessage.getUnidentifiedDeliveryIndicators().get() - ? "enabled" - : "disabled" - )); - } - } - if (syncMessage.getFetchType().isPresent()) { - final SignalServiceSyncMessage.FetchType fetchType = syncMessage.getFetchType().get(); - System.out.println("Received sync message with fetch type: " + fetchType.toString()); - } - if (syncMessage.getViewOnceOpen().isPresent()) { - final ViewOnceOpenMessage viewOnceOpenMessage = syncMessage.getViewOnceOpen().get(); - System.out.println("Received sync message with view once open message:"); - System.out.println(" - Sender:" + viewOnceOpenMessage.getSender().getLegacyIdentifier()); - System.out.println(" - Timestamp:" + viewOnceOpenMessage.getTimestamp()); - } - if (syncMessage.getStickerPackOperations().isPresent()) { - final List stickerPackOperationMessages = syncMessage.getStickerPackOperations() - .get(); - System.out.println("Received sync message with sticker pack operations:"); - for (StickerPackOperationMessage m : stickerPackOperationMessages) { - System.out.println(" - " + m.getType().toString()); - if (m.getPackId().isPresent()) { - System.out.println(" packId: " + Base64.encodeBytes(m.getPackId().get())); - } - if (m.getPackKey().isPresent()) { - System.out.println(" packKey: " + Base64.encodeBytes(m.getPackKey().get())); - } - } - } - if (syncMessage.getMessageRequestResponse().isPresent()) { - final MessageRequestResponseMessage requestResponseMessage = syncMessage.getMessageRequestResponse() - .get(); - System.out.println("Received message request response:"); - System.out.println(" Type: " + requestResponseMessage.getType()); - if (requestResponseMessage.getGroupId().isPresent()) { - System.out.println(" Group id: " + Base64.encodeBytes(requestResponseMessage.getGroupId() - .get())); - } - if (requestResponseMessage.getPerson().isPresent()) { - System.out.println(" Person: " + requestResponseMessage.getPerson() - .get() - .getLegacyIdentifier()); - } - } - if (syncMessage.getKeys().isPresent()) { - final KeysMessage keysMessage = syncMessage.getKeys().get(); - System.out.println("Received sync message with keys:"); - if (keysMessage.getStorageService().isPresent()) { - System.out.println(" With storage key length: " + keysMessage.getStorageService() - .get() - .serialize().length); - } else { - System.out.println(" With empty storage key"); - } - } + writer.println("Received a sync message"); + var syncMessage = content.getSyncMessage().get(); + printSyncMessage(writer, syncMessage); } + if (content.getCallMessage().isPresent()) { - System.out.println("Received a call message"); - SignalServiceCallMessage callMessage = content.getCallMessage().get(); - if (callMessage.getAnswerMessage().isPresent()) { - AnswerMessage answerMessage = callMessage.getAnswerMessage().get(); - System.out.println("Answer message: " + answerMessage.getId() + ": " + answerMessage.getSdp()); - } - if (callMessage.getBusyMessage().isPresent()) { - BusyMessage busyMessage = callMessage.getBusyMessage().get(); - System.out.println("Busy message: " + busyMessage.getId()); - } - if (callMessage.getHangupMessage().isPresent()) { - HangupMessage hangupMessage = callMessage.getHangupMessage().get(); - System.out.println("Hangup message: " + hangupMessage.getId()); - } - if (callMessage.getIceUpdateMessages().isPresent()) { - List iceUpdateMessages = callMessage.getIceUpdateMessages().get(); - for (IceUpdateMessage iceUpdateMessage : iceUpdateMessages) { - System.out.println("Ice update message: " - + iceUpdateMessage.getId() - + ", sdp: " - + iceUpdateMessage.getSdp()); - } - } - if (callMessage.getOfferMessage().isPresent()) { - OfferMessage offerMessage = callMessage.getOfferMessage().get(); - System.out.println("Offer message: " + offerMessage.getId() + ": " + offerMessage.getSdp()); - } + writer.println("Received a call message"); + var callMessage = content.getCallMessage().get(); + printCallMessage(writer.indentedWriter(), callMessage); } if (content.getReceiptMessage().isPresent()) { - System.out.println("Received a receipt message"); - SignalServiceReceiptMessage receiptMessage = content.getReceiptMessage().get(); - System.out.println(" - When: " + DateUtils.formatTimestamp(receiptMessage.getWhen())); - if (receiptMessage.isDeliveryReceipt()) { - System.out.println(" - Is delivery receipt"); - } - if (receiptMessage.isReadReceipt()) { - System.out.println(" - Is read receipt"); - } - System.out.println(" - Timestamps:"); - for (long timestamp : receiptMessage.getTimestamps()) { - System.out.println(" " + DateUtils.formatTimestamp(timestamp)); - } + writer.println("Received a receipt message"); + var receiptMessage = content.getReceiptMessage().get(); + printReceiptMessage(writer.indentedWriter(), receiptMessage); } if (content.getTypingMessage().isPresent()) { - System.out.println("Received a typing message"); - SignalServiceTypingMessage typingMessage = content.getTypingMessage().get(); - System.out.println(" - Action: " + typingMessage.getAction()); - System.out.println(" - Timestamp: " + DateUtils.formatTimestamp(typingMessage.getTimestamp())); - if (typingMessage.getGroupId().isPresent()) { - System.out.println(" - Group Info:"); - final GroupId groupId = GroupId.unknownVersion(typingMessage.getGroupId().get()); - System.out.println(" Id: " + groupId.toBase64()); - GroupInfo group = m.getGroup(groupId); - if (group != null) { - System.out.println(" Name: " + group.getTitle()); - } else { - System.out.println(" Name: "); - } - } + writer.println("Received a typing message"); + var typingMessage = content.getTypingMessage().get(); + printTypingMessage(writer.indentedWriter(), typingMessage); } } } else { - System.out.println("Unknown message received."); + writer.println("Unknown message received."); } - System.out.println(); + writer.println(); } - private void handleSignalServiceDataMessage(SignalServiceDataMessage message) { - System.out.println("Message timestamp: " + DateUtils.formatTimestamp(message.getTimestamp())); + private void printDataMessage( + PlainTextWriter writer, SignalServiceDataMessage message + ) throws IOException { + writer.println("Message timestamp: {}", DateUtils.formatTimestamp(message.getTimestamp())); if (message.isViewOnce()) { - System.out.println("=VIEW ONCE="); + writer.println("=VIEW ONCE="); } if (message.getBody().isPresent()) { - System.out.println("Body: " + message.getBody().get()); + writer.println("Body: {}", message.getBody().get()); } if (message.getGroupContext().isPresent()) { - System.out.println("Group info:"); - final SignalServiceGroupContext groupContext = message.getGroupContext().get(); - final GroupId groupId = GroupUtils.getGroupId(groupContext); - if (groupContext.getGroupV1().isPresent()) { - SignalServiceGroup groupInfo = groupContext.getGroupV1().get(); - System.out.println(" Id: " + groupId.toBase64()); - if (groupInfo.getType() == SignalServiceGroup.Type.UPDATE && groupInfo.getName().isPresent()) { - System.out.println(" Name: " + groupInfo.getName().get()); - } else { - GroupInfo group = m.getGroup(groupId); - if (group != null) { - System.out.println(" Name: " + group.getTitle()); - } else { - System.out.println(" Name: "); - } - } - System.out.println(" Type: " + groupInfo.getType()); - if (groupInfo.getMembers().isPresent()) { - for (SignalServiceAddress member : groupInfo.getMembers().get()) { - System.out.println(" Member: " + member.getLegacyIdentifier()); - } - } - if (groupInfo.getAvatar().isPresent()) { - System.out.println(" Avatar:"); - printAttachment(groupInfo.getAvatar().get()); - } - } else if (groupContext.getGroupV2().isPresent()) { - final SignalServiceGroupV2 groupInfo = groupContext.getGroupV2().get(); - System.out.println(" Id: " + groupId.toBase64()); - GroupInfo group = m.getGroup(groupId); - if (group != null) { - System.out.println(" Name: " + group.getTitle()); - } else { - System.out.println(" Name: "); - } - System.out.println(" Revision: " + groupInfo.getRevision()); - System.out.println(" Master key length: " + groupInfo.getMasterKey().serialize().length); - System.out.println(" Has signed group change: " + groupInfo.hasSignedGroupChange()); - } + writer.println("Group info:"); + final var groupContext = message.getGroupContext().get(); + printGroupContext(writer.indentedWriter(), groupContext); + } + if (message.getGroupCallUpdate().isPresent()) { + writer.println("Group call update:"); + final var groupCallUpdate = message.getGroupCallUpdate().get(); + writer.indentedWriter().println("Era id: {}", groupCallUpdate.getEraId()); } if (message.getPreviews().isPresent()) { - final List previews = message.getPreviews().get(); - System.out.println("Previews:"); - for (SignalServiceDataMessage.Preview preview : previews) { - System.out.println(" - Title: " + preview.getTitle()); - System.out.println(" - Url: " + preview.getUrl()); - if (preview.getImage().isPresent()) { - printAttachment(preview.getImage().get()); - } + writer.println("Previews:"); + final var previews = message.getPreviews().get(); + for (var preview : previews) { + writer.println("- Preview"); + printPreview(writer.indentedWriter(), preview); } } if (message.getSharedContacts().isPresent()) { - final List sharedContacts = message.getSharedContacts().get(); - System.out.println("Contacts:"); - for (SharedContact contact : sharedContacts) { - System.out.println(" - Name: " + contact.getName()); - // TODO show or store rest of the contact info + final var sharedContacts = message.getSharedContacts().get(); + writer.println("Contacts:"); + for (var contact : sharedContacts) { + writer.println("- Contact:"); + printSharedContact(writer.indentedWriter(), contact); } } if (message.getSticker().isPresent()) { - final SignalServiceDataMessage.Sticker sticker = message.getSticker().get(); - System.out.println("Sticker:"); - System.out.println(" - Pack id: " + Base64.encodeBytes(sticker.getPackId())); - System.out.println(" - Pack key: " + Base64.encodeBytes(sticker.getPackKey())); - System.out.println(" - Sticker id: " + sticker.getStickerId()); - // TODO also download sticker image ?? + final var sticker = message.getSticker().get(); + writer.println("Sticker:"); + printSticker(writer.indentedWriter(), sticker); } if (message.isEndSession()) { - System.out.println("Is end session"); + writer.println("Is end session"); } if (message.isExpirationUpdate()) { - System.out.println("Is Expiration update: " + message.isExpirationUpdate()); + writer.println("Is Expiration update: {}", message.isExpirationUpdate()); } if (message.getExpiresInSeconds() > 0) { - System.out.println("Expires in: " + message.getExpiresInSeconds() + " seconds"); + writer.println("Expires in: {} seconds", message.getExpiresInSeconds()); } if (message.getProfileKey().isPresent()) { - System.out.println("Profile key update, key length:" + message.getProfileKey().get().length); + writer.println("Profile key update, key length: {}", message.getProfileKey().get().length); } - if (message.getReaction().isPresent()) { - final SignalServiceDataMessage.Reaction reaction = message.getReaction().get(); - System.out.println("Reaction:"); - System.out.println(" - Emoji: " + reaction.getEmoji()); - System.out.println(" - Target author: " + m.resolveSignalServiceAddress(reaction.getTargetAuthor()) - .getLegacyIdentifier()); - System.out.println(" - Target timestamp: " + reaction.getTargetSentTimestamp()); - System.out.println(" - Is remove: " + reaction.isRemove()); + writer.println("Reaction:"); + final var reaction = message.getReaction().get(); + printReaction(writer.indentedWriter(), reaction); } - if (message.getQuote().isPresent()) { - SignalServiceDataMessage.Quote quote = message.getQuote().get(); - System.out.println("Quote: (" + quote.getId() + ")"); - System.out.println(" Author: " + m.resolveSignalServiceAddress(quote.getAuthor()).getLegacyIdentifier()); - System.out.println(" Text: " + quote.getText()); - if (quote.getMentions() != null && quote.getMentions().size() > 0) { - System.out.println(" Mentions: "); - for (SignalServiceDataMessage.Mention mention : quote.getMentions()) { - printMention(mention, m); - } - } - if (quote.getAttachments().size() > 0) { - System.out.println(" Attachments: "); - for (SignalServiceDataMessage.Quote.QuotedAttachment attachment : quote.getAttachments()) { - System.out.println(" - Filename: " + attachment.getFileName()); - System.out.println(" Type: " + attachment.getContentType()); - System.out.println(" Thumbnail:"); - if (attachment.getThumbnail() != null) { - printAttachment(attachment.getThumbnail()); - } - } - } + writer.println("Quote:"); + var quote = message.getQuote().get(); + printQuote(writer.indentedWriter(), quote); } - if (message.getRemoteDelete().isPresent()) { - final SignalServiceDataMessage.RemoteDelete remoteDelete = message.getRemoteDelete().get(); - System.out.println("Remote delete message: timestamp = " + remoteDelete.getTargetSentTimestamp()); + final var remoteDelete = message.getRemoteDelete().get(); + writer.println("Remote delete message: timestamp = {}", remoteDelete.getTargetSentTimestamp()); } if (message.getMentions().isPresent()) { - System.out.println("Mentions: "); - for (SignalServiceDataMessage.Mention mention : message.getMentions().get()) { - printMention(mention, m); + writer.println("Mentions:"); + for (var mention : message.getMentions().get()) { + printMention(writer, mention); } } - if (message.getAttachments().isPresent()) { - System.out.println("Attachments: "); - for (SignalServiceAttachment attachment : message.getAttachments().get()) { - printAttachment(attachment); + writer.println("Attachments:"); + for (var attachment : message.getAttachments().get()) { + writer.println("- Attachment:"); + printAttachment(writer.indentedWriter(), attachment); } } } - private void printMention(SignalServiceDataMessage.Mention mention, Manager m) { - System.out.println("- " + m.resolveSignalServiceAddress(new SignalServiceAddress(mention.getUuid(), null)) - .getLegacyIdentifier() + ": " + mention.getStart() + " (length: " + mention.getLength() + ")"); + private void printTypingMessage( + final PlainTextWriter writer, final SignalServiceTypingMessage typingMessage + ) throws IOException { + writer.println("Action: {}", typingMessage.getAction()); + writer.println("Timestamp: {}", DateUtils.formatTimestamp(typingMessage.getTimestamp())); + if (typingMessage.getGroupId().isPresent()) { + writer.println("Group Info:"); + final var groupId = GroupId.unknownVersion(typingMessage.getGroupId().get()); + printGroupInfo(writer.indentedWriter(), groupId); + } } - private void printAttachment(SignalServiceAttachment attachment) { - System.out.println("- " + attachment.getContentType() + " (" + (attachment.isPointer() ? "Pointer" : "") + ( - attachment.isStream() ? "Stream" : "" - ) + ")"); + private void printReceiptMessage( + final PlainTextWriter writer, final SignalServiceReceiptMessage receiptMessage + ) throws IOException { + writer.println("When: {}", DateUtils.formatTimestamp(receiptMessage.getWhen())); + if (receiptMessage.isDeliveryReceipt()) { + writer.println("Is delivery receipt"); + } + if (receiptMessage.isReadReceipt()) { + writer.println("Is read receipt"); + } + if (receiptMessage.isViewedReceipt()) { + writer.println("Is viewed receipt"); + } + writer.println("Timestamps:"); + for (long timestamp : receiptMessage.getTimestamps()) { + writer.println("- {}", DateUtils.formatTimestamp(timestamp)); + } + } + + private void printCallMessage( + final PlainTextWriter writer, final SignalServiceCallMessage callMessage + ) throws IOException { + if (callMessage.getDestinationDeviceId().isPresent()) { + final var deviceId = callMessage.getDestinationDeviceId().get(); + writer.println("Destination device id: {}", deviceId); + } + if (callMessage.getAnswerMessage().isPresent()) { + var answerMessage = callMessage.getAnswerMessage().get(); + writer.println("Answer message: {}, sdp: {})", answerMessage.getId(), answerMessage.getSdp()); + } + if (callMessage.getBusyMessage().isPresent()) { + var busyMessage = callMessage.getBusyMessage().get(); + writer.println("Busy message: {}", busyMessage.getId()); + } + if (callMessage.getHangupMessage().isPresent()) { + var hangupMessage = callMessage.getHangupMessage().get(); + writer.println("Hangup message: {}", hangupMessage.getId()); + } + if (callMessage.getIceUpdateMessages().isPresent()) { + writer.println("Ice update messages:"); + var iceUpdateMessages = callMessage.getIceUpdateMessages().get(); + for (var iceUpdateMessage : iceUpdateMessages) { + writer.println("- {}, sdp: {}", iceUpdateMessage.getId(), iceUpdateMessage.getSdp()); + } + } + if (callMessage.getOfferMessage().isPresent()) { + var offerMessage = callMessage.getOfferMessage().get(); + writer.println("Offer message: {}, sdp: {}", offerMessage.getId(), offerMessage.getSdp()); + } + if (callMessage.getOpaqueMessage().isPresent()) { + final var opaqueMessage = callMessage.getOpaqueMessage().get(); + writer.println("Opaque message: size {}", opaqueMessage.getOpaque().length); + } + } + + private void printSyncMessage( + final PlainTextWriter writer, final SignalServiceSyncMessage syncMessage + ) throws IOException { + if (syncMessage.getContacts().isPresent()) { + final var contactsMessage = syncMessage.getContacts().get(); + var type = contactsMessage.isComplete() ? "complete" : "partial"; + writer.println("Received {} sync contacts:", type); + printAttachment(writer.indentedWriter(), contactsMessage.getContactsStream()); + } + if (syncMessage.getGroups().isPresent()) { + writer.println("Received sync groups:"); + printAttachment(writer.indentedWriter(), syncMessage.getGroups().get()); + } + if (syncMessage.getRead().isPresent()) { + writer.println("Received sync read messages list"); + for (var rm : syncMessage.getRead().get()) { + writer.println("- From: {} Message timestamp: {}", + formatContact(rm.getSender()), + DateUtils.formatTimestamp(rm.getTimestamp())); + } + } + if (syncMessage.getRequest().isPresent()) { + String type; + if (syncMessage.getRequest().get().isContactsRequest()) { + type = "contacts"; + } else if (syncMessage.getRequest().get().isGroupsRequest()) { + type = "groups"; + } else if (syncMessage.getRequest().get().isBlockedListRequest()) { + type = "blocked list"; + } else if (syncMessage.getRequest().get().isConfigurationRequest()) { + type = "configuration"; + } else if (syncMessage.getRequest().get().isKeysRequest()) { + type = "keys"; + } else { + type = ""; + } + writer.println("Received sync request for: {}", type); + } + if (syncMessage.getSent().isPresent()) { + writer.println("Received sync sent message"); + final var sentTranscriptMessage = syncMessage.getSent().get(); + String to; + if (sentTranscriptMessage.getDestination().isPresent()) { + to = formatContact(sentTranscriptMessage.getDestination().get()); + } else if (sentTranscriptMessage.getRecipients().size() > 0) { + to = sentTranscriptMessage.getRecipients() + .stream() + .map(this::formatContact) + .collect(Collectors.joining(", ")); + } else { + to = ""; + } + writer.indentedWriter().println("To: {}", to); + writer.indentedWriter() + .println("Timestamp: {}", DateUtils.formatTimestamp(sentTranscriptMessage.getTimestamp())); + if (sentTranscriptMessage.getExpirationStartTimestamp() > 0) { + writer.indentedWriter() + .println("Expiration started at: {}", + DateUtils.formatTimestamp(sentTranscriptMessage.getExpirationStartTimestamp())); + } + var message = sentTranscriptMessage.getMessage(); + printDataMessage(writer.indentedWriter(), message); + } + if (syncMessage.getBlockedList().isPresent()) { + writer.println("Received sync message with block list"); + writer.println("Blocked numbers:"); + final var blockedList = syncMessage.getBlockedList().get(); + for (var address : blockedList.getAddresses()) { + writer.println("- {}", address.getLegacyIdentifier()); + } + } + if (syncMessage.getVerified().isPresent()) { + writer.println("Received sync message with verified identities:"); + final var verifiedMessage = syncMessage.getVerified().get(); + writer.println("- {}: {}", formatContact(verifiedMessage.getDestination()), verifiedMessage.getVerified()); + var safetyNumber = Util.formatSafetyNumber(m.computeSafetyNumber(verifiedMessage.getDestination(), + verifiedMessage.getIdentityKey())); + writer.indentedWriter().println(safetyNumber); + } + if (syncMessage.getConfiguration().isPresent()) { + writer.println("Received sync message with configuration:"); + final var configurationMessage = syncMessage.getConfiguration().get(); + if (configurationMessage.getReadReceipts().isPresent()) { + writer.println("- Read receipts: {}", + configurationMessage.getReadReceipts().get() ? "enabled" : "disabled"); + } + if (configurationMessage.getLinkPreviews().isPresent()) { + writer.println("- Link previews: {}", + configurationMessage.getLinkPreviews().get() ? "enabled" : "disabled"); + } + if (configurationMessage.getTypingIndicators().isPresent()) { + writer.println("- Typing indicators: {}", + configurationMessage.getTypingIndicators().get() ? "enabled" : "disabled"); + } + if (configurationMessage.getUnidentifiedDeliveryIndicators().isPresent()) { + writer.println("- Unidentified Delivery Indicators: {}", + configurationMessage.getUnidentifiedDeliveryIndicators().get() ? "enabled" : "disabled"); + } + } + if (syncMessage.getFetchType().isPresent()) { + final var fetchType = syncMessage.getFetchType().get(); + writer.println("Received sync message with fetch type: {}", fetchType); + } + if (syncMessage.getViewOnceOpen().isPresent()) { + final var viewOnceOpenMessage = syncMessage.getViewOnceOpen().get(); + writer.println("Received sync message with view once open message:"); + writer.indentedWriter().println("Sender: {}", formatContact(viewOnceOpenMessage.getSender())); + writer.indentedWriter() + .println("Timestamp: {}", DateUtils.formatTimestamp(viewOnceOpenMessage.getTimestamp())); + } + if (syncMessage.getStickerPackOperations().isPresent()) { + final var stickerPackOperationMessages = syncMessage.getStickerPackOperations().get(); + writer.println("Received sync message with sticker pack operations:"); + for (var m : stickerPackOperationMessages) { + writer.println("- {}", m.getType().isPresent() ? m.getType().get() : ""); + if (m.getPackId().isPresent()) { + writer.indentedWriter() + .println("packId: {}", Base64.getEncoder().encodeToString(m.getPackId().get())); + } + if (m.getPackKey().isPresent()) { + writer.indentedWriter() + .println("packKey: {}", Base64.getEncoder().encodeToString(m.getPackKey().get())); + } + } + } + if (syncMessage.getMessageRequestResponse().isPresent()) { + final var requestResponseMessage = syncMessage.getMessageRequestResponse().get(); + writer.println("Received message request response:"); + writer.indentedWriter().println("Type: {}", requestResponseMessage.getType()); + if (requestResponseMessage.getGroupId().isPresent()) { + writer.println("For group:"); + printGroupInfo(writer.indentedWriter(), + GroupId.unknownVersion(requestResponseMessage.getGroupId().get())); + } + if (requestResponseMessage.getPerson().isPresent()) { + writer.indentedWriter() + .println("For Person: {}", formatContact(requestResponseMessage.getPerson().get())); + } + } + if (syncMessage.getKeys().isPresent()) { + final var keysMessage = syncMessage.getKeys().get(); + writer.println("Received sync message with keys:"); + if (keysMessage.getStorageService().isPresent()) { + writer.println("- storage key: length: {}", keysMessage.getStorageService().get().serialize().length); + } + } + } + + private void printPreview( + final PlainTextWriter writer, final SignalServiceDataMessage.Preview preview + ) throws IOException { + writer.println("Title: {}", preview.getTitle()); + writer.println("Description: {}", preview.getDescription()); + writer.println("Date: {}", DateUtils.formatTimestamp(preview.getDate())); + writer.println("Url: {}", preview.getUrl()); + if (preview.getImage().isPresent()) { + writer.println("Image:"); + printAttachment(writer.indentedWriter(), preview.getImage().get()); + } + } + + private void printSticker( + final PlainTextWriter writer, final SignalServiceDataMessage.Sticker sticker + ) throws IOException { + writer.println("Pack id: {}", Base64.getEncoder().encodeToString(sticker.getPackId())); + writer.println("Pack key: {}", Base64.getEncoder().encodeToString(sticker.getPackKey())); + writer.println("Sticker id: {}", sticker.getStickerId()); + writer.println("Image:"); + printAttachment(writer.indentedWriter(), sticker.getAttachment()); + } + + private void printReaction( + final PlainTextWriter writer, final SignalServiceDataMessage.Reaction reaction + ) throws IOException { + writer.println("Emoji: {}", reaction.getEmoji()); + writer.println("Target author: {}", formatContact(m.resolveSignalServiceAddress(reaction.getTargetAuthor()))); + writer.println("Target timestamp: {}", DateUtils.formatTimestamp(reaction.getTargetSentTimestamp())); + writer.println("Is remove: {}", reaction.isRemove()); + } + + private void printQuote( + final PlainTextWriter writer, final SignalServiceDataMessage.Quote quote + ) throws IOException { + writer.println("Id: {}", quote.getId()); + writer.println("Author: {}", m.resolveSignalServiceAddress(quote.getAuthor()).getLegacyIdentifier()); + writer.println("Text: {}", quote.getText()); + if (quote.getMentions() != null && quote.getMentions().size() > 0) { + writer.println("Mentions:"); + for (var mention : quote.getMentions()) { + printMention(writer, mention); + } + } + if (quote.getAttachments().size() > 0) { + writer.println("Attachments:"); + for (var attachment : quote.getAttachments()) { + writer.println("- Filename: {}", attachment.getFileName()); + writer.indent(w -> { + w.println("Type: {}", attachment.getContentType()); + w.println("Thumbnail:"); + if (attachment.getThumbnail() != null) { + printAttachment(w, attachment.getThumbnail()); + } + }); + } + } + } + + private void printSharedContact(final PlainTextWriter writer, final SharedContact contact) throws IOException { + writer.println("Name:"); + var name = contact.getName(); + writer.indent(w -> { + if (name.getDisplay().isPresent() && !name.getDisplay().get().isBlank()) { + w.println("Display name: {}", name.getDisplay().get()); + } + if (name.getGiven().isPresent() && !name.getGiven().get().isBlank()) { + w.println("First name: {}", name.getGiven().get()); + } + if (name.getMiddle().isPresent() && !name.getMiddle().get().isBlank()) { + w.println("Middle name: {}", name.getMiddle().get()); + } + if (name.getFamily().isPresent() && !name.getFamily().get().isBlank()) { + w.println("Family name: {}", name.getFamily().get()); + } + if (name.getPrefix().isPresent() && !name.getPrefix().get().isBlank()) { + w.println("Prefix name: {}", name.getPrefix().get()); + } + if (name.getSuffix().isPresent() && !name.getSuffix().get().isBlank()) { + w.println("Suffix name: {}", name.getSuffix().get()); + } + }); + + if (contact.getAvatar().isPresent()) { + var avatar = contact.getAvatar().get(); + writer.println("Avatar: (profile: {})", avatar.isProfile()); + printAttachment(writer.indentedWriter(), avatar.getAttachment()); + } + + if (contact.getOrganization().isPresent()) { + writer.println("Organisation: {}", contact.getOrganization().get()); + } + + if (contact.getPhone().isPresent()) { + writer.println("Phone details:"); + for (var phone : contact.getPhone().get()) { + writer.println("- Phone:"); + writer.indent(w -> { + if (phone.getValue() != null) { + w.println("Number: {}", phone.getValue()); + } + if (phone.getType() != null) { + w.println("Type: {}", phone.getType()); + } + if (phone.getLabel().isPresent() && !phone.getLabel().get().isBlank()) { + w.println("Label: {}", phone.getLabel().get()); + } + }); + } + } + + if (contact.getEmail().isPresent()) { + writer.println("Email details:"); + for (var email : contact.getEmail().get()) { + writer.println("- Email:"); + writer.indent(w -> { + if (email.getValue() != null) { + w.println("Address: {}", email.getValue()); + } + if (email.getType() != null) { + w.println("Type: {}", email.getType()); + } + if (email.getLabel().isPresent() && !email.getLabel().get().isBlank()) { + w.println("Label: {}", email.getLabel().get()); + } + }); + } + } + + if (contact.getAddress().isPresent()) { + writer.println("Address details:"); + for (var address : contact.getAddress().get()) { + writer.println("- Address:"); + writer.indent(w -> { + if (address.getType() != null) { + w.println("Type: {}", address.getType()); + } + if (address.getLabel().isPresent() && !address.getLabel().get().isBlank()) { + w.println("Label: {}", address.getLabel().get()); + } + if (address.getStreet().isPresent() && !address.getStreet().get().isBlank()) { + w.println("Street: {}", address.getStreet().get()); + } + if (address.getPobox().isPresent() && !address.getPobox().get().isBlank()) { + w.println("Pobox: {}", address.getPobox().get()); + } + if (address.getNeighborhood().isPresent() && !address.getNeighborhood().get().isBlank()) { + w.println("Neighbourhood: {}", address.getNeighborhood().get()); + } + if (address.getCity().isPresent() && !address.getCity().get().isBlank()) { + w.println("City: {}", address.getCity().get()); + } + if (address.getRegion().isPresent() && !address.getRegion().get().isBlank()) { + w.println("Region: {}", address.getRegion().get()); + } + if (address.getPostcode().isPresent() && !address.getPostcode().get().isBlank()) { + w.println("Postcode: {}", address.getPostcode().get()); + } + if (address.getCountry().isPresent() && !address.getCountry().get().isBlank()) { + w.println("Country: {}", address.getCountry().get()); + } + }); + } + } + } + + private void printGroupContext( + final PlainTextWriter writer, final SignalServiceGroupContext groupContext + ) throws IOException { + final var groupId = GroupUtils.getGroupId(groupContext); + if (groupContext.getGroupV1().isPresent()) { + var groupInfo = groupContext.getGroupV1().get(); + printGroupInfo(writer, groupId); + writer.println("Type: {}", groupInfo.getType()); + if (groupInfo.getMembers().isPresent()) { + writer.println("Members:"); + for (var member : groupInfo.getMembers().get()) { + writer.println("- {}", formatContact(member)); + } + } + if (groupInfo.getAvatar().isPresent()) { + writer.println("Avatar:"); + printAttachment(writer.indentedWriter(), groupInfo.getAvatar().get()); + } + } else if (groupContext.getGroupV2().isPresent()) { + final var groupInfo = groupContext.getGroupV2().get(); + printGroupInfo(writer, groupId); + writer.println("Revision: {}", groupInfo.getRevision()); + writer.println("Master key length: {}", groupInfo.getMasterKey().serialize().length); + writer.println("Has signed group change: {}", groupInfo.hasSignedGroupChange()); + } + } + + private void printGroupInfo(final PlainTextWriter writer, final GroupId groupId) throws IOException { + writer.println("Id: {}", groupId.toBase64()); + + var group = m.getGroup(groupId); + if (group != null) { + writer.println("Name: {}", group.getTitle()); + } else { + writer.println("Name: "); + } + } + + private void printMention( + PlainTextWriter writer, SignalServiceDataMessage.Mention mention + ) throws IOException { + final var address = m.resolveSignalServiceAddress(new SignalServiceAddress(mention.getUuid(), null)); + writer.println("- {}: {} (length: {})", formatContact(address), mention.getStart(), mention.getLength()); + } + + private void printAttachment(PlainTextWriter writer, SignalServiceAttachment attachment) { + writer.println("Content-Type: {}", attachment.getContentType()); + writer.println("Type: {}", attachment.isPointer() ? "Pointer" : attachment.isStream() ? "Stream" : ""); if (attachment.isPointer()) { - final SignalServiceAttachmentPointer pointer = attachment.asPointer(); - System.out.println(" Id: " + pointer.getRemoteId() + " Key length: " + pointer.getKey().length); - System.out.println(" Filename: " + ( - pointer.getFileName().isPresent() ? pointer.getFileName().get() : "-" - )); - System.out.println(" Size: " + ( - pointer.getSize().isPresent() ? pointer.getSize().get() + " bytes" : "" - ) + ( + final var pointer = attachment.asPointer(); + writer.println("Id: {} Key length: {}", pointer.getRemoteId(), pointer.getKey().length); + if (pointer.getUploadTimestamp() > 0) { + writer.println("Upload timestamp: {}", DateUtils.formatTimestamp(pointer.getUploadTimestamp())); + } + if (pointer.getCaption().isPresent()) { + writer.println("Caption: {}", pointer.getCaption().get()); + } + if (pointer.getFileName().isPresent()) { + writer.println("Filename: {}", pointer.getFileName().get()); + } + writer.println("Size: {}{}", + pointer.getSize().isPresent() ? pointer.getSize().get() + " bytes" : "", pointer.getPreview().isPresent() ? " (Preview is available: " + pointer.getPreview().get().length - + " bytes)" : "" - )); - System.out.println(" Voice note: " + (pointer.getVoiceNote() ? "yes" : "no")); - System.out.println(" Dimensions: " + pointer.getWidth() + "x" + pointer.getHeight()); - File file = m.getAttachmentFile(pointer.getRemoteId()); - if (file.exists()) { - System.out.println(" Stored plaintext in: " + file); + + " bytes)" : ""); + writer.println("Voice note: {}", pointer.getVoiceNote() ? "yes" : "no"); + writer.println("Borderless: {}", pointer.isBorderless() ? "yes" : "no"); + if (pointer.getWidth() > 0 || pointer.getHeight() > 0) { + writer.println("Dimensions: {}x{}", pointer.getWidth(), pointer.getHeight()); } + var file = m.getAttachmentFile(pointer.getRemoteId()); + if (file.exists()) { + writer.println("Stored plaintext in: {}", file); + } + } + } + + private String formatContact(SignalServiceAddress address) { + final var number = address.getLegacyIdentifier(); + var name = m.getContactOrProfileName(number); + if (name == null || name.isEmpty()) { + return number; + } else { + return MessageFormatter.arrayFormat("“{}” {}", new Object[]{name, number}).getMessage(); } } } diff --git a/src/main/java/org/asamk/signal/commands/AddDeviceCommand.java b/src/main/java/org/asamk/signal/commands/AddDeviceCommand.java index dab886d7..cf993e6d 100644 --- a/src/main/java/org/asamk/signal/commands/AddDeviceCommand.java +++ b/src/main/java/org/asamk/signal/commands/AddDeviceCommand.java @@ -3,7 +3,13 @@ package org.asamk.signal.commands; import net.sourceforge.argparse4j.inf.Namespace; import net.sourceforge.argparse4j.inf.Subparser; +import org.asamk.signal.commands.exceptions.CommandException; +import org.asamk.signal.commands.exceptions.IOErrorException; +import org.asamk.signal.commands.exceptions.UnexpectedErrorException; +import org.asamk.signal.commands.exceptions.UserErrorException; import org.asamk.signal.manager.Manager; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.whispersystems.libsignal.InvalidKeyException; import java.io.IOException; @@ -14,6 +20,8 @@ import static org.asamk.signal.util.ErrorUtils.handleAssertionError; public class AddDeviceCommand implements LocalCommand { + private final static Logger logger = LoggerFactory.getLogger(AddDeviceCommand.class); + @Override public void attachToSubparser(final Subparser subparser) { subparser.addArgument("--uri") @@ -22,23 +30,20 @@ public class AddDeviceCommand implements LocalCommand { } @Override - public int handleCommand(final Namespace ns, final Manager m) { - if (!m.isRegistered()) { - System.err.println("User is not registered."); - return 1; - } + public void handleCommand(final Namespace ns, final Manager m) throws CommandException { try { m.addDeviceLink(new URI(ns.getString("uri"))); - return 0; } catch (IOException e) { - e.printStackTrace(); - return 3; - } catch (InvalidKeyException | URISyntaxException e) { - e.printStackTrace(); - return 2; + logger.error("Add device link failed", e); + throw new IOErrorException("Add device link failed"); + } catch (URISyntaxException e) { + throw new UserErrorException("Device link uri has invalid format: {}" + e.getMessage()); + } catch (InvalidKeyException e) { + logger.error("Add device link failed", e); + throw new UnexpectedErrorException("Add device link failed."); } catch (AssertionError e) { handleAssertionError(e); - return 1; + throw e; } } } diff --git a/src/main/java/org/asamk/signal/commands/BlockCommand.java b/src/main/java/org/asamk/signal/commands/BlockCommand.java index 2a9bc4e9..98fce667 100644 --- a/src/main/java/org/asamk/signal/commands/BlockCommand.java +++ b/src/main/java/org/asamk/signal/commands/BlockCommand.java @@ -3,15 +3,18 @@ package org.asamk.signal.commands; import net.sourceforge.argparse4j.inf.Namespace; import net.sourceforge.argparse4j.inf.Subparser; -import org.asamk.signal.manager.GroupId; -import org.asamk.signal.manager.GroupIdFormatException; -import org.asamk.signal.manager.GroupNotFoundException; import org.asamk.signal.manager.Manager; +import org.asamk.signal.manager.groups.GroupIdFormatException; +import org.asamk.signal.manager.groups.GroupNotFoundException; import org.asamk.signal.util.Util; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.whispersystems.signalservice.api.util.InvalidNumberException; public class BlockCommand implements LocalCommand { + private final static Logger logger = LoggerFactory.getLogger(BlockCommand.class); + @Override public void attachToSubparser(final Subparser subparser) { subparser.addArgument("contact").help("Contact number").nargs("*"); @@ -20,31 +23,24 @@ public class BlockCommand implements LocalCommand { } @Override - public int handleCommand(final Namespace ns, final Manager m) { - if (!m.isRegistered()) { - System.err.println("User is not registered."); - return 1; - } - - for (String contact_number : ns.getList("contact")) { + public void handleCommand(final Namespace ns, final Manager m) { + for (var contact_number : ns.getList("contact")) { try { m.setContactBlocked(contact_number, true); } catch (InvalidNumberException e) { - System.err.println(e.getMessage()); + logger.warn("Invalid number {}: {}", contact_number, e.getMessage()); } } if (ns.getList("group") != null) { - for (String groupIdString : ns.getList("group")) { + for (var groupIdString : ns.getList("group")) { try { - GroupId groupId = Util.decodeGroupId(groupIdString); + var groupId = Util.decodeGroupId(groupIdString); m.setGroupBlocked(groupId, true); } catch (GroupIdFormatException | GroupNotFoundException e) { - System.err.println(e.getMessage()); + logger.warn("Invalid group id {}: {}", groupIdString, e.getMessage()); } } } - - return 0; } } diff --git a/src/main/java/org/asamk/signal/commands/Command.java b/src/main/java/org/asamk/signal/commands/Command.java index 1e4abc19..fc55cbe2 100644 --- a/src/main/java/org/asamk/signal/commands/Command.java +++ b/src/main/java/org/asamk/signal/commands/Command.java @@ -2,7 +2,15 @@ package org.asamk.signal.commands; import net.sourceforge.argparse4j.inf.Subparser; +import org.asamk.signal.OutputType; + +import java.util.Set; + public interface Command { void attachToSubparser(Subparser subparser); + + default Set getSupportedOutputTypes() { + return Set.of(OutputType.PLAIN_TEXT); + } } diff --git a/src/main/java/org/asamk/signal/commands/Commands.java b/src/main/java/org/asamk/signal/commands/Commands.java index 6b7dc123..2654b653 100644 --- a/src/main/java/org/asamk/signal/commands/Commands.java +++ b/src/main/java/org/asamk/signal/commands/Commands.java @@ -11,7 +11,11 @@ public class Commands { addCommand("addDevice", new AddDeviceCommand()); addCommand("block", new BlockCommand()); addCommand("daemon", new DaemonCommand()); +<<<<<<< HEAD addCommand("stdio", new StdioCommand()); +======= + addCommand("getUserStatus", new GetUserStatusCommand()); +>>>>>>> upstream/master addCommand("link", new LinkCommand()); addCommand("listContacts", new ListContactsCommand()); addCommand("listDevices", new ListDevicesCommand()); @@ -22,26 +26,34 @@ public class Commands { addCommand("receive", new ReceiveCommand()); addCommand("register", new RegisterCommand()); addCommand("removeDevice", new RemoveDeviceCommand()); + addCommand("remoteDelete", new RemoteDeleteCommand()); addCommand("removePin", new RemovePinCommand()); addCommand("send", new SendCommand()); - addCommand("sendReaction", new SendReactionCommand()); addCommand("sendContacts", new SendContactsCommand()); - addCommand("updateContact", new UpdateContactCommand()); + addCommand("sendReaction", new SendReactionCommand()); addCommand("setPin", new SetPinCommand()); addCommand("trust", new TrustCommand()); addCommand("unblock", new UnblockCommand()); addCommand("unregister", new UnregisterCommand()); addCommand("updateAccount", new UpdateAccountCommand()); + addCommand("updateContact", new UpdateContactCommand()); addCommand("updateGroup", new UpdateGroupCommand()); addCommand("updateProfile", new UpdateProfileCommand()); - addCommand("verify", new VerifyCommand()); addCommand("uploadStickerPack", new UploadStickerPackCommand()); + addCommand("verify", new VerifyCommand()); } public static Map getCommands() { return commands; } + public static Command getCommand(String commandKey) { + if (!commands.containsKey(commandKey)) { + return null; + } + return commands.get(commandKey); + } + private static void addCommand(String name, Command command) { commands.put(name, command); } diff --git a/src/main/java/org/asamk/signal/commands/DaemonCommand.java b/src/main/java/org/asamk/signal/commands/DaemonCommand.java index 3caaaa37..8d26e452 100644 --- a/src/main/java/org/asamk/signal/commands/DaemonCommand.java +++ b/src/main/java/org/asamk/signal/commands/DaemonCommand.java @@ -4,21 +4,28 @@ import net.sourceforge.argparse4j.impl.Arguments; import net.sourceforge.argparse4j.inf.Namespace; import net.sourceforge.argparse4j.inf.Subparser; +import org.asamk.signal.DbusConfig; import org.asamk.signal.DbusReceiveMessageHandler; import org.asamk.signal.JsonDbusReceiveMessageHandler; +import org.asamk.signal.OutputType; +import org.asamk.signal.commands.exceptions.CommandException; +import org.asamk.signal.commands.exceptions.UnexpectedErrorException; import org.asamk.signal.dbus.DbusSignalImpl; import org.asamk.signal.manager.Manager; import org.freedesktop.dbus.connections.impl.DBusConnection; import org.freedesktop.dbus.exceptions.DBusException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; import java.util.concurrent.TimeUnit; -import static org.asamk.signal.DbusConfig.SIGNAL_BUSNAME; -import static org.asamk.signal.DbusConfig.SIGNAL_OBJECTPATH; -import static org.asamk.signal.util.ErrorUtils.handleAssertionError; +public class DaemonCommand implements MultiLocalCommand { -public class DaemonCommand implements LocalCommand { + private final static Logger logger = LoggerFactory.getLogger(DaemonCommand.class); @Override public void attachToSubparser(final Subparser subparser) { @@ -29,56 +36,114 @@ public class DaemonCommand implements LocalCommand { .help("Don’t download attachments of received messages.") .action(Arguments.storeTrue()); subparser.addArgument("--json") - .help("Output received messages in json format, one json object per line.") + .help("WARNING: This parameter is now deprecated! Please use the global \"--output=json\" option instead.\n\nOutput received messages in json format, one json object per line.") .action(Arguments.storeTrue()); } @Override - public int handleCommand(final Namespace ns, final Manager m) { - if (!m.isRegistered()) { - System.err.println("User is not registered."); - return 1; + public Set getSupportedOutputTypes() { + return Set.of(OutputType.PLAIN_TEXT, OutputType.JSON); + } + + @Override + public void handleCommand(final Namespace ns, final Manager m) throws CommandException { + var inJson = ns.get("output") == OutputType.JSON || ns.getBoolean("json"); + + // TODO delete later when "json" variable is removed + if (ns.getBoolean("json")) { + logger.warn("\"--json\" option has been deprecated, please use the global \"--output=json\" instead."); } - DBusConnection conn = null; - try { + + boolean ignoreAttachments = ns.getBoolean("ignore_attachments"); + + DBusConnection.DBusBusType busType; + if (ns.getBoolean("system")) { + busType = DBusConnection.DBusBusType.SYSTEM; + } else { + busType = DBusConnection.DBusBusType.SESSION; + } + + try (var conn = DBusConnection.getConnection(busType)) { + var objectPath = DbusConfig.getObjectPath(); + var t = run(conn, objectPath, m, ignoreAttachments, inJson); + + conn.requestBusName(DbusConfig.getBusname()); + try { - DBusConnection.DBusBusType busType; - if (ns.getBoolean("system")) { - busType = DBusConnection.DBusBusType.SYSTEM; - } else { - busType = DBusConnection.DBusBusType.SESSION; - } - conn = DBusConnection.getConnection(busType); - conn.exportObject(SIGNAL_OBJECTPATH, new DbusSignalImpl(m)); - conn.requestBusName(SIGNAL_BUSNAME); - } catch (UnsatisfiedLinkError e) { - System.err.println("Missing native library dependency for dbus service: " + e.getMessage()); - return 1; - } catch (DBusException e) { - e.printStackTrace(); - return 2; - } - boolean ignoreAttachments = ns.getBoolean("ignore_attachments"); - try { - m.receiveMessages(1, - TimeUnit.HOURS, - false, - ignoreAttachments, - ns.getBoolean("json") - ? new JsonDbusReceiveMessageHandler(m, conn, SIGNAL_OBJECTPATH) - : new DbusReceiveMessageHandler(m, conn, SIGNAL_OBJECTPATH)); - return 0; - } catch (IOException e) { - System.err.println("Error while receiving messages: " + e.getMessage()); - return 3; - } catch (AssertionError e) { - handleAssertionError(e); - return 1; - } - } finally { - if (conn != null) { - conn.disconnect(); + t.join(); + } catch (InterruptedException ignored) { } + } catch (DBusException | IOException e) { + logger.error("Dbus command failed", e); + throw new UnexpectedErrorException("Dbus command failed"); } } + + @Override + public void handleCommand(final Namespace ns, final List managers) throws CommandException { + var inJson = ns.get("output") == OutputType.JSON || ns.getBoolean("json"); + + // TODO delete later when "json" variable is removed + if (ns.getBoolean("json")) { + logger.warn("\"--json\" option has been deprecated, please use the global \"--output=json\" instead."); + } + + boolean ignoreAttachments = ns.getBoolean("ignore_attachments"); + + DBusConnection.DBusBusType busType; + if (ns.getBoolean("system")) { + busType = DBusConnection.DBusBusType.SYSTEM; + } else { + busType = DBusConnection.DBusBusType.SESSION; + } + + try (var conn = DBusConnection.getConnection(busType)) { + var receiveThreads = new ArrayList(); + for (var m : managers) { + var objectPath = DbusConfig.getObjectPath(m.getUsername()); + var thread = run(conn, objectPath, m, ignoreAttachments, inJson); + receiveThreads.add(thread); + } + + conn.requestBusName(DbusConfig.getBusname()); + + for (var t : receiveThreads) { + try { + t.join(); + } catch (InterruptedException ignored) { + } + } + } catch (DBusException | IOException e) { + logger.error("Dbus command failed", e); + throw new UnexpectedErrorException("Dbus command failed"); + } + } + + private Thread run( + DBusConnection conn, String objectPath, Manager m, boolean ignoreAttachments, boolean inJson + ) throws DBusException { + conn.exportObject(objectPath, new DbusSignalImpl(m)); + + final var thread = new Thread(() -> { + while (true) { + try { + m.receiveMessages(1, + TimeUnit.HOURS, + false, + ignoreAttachments, + inJson + ? new JsonDbusReceiveMessageHandler(m, conn, objectPath) + : new DbusReceiveMessageHandler(m, conn, objectPath)); + } catch (IOException e) { + logger.warn("Receiving messages failed, retrying", e); + } + } + }); + + logger.info("Exported dbus object: " + objectPath); + + thread.start(); + + return thread; + } } diff --git a/src/main/java/org/asamk/signal/commands/DbusCommand.java b/src/main/java/org/asamk/signal/commands/DbusCommand.java index 4dee75b2..e4c78c84 100644 --- a/src/main/java/org/asamk/signal/commands/DbusCommand.java +++ b/src/main/java/org/asamk/signal/commands/DbusCommand.java @@ -3,8 +3,15 @@ package org.asamk.signal.commands; import net.sourceforge.argparse4j.inf.Namespace; import org.asamk.Signal; +import org.asamk.signal.commands.exceptions.CommandException; +import org.asamk.signal.dbus.DbusSignalImpl; +import org.asamk.signal.manager.Manager; -public interface DbusCommand extends Command { +public interface DbusCommand extends LocalCommand { - int handleCommand(Namespace ns, Signal signal); + void handleCommand(Namespace ns, Signal signal) throws CommandException; + + default void handleCommand(final Namespace ns, final Manager m) throws CommandException { + handleCommand(ns, new DbusSignalImpl(m)); + } } diff --git a/src/main/java/org/asamk/signal/commands/ExtendedDbusCommand.java b/src/main/java/org/asamk/signal/commands/ExtendedDbusCommand.java index f9cd9de8..1d454f4d 100644 --- a/src/main/java/org/asamk/signal/commands/ExtendedDbusCommand.java +++ b/src/main/java/org/asamk/signal/commands/ExtendedDbusCommand.java @@ -3,9 +3,10 @@ package org.asamk.signal.commands; import net.sourceforge.argparse4j.inf.Namespace; import org.asamk.Signal; +import org.asamk.signal.commands.exceptions.CommandException; import org.freedesktop.dbus.connections.impl.DBusConnection; public interface ExtendedDbusCommand extends Command { - int handleCommand(Namespace ns, Signal signal, DBusConnection dbusconnection); + void handleCommand(Namespace ns, Signal signal, DBusConnection dbusconnection) throws CommandException; } diff --git a/src/main/java/org/asamk/signal/commands/GetUserStatusCommand.java b/src/main/java/org/asamk/signal/commands/GetUserStatusCommand.java new file mode 100644 index 00000000..69140b23 --- /dev/null +++ b/src/main/java/org/asamk/signal/commands/GetUserStatusCommand.java @@ -0,0 +1,89 @@ +package org.asamk.signal.commands; + +import net.sourceforge.argparse4j.impl.Arguments; +import net.sourceforge.argparse4j.inf.Namespace; +import net.sourceforge.argparse4j.inf.Subparser; + +import org.asamk.signal.JsonWriter; +import org.asamk.signal.OutputType; +import org.asamk.signal.PlainTextWriterImpl; +import org.asamk.signal.commands.exceptions.CommandException; +import org.asamk.signal.commands.exceptions.IOErrorException; +import org.asamk.signal.manager.Manager; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +public class GetUserStatusCommand implements LocalCommand { + + private final static Logger logger = LoggerFactory.getLogger(GetUserStatusCommand.class); + + @Override + public void attachToSubparser(final Subparser subparser) { + subparser.addArgument("number").help("Phone number").nargs("+"); + subparser.help("Check if the specified phone number/s have been registered"); + subparser.addArgument("--json") + .help("WARNING: This parameter is now deprecated! Please use the global \"--output=json\" option instead.\n\nOutput received messages in json format, one json object per line.") + .action(Arguments.storeTrue()); + } + + @Override + public Set getSupportedOutputTypes() { + return Set.of(OutputType.PLAIN_TEXT, OutputType.JSON); + } + + @Override + public void handleCommand(final Namespace ns, final Manager m) throws CommandException { + // Setup the json object mapper + var inJson = ns.get("output") == OutputType.JSON || ns.getBoolean("json"); + + // TODO delete later when "json" variable is removed + if (ns.getBoolean("json")) { + logger.warn("\"--json\" option has been deprecated, please use the global \"--output=json\" instead."); + } + + // Get a map of registration statuses + Map registered; + try { + registered = m.areUsersRegistered(new HashSet<>(ns.getList("number"))); + } catch (IOException e) { + logger.debug("Failed to check registered users", e); + throw new IOErrorException("Unable to check if users are registered"); + } + + // Output + if (inJson) { + final var jsonWriter = new JsonWriter(System.out); + + var jsonUserStatuses = registered.entrySet() + .stream() + .map(entry -> new JsonUserStatus(entry.getKey(), entry.getValue())) + .collect(Collectors.toList()); + + jsonWriter.write(jsonUserStatuses); + } else { + final var writer = new PlainTextWriterImpl(System.out); + + for (var entry : registered.entrySet()) { + writer.println("{}: {}", entry.getKey(), entry.getValue()); + } + } + } + + private static final class JsonUserStatus { + + public String name; + + public boolean isRegistered; + + public JsonUserStatus(String name, boolean isRegistered) { + this.name = name; + this.isRegistered = isRegistered; + } + } +} diff --git a/src/main/java/org/asamk/signal/commands/JoinGroupCommand.java b/src/main/java/org/asamk/signal/commands/JoinGroupCommand.java index 8438e1fa..e59ecec6 100644 --- a/src/main/java/org/asamk/signal/commands/JoinGroupCommand.java +++ b/src/main/java/org/asamk/signal/commands/JoinGroupCommand.java @@ -3,21 +3,20 @@ package org.asamk.signal.commands; import net.sourceforge.argparse4j.inf.Namespace; import net.sourceforge.argparse4j.inf.Subparser; -import org.asamk.Signal; -import org.asamk.signal.manager.GroupId; -import org.asamk.signal.manager.GroupInviteLinkUrl; +import org.asamk.signal.PlainTextWriterImpl; +import org.asamk.signal.commands.exceptions.CommandException; +import org.asamk.signal.commands.exceptions.IOErrorException; +import org.asamk.signal.commands.exceptions.UnexpectedErrorException; +import org.asamk.signal.commands.exceptions.UserErrorException; import org.asamk.signal.manager.Manager; +import org.asamk.signal.manager.groups.GroupInviteLinkUrl; import org.freedesktop.dbus.exceptions.DBusExecutionException; -import org.whispersystems.libsignal.util.Pair; import org.whispersystems.signalservice.api.groupsv2.GroupLinkNotActiveException; -import org.whispersystems.signalservice.api.messages.SendMessageResult; import org.whispersystems.signalservice.internal.push.exceptions.GroupPatchNotAcceptedException; import java.io.IOException; -import java.util.List; import static org.asamk.signal.util.ErrorUtils.handleAssertionError; -import static org.asamk.signal.util.ErrorUtils.handleIOException; import static org.asamk.signal.util.ErrorUtils.handleTimestampAndSendMessageResults; public class JoinGroupCommand implements LocalCommand { @@ -28,57 +27,43 @@ public class JoinGroupCommand implements LocalCommand { } @Override - public int handleCommand(final Namespace ns, final Manager m) { - if (!m.isRegistered()) { - System.err.println("User is not registered."); - return 1; - } - + public void handleCommand(final Namespace ns, final Manager m) throws CommandException { final GroupInviteLinkUrl linkUrl; - String uri = ns.getString("uri"); + var uri = ns.getString("uri"); try { linkUrl = GroupInviteLinkUrl.fromUri(uri); } catch (GroupInviteLinkUrl.InvalidGroupLinkException e) { - System.err.println("Group link is invalid: " + e.getMessage()); - return 2; + throw new UserErrorException("Group link is invalid: " + e.getMessage()); } catch (GroupInviteLinkUrl.UnknownGroupLinkVersionException e) { - System.err.println("Group link was created with an incompatible version: " + e.getMessage()); - return 2; + throw new UserErrorException("Group link was created with an incompatible version: " + e.getMessage()); } if (linkUrl == null) { - System.err.println("Link is not a signal group invitation link"); - return 2; + throw new UserErrorException("Link is not a signal group invitation link"); } try { - final Pair> results = m.joinGroup(linkUrl); - GroupId newGroupId = results.first(); + final var writer = new PlainTextWriterImpl(System.out); + + final var results = m.joinGroup(linkUrl); + var newGroupId = results.first(); if (!m.getGroup(newGroupId).isMember(m.getSelfAddress())) { - System.out.println("Requested to join group \"" + newGroupId.toBase64() + "\""); + writer.println("Requested to join group \"{}\"", newGroupId.toBase64()); } else { - System.out.println("Joined group \"" + newGroupId.toBase64() + "\""); + writer.println("Joined group \"{}\"", newGroupId.toBase64()); } - return handleTimestampAndSendMessageResults(0, results.second()); + handleTimestampAndSendMessageResults(writer, 0, results.second()); } catch (AssertionError e) { handleAssertionError(e); - return 1; + throw e; } catch (GroupPatchNotAcceptedException e) { - System.err.println("Failed to join group, maybe already a member"); - return 1; + throw new UserErrorException("Failed to join group, maybe already a member"); } catch (IOException e) { - e.printStackTrace(); - handleIOException(e); - return 1; - } catch (Signal.Error.AttachmentInvalid e) { - System.err.println("Failed to add avatar attachment for group\": " + e.getMessage()); - return 1; + throw new IOErrorException("Failed to send message: " + e.getMessage()); } catch (DBusExecutionException e) { - System.err.println("Failed to send message: " + e.getMessage()); - return 1; + throw new UnexpectedErrorException("Failed to send message: " + e.getMessage()); } catch (GroupLinkNotActiveException e) { - System.err.println("Group link is not valid: " + e.getMessage()); - return 2; + throw new UserErrorException("Group link is not valid: " + e.getMessage()); } } } diff --git a/src/main/java/org/asamk/signal/commands/LinkCommand.java b/src/main/java/org/asamk/signal/commands/LinkCommand.java index 7cc9daf5..3a018619 100644 --- a/src/main/java/org/asamk/signal/commands/LinkCommand.java +++ b/src/main/java/org/asamk/signal/commands/LinkCommand.java @@ -3,8 +3,15 @@ package org.asamk.signal.commands; import net.sourceforge.argparse4j.inf.Namespace; import net.sourceforge.argparse4j.inf.Subparser; +import org.asamk.signal.PlainTextWriterImpl; +import org.asamk.signal.commands.exceptions.CommandException; +import org.asamk.signal.commands.exceptions.IOErrorException; +import org.asamk.signal.commands.exceptions.UnexpectedErrorException; +import org.asamk.signal.commands.exceptions.UserErrorException; import org.asamk.signal.manager.ProvisioningManager; import org.asamk.signal.manager.UserAlreadyExists; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.whispersystems.libsignal.InvalidKeyException; import java.io.IOException; @@ -14,41 +21,42 @@ import static org.asamk.signal.util.ErrorUtils.handleAssertionError; public class LinkCommand implements ProvisioningCommand { + private final static Logger logger = LoggerFactory.getLogger(LinkCommand.class); + @Override public void attachToSubparser(final Subparser subparser) { subparser.addArgument("-n", "--name").help("Specify a name to describe this new device."); } @Override - public int handleCommand(final Namespace ns, final ProvisioningManager m) { - String deviceName = ns.getString("name"); + public void handleCommand(final Namespace ns, final ProvisioningManager m) throws CommandException { + final var writer = new PlainTextWriterImpl(System.out); + + var deviceName = ns.getString("name"); if (deviceName == null) { deviceName = "cli"; } try { - System.out.println(m.getDeviceLinkUri()); - String username = m.finishDeviceLink(deviceName); - System.out.println("Associated with: " + username); + writer.println("{}", m.getDeviceLinkUri()); + try (var manager = m.finishDeviceLink(deviceName)) { + writer.println("Associated with: {}", manager.getUsername()); + } } catch (TimeoutException e) { - System.err.println("Link request timed out, please try again."); - return 3; + throw new UserErrorException("Link request timed out, please try again."); } catch (IOException e) { - System.err.println("Link request error: " + e.getMessage()); - return 3; + throw new IOErrorException("Link request error: " + e.getMessage()); } catch (AssertionError e) { handleAssertionError(e); - return 1; + throw e; } catch (InvalidKeyException e) { - e.printStackTrace(); - return 2; + logger.debug("Finish device link failed", e); + throw new UnexpectedErrorException("Invalid key: " + e.getMessage()); } catch (UserAlreadyExists e) { - System.err.println("The user " + throw new UserErrorException("The user " + e.getUsername() + " already exists\nDelete \"" + e.getFileName() + "\" before trying again."); - return 1; } - return 0; } } diff --git a/src/main/java/org/asamk/signal/commands/ListContactsCommand.java b/src/main/java/org/asamk/signal/commands/ListContactsCommand.java index 24d6898c..4b27e17d 100644 --- a/src/main/java/org/asamk/signal/commands/ListContactsCommand.java +++ b/src/main/java/org/asamk/signal/commands/ListContactsCommand.java @@ -3,10 +3,8 @@ package org.asamk.signal.commands; import net.sourceforge.argparse4j.inf.Namespace; import net.sourceforge.argparse4j.inf.Subparser; +import org.asamk.signal.PlainTextWriterImpl; import org.asamk.signal.manager.Manager; -import org.asamk.signal.storage.contacts.ContactInfo; - -import java.util.List; public class ListContactsCommand implements LocalCommand { @@ -15,15 +13,12 @@ public class ListContactsCommand implements LocalCommand { } @Override - public int handleCommand(final Namespace ns, final Manager m) { - if (!m.isRegistered()) { - System.err.println("User is not registered."); - return 1; + public void handleCommand(final Namespace ns, final Manager m) { + final var writer = new PlainTextWriterImpl(System.out); + + var contacts = m.getContacts(); + for (var c : contacts) { + writer.println("Number: {} Name: {} Blocked: {}", c.number, c.name, c.blocked); } - List contacts = m.getContacts(); - for (ContactInfo c : contacts) { - System.out.println(String.format("Number: %s Name: %s Blocked: %b", c.number, c.name, c.blocked)); - } - return 0; } } diff --git a/src/main/java/org/asamk/signal/commands/ListDevicesCommand.java b/src/main/java/org/asamk/signal/commands/ListDevicesCommand.java index 4b9dac5c..7165d07c 100644 --- a/src/main/java/org/asamk/signal/commands/ListDevicesCommand.java +++ b/src/main/java/org/asamk/signal/commands/ListDevicesCommand.java @@ -3,8 +3,13 @@ package org.asamk.signal.commands; import net.sourceforge.argparse4j.inf.Namespace; import net.sourceforge.argparse4j.inf.Subparser; +import org.asamk.signal.PlainTextWriterImpl; +import org.asamk.signal.commands.exceptions.CommandException; +import org.asamk.signal.commands.exceptions.IOErrorException; import org.asamk.signal.manager.Manager; import org.asamk.signal.util.DateUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.whispersystems.signalservice.api.messages.multidevice.DeviceInfo; import java.io.IOException; @@ -12,31 +17,31 @@ import java.util.List; public class ListDevicesCommand implements LocalCommand { + private final static Logger logger = LoggerFactory.getLogger(ListDevicesCommand.class); + @Override public void attachToSubparser(final Subparser subparser) { } @Override - public int handleCommand(final Namespace ns, final Manager m) { - if (!m.isRegistered()) { - System.err.println("User is not registered."); - return 1; - } + public void handleCommand(final Namespace ns, final Manager m) throws CommandException { + final var writer = new PlainTextWriterImpl(System.out); + + List devices; try { - List devices = m.getLinkedDevices(); - for (DeviceInfo d : devices) { - System.out.println("Device " - + d.getId() - + (d.getId() == m.getDeviceId() ? " (this device)" : "") - + ":"); - System.out.println(" Name: " + d.getName()); - System.out.println(" Created: " + DateUtils.formatTimestamp(d.getCreated())); - System.out.println(" Last seen: " + DateUtils.formatTimestamp(d.getLastSeen())); - } - return 0; + devices = m.getLinkedDevices(); } catch (IOException e) { - e.printStackTrace(); - return 3; + logger.debug("Failed to get linked devices", e); + throw new IOErrorException("Failed to get linked devices: " + e.getMessage()); + } + + for (var d : devices) { + writer.println("- Device {}{}:", d.getId(), (d.getId() == m.getDeviceId() ? " (this device)" : "")); + writer.indent(w -> { + w.println("Name: {}", d.getName()); + w.println("Created: {}", DateUtils.formatTimestamp(d.getCreated())); + w.println("Last seen: {}", DateUtils.formatTimestamp(d.getLastSeen())); + }); } } } diff --git a/src/main/java/org/asamk/signal/commands/ListGroupsCommand.java b/src/main/java/org/asamk/signal/commands/ListGroupsCommand.java index 4d1032a2..a547cc15 100644 --- a/src/main/java/org/asamk/signal/commands/ListGroupsCommand.java +++ b/src/main/java/org/asamk/signal/commands/ListGroupsCommand.java @@ -4,77 +4,131 @@ import net.sourceforge.argparse4j.impl.Arguments; import net.sourceforge.argparse4j.inf.Namespace; import net.sourceforge.argparse4j.inf.Subparser; -import org.asamk.signal.manager.GroupInviteLinkUrl; +import org.asamk.signal.JsonWriter; +import org.asamk.signal.OutputType; +import org.asamk.signal.PlainTextWriter; +import org.asamk.signal.PlainTextWriterImpl; +import org.asamk.signal.commands.exceptions.CommandException; import org.asamk.signal.manager.Manager; -import org.asamk.signal.storage.groups.GroupInfo; +import org.asamk.signal.manager.storage.groups.GroupInfo; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.whispersystems.signalservice.api.push.SignalServiceAddress; -import java.util.List; +import java.util.ArrayList; import java.util.Set; import java.util.stream.Collectors; public class ListGroupsCommand implements LocalCommand { - private static void printGroup(Manager m, GroupInfo group, boolean detailed) { + private final static Logger logger = LoggerFactory.getLogger(ListGroupsCommand.class); + + private static Set resolveMembers(Manager m, Set addresses) { + return addresses.stream() + .map(m::resolveSignalServiceAddress) + .map(SignalServiceAddress::getLegacyIdentifier) + .collect(Collectors.toSet()); + } + + private static void printGroupPlainText( + PlainTextWriter writer, Manager m, GroupInfo group, boolean detailed + ) { if (detailed) { - Set members = group.getMembers() - .stream() - .map(m::resolveSignalServiceAddress) - .map(SignalServiceAddress::getLegacyIdentifier) - .collect(Collectors.toSet()); + final var groupInviteLink = group.getGroupInviteLink(); - Set pendingMembers = group.getPendingMembers() - .stream() - .map(m::resolveSignalServiceAddress) - .map(SignalServiceAddress::getLegacyIdentifier) - .collect(Collectors.toSet()); - - Set requestingMembers = group.getRequestingMembers() - .stream() - .map(m::resolveSignalServiceAddress) - .map(SignalServiceAddress::getLegacyIdentifier) - .collect(Collectors.toSet()); - - final GroupInviteLinkUrl groupInviteLink = group.getGroupInviteLink(); - - System.out.println(String.format( - "Id: %s Name: %s Active: %s Blocked: %b Members: %s Pending members: %s Requesting members: %s Link: %s", + writer.println( + "Id: {} Name: {} Active: {} Blocked: {} Members: {} Pending members: {} Requesting members: {} Link: {}", group.getGroupId().toBase64(), group.getTitle(), group.isMember(m.getSelfAddress()), group.isBlocked(), - members, - pendingMembers, - requestingMembers, - groupInviteLink == null ? '-' : groupInviteLink.getUrl())); + resolveMembers(m, group.getMembers()), + resolveMembers(m, group.getPendingMembers()), + resolveMembers(m, group.getRequestingMembers()), + groupInviteLink == null ? '-' : groupInviteLink.getUrl()); } else { - System.out.println(String.format("Id: %s Name: %s Active: %s Blocked: %b", + writer.println("Id: {} Name: {} Active: {} Blocked: {}", group.getGroupId().toBase64(), group.getTitle(), group.isMember(m.getSelfAddress()), - group.isBlocked())); + group.isBlocked()); } } @Override public void attachToSubparser(final Subparser subparser) { - subparser.addArgument("-d", "--detailed").action(Arguments.storeTrue()).help("List members of each group"); - subparser.help("List group name and ids"); + subparser.addArgument("-d", "--detailed") + .action(Arguments.storeTrue()) + .help("List the members and group invite links of each group. If output=json, then this is always set"); + + subparser.help("List group information including names, ids, active status, blocked status and members"); } @Override - public int handleCommand(final Namespace ns, final Manager m) { - if (!m.isRegistered()) { - System.err.println("User is not registered."); - return 1; - } + public Set getSupportedOutputTypes() { + return Set.of(OutputType.PLAIN_TEXT, OutputType.JSON); + } - List groups = m.getGroups(); - boolean detailed = ns.getBoolean("detailed"); + @Override + public void handleCommand(final Namespace ns, final Manager m) throws CommandException { + if (ns.get("output") == OutputType.JSON) { + final var jsonWriter = new JsonWriter(System.out); - for (GroupInfo group : groups) { - printGroup(m, group, detailed); + var jsonGroups = new ArrayList(); + for (var group : m.getGroups()) { + final var groupInviteLink = group.getGroupInviteLink(); + + jsonGroups.add(new JsonGroup(group.getGroupId().toBase64(), + group.getTitle(), + group.isMember(m.getSelfAddress()), + group.isBlocked(), + resolveMembers(m, group.getMembers()), + resolveMembers(m, group.getPendingMembers()), + resolveMembers(m, group.getRequestingMembers()), + groupInviteLink == null ? null : groupInviteLink.getUrl())); + } + + jsonWriter.write(jsonGroups); + } else { + final var writer = new PlainTextWriterImpl(System.out); + boolean detailed = ns.getBoolean("detailed"); + for (var group : m.getGroups()) { + printGroupPlainText(writer, m, group, detailed); + } + } + } + + private static final class JsonGroup { + + public String id; + public String name; + public boolean isMember; + public boolean isBlocked; + + public Set members; + public Set pendingMembers; + public Set requestingMembers; + public String groupInviteLink; + + public JsonGroup( + String id, + String name, + boolean isMember, + boolean isBlocked, + Set members, + Set pendingMembers, + Set requestingMembers, + String groupInviteLink + ) { + this.id = id; + this.name = name; + this.isMember = isMember; + this.isBlocked = isBlocked; + + this.members = members; + this.pendingMembers = pendingMembers; + this.requestingMembers = requestingMembers; + this.groupInviteLink = groupInviteLink; } - return 0; } } diff --git a/src/main/java/org/asamk/signal/commands/ListIdentitiesCommand.java b/src/main/java/org/asamk/signal/commands/ListIdentitiesCommand.java index a75e4328..dc2d92fb 100644 --- a/src/main/java/org/asamk/signal/commands/ListIdentitiesCommand.java +++ b/src/main/java/org/asamk/signal/commands/ListIdentitiesCommand.java @@ -3,24 +3,32 @@ package org.asamk.signal.commands; import net.sourceforge.argparse4j.inf.Namespace; import net.sourceforge.argparse4j.inf.Subparser; +import org.asamk.signal.PlainTextWriter; +import org.asamk.signal.PlainTextWriterImpl; +import org.asamk.signal.commands.exceptions.CommandException; +import org.asamk.signal.commands.exceptions.UserErrorException; import org.asamk.signal.manager.Manager; -import org.asamk.signal.storage.protocol.JsonIdentityKeyStore; +import org.asamk.signal.manager.storage.protocol.IdentityInfo; import org.asamk.signal.util.Hex; import org.asamk.signal.util.Util; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.whispersystems.signalservice.api.util.InvalidNumberException; import java.util.List; public class ListIdentitiesCommand implements LocalCommand { - private static void printIdentityFingerprint(Manager m, JsonIdentityKeyStore.Identity theirId) { - String digits = Util.formatSafetyNumber(m.computeSafetyNumber(theirId.getAddress(), theirId.getIdentityKey())); - System.out.println(String.format("%s: %s Added: %s Fingerprint: %s Safety Number: %s", + private final static Logger logger = LoggerFactory.getLogger(ListIdentitiesCommand.class); + + private static void printIdentityFingerprint(PlainTextWriter writer, Manager m, IdentityInfo theirId) { + var digits = Util.formatSafetyNumber(m.computeSafetyNumber(theirId.getAddress(), theirId.getIdentityKey())); + writer.println("{}: {} Added: {} Fingerprint: {} Safety Number: {}", theirId.getAddress().getNumber().orNull(), theirId.getTrustLevel(), theirId.getDateAdded(), Hex.toString(theirId.getFingerprint()), - digits)); + digits); } @Override @@ -29,26 +37,27 @@ public class ListIdentitiesCommand implements LocalCommand { } @Override - public int handleCommand(final Namespace ns, final Manager m) { - if (!m.isRegistered()) { - System.err.println("User is not registered."); - return 1; - } - if (ns.get("number") == null) { - for (JsonIdentityKeyStore.Identity identity : m.getIdentities()) { - printIdentityFingerprint(m, identity); - } - } else { - String number = ns.getString("number"); - try { - List identities = m.getIdentities(number); - for (JsonIdentityKeyStore.Identity id : identities) { - printIdentityFingerprint(m, id); - } - } catch (InvalidNumberException e) { - System.err.println("Invalid number: " + e.getMessage()); + public void handleCommand(final Namespace ns, final Manager m) throws CommandException { + final var writer = new PlainTextWriterImpl(System.out); + + var number = ns.getString("number"); + + if (number == null) { + for (var identity : m.getIdentities()) { + printIdentityFingerprint(writer, m, identity); } + return; + } + + List identities; + try { + identities = m.getIdentities(number); + } catch (InvalidNumberException e) { + throw new UserErrorException("Invalid number: " + e.getMessage()); + } + + for (var id : identities) { + printIdentityFingerprint(writer, m, id); } - return 0; } } diff --git a/src/main/java/org/asamk/signal/commands/LocalCommand.java b/src/main/java/org/asamk/signal/commands/LocalCommand.java index 7bcb1a4b..a7c64dc1 100644 --- a/src/main/java/org/asamk/signal/commands/LocalCommand.java +++ b/src/main/java/org/asamk/signal/commands/LocalCommand.java @@ -2,9 +2,10 @@ package org.asamk.signal.commands; import net.sourceforge.argparse4j.inf.Namespace; +import org.asamk.signal.commands.exceptions.CommandException; import org.asamk.signal.manager.Manager; public interface LocalCommand extends Command { - int handleCommand(Namespace ns, Manager m); + void handleCommand(Namespace ns, Manager m) throws CommandException; } diff --git a/src/main/java/org/asamk/signal/commands/MultiLocalCommand.java b/src/main/java/org/asamk/signal/commands/MultiLocalCommand.java new file mode 100644 index 00000000..2a8457bd --- /dev/null +++ b/src/main/java/org/asamk/signal/commands/MultiLocalCommand.java @@ -0,0 +1,18 @@ +package org.asamk.signal.commands; + +import net.sourceforge.argparse4j.inf.Namespace; + +import org.asamk.signal.commands.exceptions.CommandException; +import org.asamk.signal.manager.Manager; + +import java.util.List; + +public interface MultiLocalCommand extends LocalCommand { + + void handleCommand(Namespace ns, List m) throws CommandException; + + @Override + default void handleCommand(final Namespace ns, final Manager m) throws CommandException { + handleCommand(ns, List.of(m)); + } +} diff --git a/src/main/java/org/asamk/signal/commands/ProvisioningCommand.java b/src/main/java/org/asamk/signal/commands/ProvisioningCommand.java index 12a612ff..354e4af3 100644 --- a/src/main/java/org/asamk/signal/commands/ProvisioningCommand.java +++ b/src/main/java/org/asamk/signal/commands/ProvisioningCommand.java @@ -2,9 +2,10 @@ package org.asamk.signal.commands; import net.sourceforge.argparse4j.inf.Namespace; +import org.asamk.signal.commands.exceptions.CommandException; import org.asamk.signal.manager.ProvisioningManager; public interface ProvisioningCommand extends Command { - int handleCommand(Namespace ns, ProvisioningManager m); + void handleCommand(Namespace ns, ProvisioningManager m) throws CommandException; } diff --git a/src/main/java/org/asamk/signal/commands/QuitGroupCommand.java b/src/main/java/org/asamk/signal/commands/QuitGroupCommand.java index efc63f8f..d8a86585 100644 --- a/src/main/java/org/asamk/signal/commands/QuitGroupCommand.java +++ b/src/main/java/org/asamk/signal/commands/QuitGroupCommand.java @@ -3,23 +3,20 @@ package org.asamk.signal.commands; import net.sourceforge.argparse4j.inf.Namespace; import net.sourceforge.argparse4j.inf.Subparser; -import org.asamk.signal.manager.GroupId; -import org.asamk.signal.manager.GroupIdFormatException; -import org.asamk.signal.manager.GroupNotFoundException; +import org.asamk.signal.PlainTextWriterImpl; +import org.asamk.signal.commands.exceptions.CommandException; +import org.asamk.signal.commands.exceptions.IOErrorException; +import org.asamk.signal.commands.exceptions.UserErrorException; import org.asamk.signal.manager.Manager; -import org.asamk.signal.manager.NotAGroupMemberException; +import org.asamk.signal.manager.groups.GroupId; +import org.asamk.signal.manager.groups.GroupIdFormatException; +import org.asamk.signal.manager.groups.GroupNotFoundException; +import org.asamk.signal.manager.groups.NotAGroupMemberException; import org.asamk.signal.util.Util; -import org.whispersystems.libsignal.util.Pair; -import org.whispersystems.signalservice.api.messages.SendMessageResult; import java.io.IOException; -import java.util.List; import static org.asamk.signal.util.ErrorUtils.handleAssertionError; -import static org.asamk.signal.util.ErrorUtils.handleGroupIdFormatException; -import static org.asamk.signal.util.ErrorUtils.handleGroupNotFoundException; -import static org.asamk.signal.util.ErrorUtils.handleIOException; -import static org.asamk.signal.util.ErrorUtils.handleNotAGroupMemberException; import static org.asamk.signal.util.ErrorUtils.handleTimestampAndSendMessageResults; public class QuitGroupCommand implements LocalCommand { @@ -30,31 +27,28 @@ public class QuitGroupCommand implements LocalCommand { } @Override - public int handleCommand(final Namespace ns, final Manager m) { - if (!m.isRegistered()) { - System.err.println("User is not registered."); - return 1; + public void handleCommand(final Namespace ns, final Manager m) throws CommandException { + final var writer = new PlainTextWriterImpl(System.out); + + final GroupId groupId; + try { + groupId = Util.decodeGroupId(ns.getString("group")); + } catch (GroupIdFormatException e) { + throw new UserErrorException("Invalid group id:" + e.getMessage()); } try { - final GroupId groupId = Util.decodeGroupId(ns.getString("group")); - final Pair> results = m.sendQuitGroupMessage(groupId); - return handleTimestampAndSendMessageResults(results.first(), results.second()); + final var results = m.sendQuitGroupMessage(groupId); + handleTimestampAndSendMessageResults(writer, results.first(), results.second()); } catch (IOException e) { - handleIOException(e); - return 3; + throw new IOErrorException("Failed to send message: " + e.getMessage()); } catch (AssertionError e) { handleAssertionError(e); - return 1; + throw e; } catch (GroupNotFoundException e) { - handleGroupNotFoundException(e); - return 1; + throw new UserErrorException("Failed to send to group: " + e.getMessage()); } catch (NotAGroupMemberException e) { - handleNotAGroupMemberException(e); - return 1; - } catch (GroupIdFormatException e) { - handleGroupIdFormatException(e); - return 1; + throw new UserErrorException("Failed to send to group: " + e.getMessage()); } } } diff --git a/src/main/java/org/asamk/signal/commands/ReceiveCommand.java b/src/main/java/org/asamk/signal/commands/ReceiveCommand.java index bc68565a..8612a71b 100644 --- a/src/main/java/org/asamk/signal/commands/ReceiveCommand.java +++ b/src/main/java/org/asamk/signal/commands/ReceiveCommand.java @@ -1,32 +1,38 @@ package org.asamk.signal.commands; -import com.fasterxml.jackson.annotation.JsonAutoDetect; -import com.fasterxml.jackson.annotation.PropertyAccessor; -import com.fasterxml.jackson.core.JsonGenerator; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.node.ObjectNode; - import net.sourceforge.argparse4j.impl.Arguments; import net.sourceforge.argparse4j.inf.Namespace; import net.sourceforge.argparse4j.inf.Subparser; import org.asamk.Signal; import org.asamk.signal.JsonReceiveMessageHandler; +import org.asamk.signal.JsonWriter; +import org.asamk.signal.OutputType; +import org.asamk.signal.PlainTextWriterImpl; import org.asamk.signal.ReceiveMessageHandler; +import org.asamk.signal.commands.exceptions.CommandException; +import org.asamk.signal.commands.exceptions.IOErrorException; +import org.asamk.signal.commands.exceptions.UnexpectedErrorException; import org.asamk.signal.json.JsonMessageEnvelope; import org.asamk.signal.manager.Manager; import org.asamk.signal.util.DateUtils; import org.freedesktop.dbus.connections.impl.DBusConnection; import org.freedesktop.dbus.exceptions.DBusException; -import org.whispersystems.util.Base64; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import java.io.IOException; +import java.util.Base64; +import java.util.Map; +import java.util.Set; import java.util.concurrent.TimeUnit; import static org.asamk.signal.util.ErrorUtils.handleAssertionError; public class ReceiveCommand implements ExtendedDbusCommand, LocalCommand { + private final static Logger logger = LoggerFactory.getLogger(ReceiveCommand.class); + @Override public void attachToSubparser(final Subparser subparser) { subparser.addArgument("-t", "--timeout") @@ -36,146 +42,136 @@ public class ReceiveCommand implements ExtendedDbusCommand, LocalCommand { .help("Don’t download attachments of received messages.") .action(Arguments.storeTrue()); subparser.addArgument("--json") - .help("Output received messages in json format, one json object per line.") + .help("WARNING: This parameter is now deprecated! Please use the global \"--output=json\" option instead.\n\nOutput received messages in json format, one json object per line.") .action(Arguments.storeTrue()); } - public int handleCommand(final Namespace ns, final Signal signal, DBusConnection dbusconnection) { - final ObjectMapper jsonProcessor; + @Override + public Set getSupportedOutputTypes() { + return Set.of(OutputType.PLAIN_TEXT, OutputType.JSON); + } + + public void handleCommand( + final Namespace ns, final Signal signal, DBusConnection dbusconnection + ) throws CommandException { + var inJson = ns.get("output") == OutputType.JSON || ns.getBoolean("json"); + + // TODO delete later when "json" variable is removed if (ns.getBoolean("json")) { - jsonProcessor = new ObjectMapper(); - jsonProcessor.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY); // disable autodetect - jsonProcessor.disable(JsonGenerator.Feature.AUTO_CLOSE_TARGET); - } else { - jsonProcessor = null; + logger.warn("\"--json\" option has been deprecated, please use the global \"--output=json\" instead."); } + try { - dbusconnection.addSigHandler(Signal.MessageReceived.class, messageReceived -> { - if (jsonProcessor != null) { - JsonMessageEnvelope envelope = new JsonMessageEnvelope(messageReceived); - ObjectNode result = jsonProcessor.createObjectNode(); - result.putPOJO("envelope", envelope); - try { - jsonProcessor.writeValue(System.out, result); - System.out.println(); - } catch (IOException e) { - e.printStackTrace(); - } - } else { - System.out.print(String.format("Envelope from: %s\nTimestamp: %s\nBody: %s\n", - messageReceived.getSender(), - DateUtils.formatTimestamp(messageReceived.getTimestamp()), - messageReceived.getMessage())); + if (inJson) { + final var jsonWriter = new JsonWriter(System.out); + + dbusconnection.addSigHandler(Signal.MessageReceived.class, messageReceived -> { + var envelope = new JsonMessageEnvelope(messageReceived); + final var object = Map.of("envelope", envelope); + jsonWriter.write(object); + }); + + dbusconnection.addSigHandler(Signal.ReceiptReceived.class, receiptReceived -> { + var envelope = new JsonMessageEnvelope(receiptReceived); + final var object = Map.of("envelope", envelope); + jsonWriter.write(object); + }); + + dbusconnection.addSigHandler(Signal.SyncMessageReceived.class, syncReceived -> { + var envelope = new JsonMessageEnvelope(syncReceived); + final var object = Map.of("envelope", envelope); + jsonWriter.write(object); + }); + } else { + final var writer = new PlainTextWriterImpl(System.out); + + dbusconnection.addSigHandler(Signal.MessageReceived.class, messageReceived -> { + writer.println("Envelope from: {}", messageReceived.getSender()); + writer.println("Timestamp: {}", DateUtils.formatTimestamp(messageReceived.getTimestamp())); + writer.println("Body: {}", messageReceived.getMessage()); if (messageReceived.getGroupId().length > 0) { - System.out.println("Group info:"); - System.out.println(" Id: " + Base64.encodeBytes(messageReceived.getGroupId())); + writer.println("Group info:"); + writer.indentedWriter() + .println("Id: {}", Base64.getEncoder().encodeToString(messageReceived.getGroupId())); } if (messageReceived.getAttachments().size() > 0) { - System.out.println("Attachments: "); - for (String attachment : messageReceived.getAttachments()) { - System.out.println("- Stored plaintext in: " + attachment); + writer.println("Attachments:"); + for (var attachment : messageReceived.getAttachments()) { + writer.println("- Stored plaintext in: {}", attachment); } } - System.out.println(); - } - }); + writer.println(); + }); - dbusconnection.addSigHandler(Signal.ReceiptReceived.class, receiptReceived -> { - if (jsonProcessor != null) { - JsonMessageEnvelope envelope = new JsonMessageEnvelope(receiptReceived); - ObjectNode result = jsonProcessor.createObjectNode(); - result.putPOJO("envelope", envelope); - try { - jsonProcessor.writeValue(System.out, result); - System.out.println(); - } catch (IOException e) { - e.printStackTrace(); - } - } else { - System.out.print(String.format("Receipt from: %s\nTimestamp: %s\n", - receiptReceived.getSender(), - DateUtils.formatTimestamp(receiptReceived.getTimestamp()))); - } - }); + dbusconnection.addSigHandler(Signal.ReceiptReceived.class, receiptReceived -> { + writer.println("Receipt from: {}", receiptReceived.getSender()); + writer.println("Timestamp: {}", DateUtils.formatTimestamp(receiptReceived.getTimestamp())); + }); - dbusconnection.addSigHandler(Signal.SyncMessageReceived.class, syncReceived -> { - if (jsonProcessor != null) { - JsonMessageEnvelope envelope = new JsonMessageEnvelope(syncReceived); - ObjectNode result = jsonProcessor.createObjectNode(); - result.putPOJO("envelope", envelope); - try { - jsonProcessor.writeValue(System.out, result); - System.out.println(); - } catch (IOException e) { - e.printStackTrace(); - } - } else { - System.out.print(String.format("Sync Envelope from: %s to: %s\nTimestamp: %s\nBody: %s\n", + dbusconnection.addSigHandler(Signal.SyncMessageReceived.class, syncReceived -> { + writer.println("Sync Envelope from: {} to: {}", syncReceived.getSource(), - syncReceived.getDestination(), - DateUtils.formatTimestamp(syncReceived.getTimestamp()), - syncReceived.getMessage())); + syncReceived.getDestination()); + writer.println("Timestamp: {}", DateUtils.formatTimestamp(syncReceived.getTimestamp())); + writer.println("Body: {}", syncReceived.getMessage()); if (syncReceived.getGroupId().length > 0) { - System.out.println("Group info:"); - System.out.println(" Id: " + Base64.encodeBytes(syncReceived.getGroupId())); + writer.println("Group info:"); + writer.indentedWriter() + .println("Id: {}", Base64.getEncoder().encodeToString(syncReceived.getGroupId())); } if (syncReceived.getAttachments().size() > 0) { - System.out.println("Attachments: "); - for (String attachment : syncReceived.getAttachments()) { - System.out.println("- Stored plaintext in: " + attachment); + writer.println("Attachments:"); + for (var attachment : syncReceived.getAttachments()) { + writer.println("- Stored plaintext in: {}", attachment); } } - System.out.println(); - } - }); - } catch (UnsatisfiedLinkError e) { - System.err.println("Missing native library dependency for dbus service: " + e.getMessage()); - return 1; + writer.println(); + }); + } } catch (DBusException e) { - e.printStackTrace(); - return 1; + logger.error("Dbus client failed", e); + throw new UnexpectedErrorException("Dbus client failed"); } while (true) { try { Thread.sleep(10000); - } catch (InterruptedException e) { - return 0; + } catch (InterruptedException ignored) { + return; } } } @Override - public int handleCommand(final Namespace ns, final Manager m) { - if (!m.isRegistered()) { - System.err.println("User is not registered."); - return 1; + public void handleCommand(final Namespace ns, final Manager m) throws CommandException { + var inJson = ns.get("output") == OutputType.JSON || ns.getBoolean("json"); + + // TODO delete later when "json" variable is removed + if (ns.getBoolean("json")) { + logger.warn("\"--json\" option has been deprecated, please use the global \"--output=json\" instead."); } + double timeout = 5; if (ns.getDouble("timeout") != null) { timeout = ns.getDouble("timeout"); } - boolean returnOnTimeout = true; + var returnOnTimeout = true; if (timeout < 0) { returnOnTimeout = false; timeout = 3600; } boolean ignoreAttachments = ns.getBoolean("ignore_attachments"); try { - final Manager.ReceiveMessageHandler handler = ns.getBoolean("json") - ? new JsonReceiveMessageHandler(m) - : new ReceiveMessageHandler(m); + final var handler = inJson ? new JsonReceiveMessageHandler(m) : new ReceiveMessageHandler(m); m.receiveMessages((long) (timeout * 1000), TimeUnit.MILLISECONDS, returnOnTimeout, ignoreAttachments, handler); - return 0; } catch (IOException e) { - System.err.println("Error while receiving messages: " + e.getMessage()); - return 3; + throw new IOErrorException("Error while receiving messages: " + e.getMessage()); } catch (AssertionError e) { handleAssertionError(e); - return 1; + throw e; } } } diff --git a/src/main/java/org/asamk/signal/commands/RegisterCommand.java b/src/main/java/org/asamk/signal/commands/RegisterCommand.java index f69e0844..a4c613af 100644 --- a/src/main/java/org/asamk/signal/commands/RegisterCommand.java +++ b/src/main/java/org/asamk/signal/commands/RegisterCommand.java @@ -4,12 +4,15 @@ import net.sourceforge.argparse4j.impl.Arguments; import net.sourceforge.argparse4j.inf.Namespace; import net.sourceforge.argparse4j.inf.Subparser; -import org.asamk.signal.manager.Manager; +import org.asamk.signal.commands.exceptions.CommandException; +import org.asamk.signal.commands.exceptions.IOErrorException; +import org.asamk.signal.commands.exceptions.UserErrorException; +import org.asamk.signal.manager.RegistrationManager; import org.whispersystems.signalservice.api.push.exceptions.CaptchaRequiredException; import java.io.IOException; -public class RegisterCommand implements LocalCommand { +public class RegisterCommand implements RegistrationCommand { @Override public void attachToSubparser(final Subparser subparser) { @@ -21,18 +24,25 @@ public class RegisterCommand implements LocalCommand { } @Override - public int handleCommand(final Namespace ns, final Manager m) { + public void handleCommand(final Namespace ns, final RegistrationManager m) throws CommandException { + final boolean voiceVerification = ns.getBoolean("voice"); + final var captcha = ns.getString("captcha"); + try { - final boolean voiceVerification = ns.getBoolean("voice"); - final String captcha = ns.getString("captcha"); m.register(voiceVerification, captcha); - return 0; } catch (CaptchaRequiredException e) { - System.err.println("Captcha invalid or required for verification (" + e.getMessage() + ")"); - return 1; + String message; + if (captcha == null) { + message = "Captcha required for verification, use --captcha CAPTCHA\n" + + "To get the token, go to https://signalcaptchas.org/registration/generate.html\n" + + "Check the developer tools (F12) console for a failed redirect to signalcaptcha://\n" + + "Everything after signalcaptcha:// is the captcha token."; + } else { + message = "Invalid captcha given."; + } + throw new UserErrorException(message); } catch (IOException e) { - System.err.println("Request verify error: " + e.getMessage()); - return 3; + throw new IOErrorException("Request verify error: " + e.getMessage()); } } } diff --git a/src/main/java/org/asamk/signal/commands/RegistrationCommand.java b/src/main/java/org/asamk/signal/commands/RegistrationCommand.java new file mode 100644 index 00000000..425ac71d --- /dev/null +++ b/src/main/java/org/asamk/signal/commands/RegistrationCommand.java @@ -0,0 +1,11 @@ +package org.asamk.signal.commands; + +import net.sourceforge.argparse4j.inf.Namespace; + +import org.asamk.signal.commands.exceptions.CommandException; +import org.asamk.signal.manager.RegistrationManager; + +public interface RegistrationCommand extends Command { + + void handleCommand(Namespace ns, RegistrationManager m) throws CommandException; +} diff --git a/src/main/java/org/asamk/signal/commands/RemoteDeleteCommand.java b/src/main/java/org/asamk/signal/commands/RemoteDeleteCommand.java new file mode 100644 index 00000000..796a3344 --- /dev/null +++ b/src/main/java/org/asamk/signal/commands/RemoteDeleteCommand.java @@ -0,0 +1,82 @@ +package org.asamk.signal.commands; + +import net.sourceforge.argparse4j.inf.Namespace; +import net.sourceforge.argparse4j.inf.Subparser; + +import org.asamk.Signal; +import org.asamk.signal.PlainTextWriterImpl; +import org.asamk.signal.commands.exceptions.CommandException; +import org.asamk.signal.commands.exceptions.UnexpectedErrorException; +import org.asamk.signal.commands.exceptions.UserErrorException; +import org.asamk.signal.manager.groups.GroupIdFormatException; +import org.asamk.signal.util.Util; +import org.freedesktop.dbus.errors.UnknownObject; +import org.freedesktop.dbus.exceptions.DBusExecutionException; + +import java.util.List; + +import static org.asamk.signal.util.ErrorUtils.handleAssertionError; + +public class RemoteDeleteCommand implements DbusCommand { + + @Override + public void attachToSubparser(final Subparser subparser) { + subparser.help("Remotely delete a previously sent message."); + subparser.addArgument("-t", "--target-timestamp") + .required(true) + .type(long.class) + .help("Specify the timestamp of the message to delete."); + subparser.addArgument("-g", "--group") + .help("Specify the recipient group ID."); + subparser.addArgument("recipient") + .help("Specify the recipients' phone number.").nargs("*"); + } + + @Override + public void handleCommand(final Namespace ns, final Signal signal) throws CommandException { + final List recipients = ns.getList("recipient"); + final var groupIdString = ns.getString("group"); + + final var noRecipients = recipients == null || recipients.isEmpty(); + if (noRecipients && groupIdString == null) { + throw new UserErrorException("No recipients given"); + } + if (!noRecipients && groupIdString != null) { + throw new UserErrorException("You cannot specify recipients by phone number and groups at the same time"); + } + + final long targetTimestamp = ns.getLong("target_timestamp"); + + final var writer = new PlainTextWriterImpl(System.out); + + byte[] groupId = null; + if (groupIdString != null) { + try { + groupId = Util.decodeGroupId(groupIdString).serialize(); + } catch (GroupIdFormatException e) { + throw new UserErrorException("Invalid group id: " + e.getMessage()); + } + } + + try { + long timestamp; + if (groupId != null) { + timestamp = signal.sendGroupRemoteDeleteMessage(targetTimestamp, groupId); + } else { + timestamp = signal.sendRemoteDeleteMessage(targetTimestamp, recipients); + } + writer.println("{}", timestamp); + } catch (AssertionError e) { + handleAssertionError(e); + throw e; + } catch (UnknownObject e) { + throw new UserErrorException("Failed to find dbus object, maybe missing the -u flag: " + e.getMessage()); + } catch (Signal.Error.InvalidNumber e) { + throw new UserErrorException("Invalid number: " + e.getMessage()); + } catch (Signal.Error.GroupNotFound e) { + throw new UserErrorException("Failed to send to group: " + e.getMessage()); + } catch (DBusExecutionException e) { + throw new UnexpectedErrorException("Failed to send message: " + e.getMessage()); + } + } +} diff --git a/src/main/java/org/asamk/signal/commands/RemoveDeviceCommand.java b/src/main/java/org/asamk/signal/commands/RemoveDeviceCommand.java index 1e2343e7..c9be92e8 100644 --- a/src/main/java/org/asamk/signal/commands/RemoveDeviceCommand.java +++ b/src/main/java/org/asamk/signal/commands/RemoveDeviceCommand.java @@ -3,6 +3,8 @@ package org.asamk.signal.commands; import net.sourceforge.argparse4j.inf.Namespace; import net.sourceforge.argparse4j.inf.Subparser; +import org.asamk.signal.commands.exceptions.CommandException; +import org.asamk.signal.commands.exceptions.IOErrorException; import org.asamk.signal.manager.Manager; import java.io.IOException; @@ -18,18 +20,12 @@ public class RemoveDeviceCommand implements LocalCommand { } @Override - public int handleCommand(final Namespace ns, final Manager m) { - if (!m.isRegistered()) { - System.err.println("User is not registered."); - return 1; - } + public void handleCommand(final Namespace ns, final Manager m) throws CommandException { try { int deviceId = ns.getInt("deviceId"); m.removeLinkedDevices(deviceId); - return 0; } catch (IOException e) { - e.printStackTrace(); - return 3; + throw new IOErrorException("Error while removing device: " + e.getMessage()); } } } diff --git a/src/main/java/org/asamk/signal/commands/RemovePinCommand.java b/src/main/java/org/asamk/signal/commands/RemovePinCommand.java index b7de5402..03d8d7cb 100644 --- a/src/main/java/org/asamk/signal/commands/RemovePinCommand.java +++ b/src/main/java/org/asamk/signal/commands/RemovePinCommand.java @@ -3,8 +3,12 @@ package org.asamk.signal.commands; import net.sourceforge.argparse4j.inf.Namespace; import net.sourceforge.argparse4j.inf.Subparser; +import org.asamk.signal.commands.exceptions.CommandException; +import org.asamk.signal.commands.exceptions.IOErrorException; +import org.asamk.signal.commands.exceptions.UnexpectedErrorException; import org.asamk.signal.manager.Manager; import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.internal.contacts.crypto.UnauthenticatedResponseException; import java.io.IOException; @@ -15,17 +19,13 @@ public class RemovePinCommand implements LocalCommand { } @Override - public int handleCommand(final Namespace ns, final Manager m) { - if (!m.isRegistered()) { - System.err.println("User is not registered."); - return 1; - } + public void handleCommand(final Namespace ns, final Manager m) throws CommandException { try { m.setRegistrationLockPin(Optional.absent()); - return 0; + } catch (UnauthenticatedResponseException e) { + throw new UnexpectedErrorException("Remove pin failed with unauthenticated response: " + e.getMessage()); } catch (IOException e) { - System.err.println("Remove pin error: " + e.getMessage()); - return 3; + throw new IOErrorException("Remove pin error: " + e.getMessage()); } } } diff --git a/src/main/java/org/asamk/signal/commands/SendCommand.java b/src/main/java/org/asamk/signal/commands/SendCommand.java index 896d21d6..20ddb6eb 100644 --- a/src/main/java/org/asamk/signal/commands/SendCommand.java +++ b/src/main/java/org/asamk/signal/commands/SendCommand.java @@ -5,25 +5,38 @@ import net.sourceforge.argparse4j.inf.Namespace; import net.sourceforge.argparse4j.inf.Subparser; import org.asamk.Signal; -import org.asamk.signal.manager.GroupIdFormatException; +import org.asamk.signal.PlainTextWriterImpl; +import org.asamk.signal.commands.exceptions.CommandException; +import org.asamk.signal.commands.exceptions.UnexpectedErrorException; +import org.asamk.signal.commands.exceptions.UntrustedKeyErrorException; +import org.asamk.signal.commands.exceptions.UserErrorException; +import org.asamk.signal.manager.groups.GroupIdFormatException; import org.asamk.signal.util.IOUtils; import org.asamk.signal.util.Util; +import org.freedesktop.dbus.errors.UnknownObject; import org.freedesktop.dbus.exceptions.DBusExecutionException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import java.io.IOException; import java.nio.charset.Charset; -import java.util.ArrayList; import java.util.List; import java.util.Arrays; import static org.asamk.signal.util.ErrorUtils.handleAssertionError; -import static org.asamk.signal.util.ErrorUtils.handleGroupIdFormatException; public class SendCommand implements DbusCommand { + private final static Logger logger = LoggerFactory.getLogger(SendCommand.class); + @Override public void attachToSubparser(final Subparser subparser) { - subparser.addArgument("-g", "--group").help("Specify the recipient group ID."); subparser.addArgument("recipient").help("Specify the recipients' phone number.").nargs("*"); + final var mutuallyExclusiveGroup = subparser.addMutuallyExclusiveGroup(); + mutuallyExclusiveGroup.addArgument("-g", "--group").help("Specify the recipient group ID."); + mutuallyExclusiveGroup.addArgument("--note-to-self") + .help("Send the message to self without notification.") + .action(Arguments.storeTrue()); + subparser.addArgument("-m", "--message").help("Specify the message, if missing standard input is used."); subparser.addArgument("-a", "--attachment").nargs("*").help("Add file as attachment"); subparser.addArgument("-e", "--endsession") @@ -32,82 +45,108 @@ public class SendCommand implements DbusCommand { } @Override - public int handleCommand(final Namespace ns, final Signal signal) { - if (!signal.isRegistered()) { - System.err.println("User is not registered."); - return 1; + public void handleCommand(final Namespace ns, final Signal signal) throws CommandException { + final List recipients = ns.getList("recipient"); + final var isEndSession = ns.getBoolean("endsession"); + final var groupIdString = ns.getString("group"); + final var isNoteToSelf = ns.getBoolean("note_to_self"); + + final var noRecipients = recipients == null || recipients.isEmpty(); + if ((noRecipients && isEndSession) || (noRecipients && groupIdString == null && !isNoteToSelf)) { + throw new UserErrorException("No recipients given"); + } + if (!noRecipients && groupIdString != null) { + throw new UserErrorException("You cannot specify recipients by phone number and groups at the same time"); + } + if (!noRecipients && isNoteToSelf) { + throw new UserErrorException( + "You cannot specify recipients by phone number and not to self at the same time"); } - if ((ns.getList("recipient") == null || ns.getList("recipient").size() == 0) && ( - ns.getBoolean("endsession") || ns.getString("group") == null - )) { - System.err.println("No recipients given"); - System.err.println("Aborting sending."); - return 1; - } - - if (ns.getBoolean("endsession")) { + if (isEndSession) { try { - signal.sendEndSessionMessage(ns.getList("recipient")); - return 0; + signal.sendEndSessionMessage(recipients); + return; } catch (AssertionError e) { handleAssertionError(e); - return 1; + throw e; + } catch (Signal.Error.UntrustedIdentity e) { + throw new UntrustedKeyErrorException("Failed to send message: " + e.getMessage()); } catch (DBusExecutionException e) { - System.err.println("Failed to send message: " + e.getMessage()); - return 1; + throw new UnexpectedErrorException("Failed to send message: " + e.getMessage()); } } - String messageText = ns.getString("message"); + var messageText = ns.getString("message"); if (messageText == null) { try { messageText = IOUtils.readAll(System.in, Charset.defaultCharset()); } catch (IOException e) { - System.err.println("Failed to read message from stdin: " + e.getMessage()); - System.err.println("Aborting sending."); - return 1; + throw new UserErrorException("Failed to read message from stdin: " + e.getMessage()); } } List attachments = ns.getList("attachment"); if (attachments == null) { - attachments = new ArrayList<>(); + attachments = List.of(); } - try { - if (ns.getString("group") != null) { - byte[] groupId; - try { - groupId = Util.decodeGroupId(ns.getString("group")).serialize(); - } catch (GroupIdFormatException e) { - handleGroupIdFormatException(e); - return 1; - } + final var writer = new PlainTextWriterImpl(System.out); - long timestamp = signal.sendGroupMessage(messageText, attachments, groupId); - System.out.println(timestamp); - return 0; + if (groupIdString != null) { + byte[] groupId; + try { + groupId = Util.decodeGroupId(groupIdString).serialize(); + } catch (GroupIdFormatException e) { + throw new UserErrorException("Invalid group id: " + e.getMessage()); + } + + try { + var timestamp = signal.sendGroupMessage(messageText, attachments, groupId); + writer.println("{}", timestamp); + return; + } catch (AssertionError e) { + handleAssertionError(e); + throw e; + } catch (DBusExecutionException e) { + throw new UnexpectedErrorException("Failed to send group message: " + e.getMessage()); + } + } + + if (isNoteToSelf) { + try { + var timestamp = signal.sendNoteToSelfMessage(messageText, attachments); + writer.println("{}", timestamp); + return; + } catch (AssertionError e) { + handleAssertionError(e); + throw e; + } catch (Signal.Error.UntrustedIdentity e) { + throw new UntrustedKeyErrorException("Failed to send message: " + e.getMessage()); + } catch (DBusExecutionException e) { + throw new UnexpectedErrorException("Failed to send note to self message: " + e.getMessage()); } - } catch (AssertionError e) { - handleAssertionError(e); - return 1; - } catch (DBusExecutionException e) { - System.err.println("Failed to send message: " + e.getMessage()); - return 1; } try { +<<<<<<< HEAD System.out.println(Arrays.toString(ns.getList("recipient").toArray())); long timestamp = signal.sendMessage(messageText, attachments, ns.getList("recipient")); System.out.println(timestamp); return 0; +======= + var timestamp = signal.sendMessage(messageText, attachments, recipients); + writer.println("{}", timestamp); +>>>>>>> upstream/master } catch (AssertionError e) { handleAssertionError(e); - return 1; + throw e; + } catch (UnknownObject e) { + throw new UserErrorException("Failed to find dbus object, maybe missing the -u flag: " + e.getMessage()); + } catch (Signal.Error.UntrustedIdentity e) { + throw new UntrustedKeyErrorException("Failed to send message: " + e.getMessage()); } catch (DBusExecutionException e) { - System.err.println("Failed to send message: " + e.getMessage()); - return 1; + throw new UnexpectedErrorException("Failed to send message: " + e.getMessage()); } } } diff --git a/src/main/java/org/asamk/signal/commands/SendContactsCommand.java b/src/main/java/org/asamk/signal/commands/SendContactsCommand.java index 20e81a60..176f1bb9 100644 --- a/src/main/java/org/asamk/signal/commands/SendContactsCommand.java +++ b/src/main/java/org/asamk/signal/commands/SendContactsCommand.java @@ -3,6 +3,9 @@ package org.asamk.signal.commands; import net.sourceforge.argparse4j.inf.Namespace; import net.sourceforge.argparse4j.inf.Subparser; +import org.asamk.signal.commands.exceptions.CommandException; +import org.asamk.signal.commands.exceptions.IOErrorException; +import org.asamk.signal.commands.exceptions.UntrustedKeyErrorException; import org.asamk.signal.manager.Manager; import org.whispersystems.signalservice.api.crypto.UntrustedIdentityException; @@ -16,17 +19,13 @@ public class SendContactsCommand implements LocalCommand { } @Override - public int handleCommand(final Namespace ns, final Manager m) { - if (!m.isRegistered()) { - System.err.println("User is not registered."); - return 1; - } + public void handleCommand(final Namespace ns, final Manager m) throws CommandException { try { m.sendContacts(); - return 0; - } catch (IOException | UntrustedIdentityException e) { - System.err.println("SendContacts error: " + e.getMessage()); - return 3; + } catch (UntrustedIdentityException e) { + throw new UntrustedKeyErrorException("SendContacts error: " + e.getMessage()); + } catch (IOException e) { + throw new IOErrorException("SendContacts error: " + e.getMessage()); } } } diff --git a/src/main/java/org/asamk/signal/commands/SendReactionCommand.java b/src/main/java/org/asamk/signal/commands/SendReactionCommand.java index 345c9180..a80b6a23 100644 --- a/src/main/java/org/asamk/signal/commands/SendReactionCommand.java +++ b/src/main/java/org/asamk/signal/commands/SendReactionCommand.java @@ -4,28 +4,21 @@ import net.sourceforge.argparse4j.impl.Arguments; import net.sourceforge.argparse4j.inf.Namespace; import net.sourceforge.argparse4j.inf.Subparser; -import org.asamk.signal.manager.GroupId; -import org.asamk.signal.manager.GroupIdFormatException; -import org.asamk.signal.manager.GroupNotFoundException; -import org.asamk.signal.manager.Manager; -import org.asamk.signal.manager.NotAGroupMemberException; +import org.asamk.Signal; +import org.asamk.signal.PlainTextWriterImpl; +import org.asamk.signal.commands.exceptions.CommandException; +import org.asamk.signal.commands.exceptions.UnexpectedErrorException; +import org.asamk.signal.commands.exceptions.UserErrorException; +import org.asamk.signal.manager.groups.GroupIdFormatException; import org.asamk.signal.util.Util; -import org.whispersystems.libsignal.util.Pair; -import org.whispersystems.signalservice.api.messages.SendMessageResult; -import org.whispersystems.signalservice.api.util.InvalidNumberException; +import org.freedesktop.dbus.errors.UnknownObject; +import org.freedesktop.dbus.exceptions.DBusExecutionException; -import java.io.IOException; import java.util.List; import static org.asamk.signal.util.ErrorUtils.handleAssertionError; -import static org.asamk.signal.util.ErrorUtils.handleGroupIdFormatException; -import static org.asamk.signal.util.ErrorUtils.handleGroupNotFoundException; -import static org.asamk.signal.util.ErrorUtils.handleIOException; -import static org.asamk.signal.util.ErrorUtils.handleInvalidNumberException; -import static org.asamk.signal.util.ErrorUtils.handleNotAGroupMemberException; -import static org.asamk.signal.util.ErrorUtils.handleTimestampAndSendMessageResults; -public class SendReactionCommand implements LocalCommand { +public class SendReactionCommand implements DbusCommand { @Override public void attachToSubparser(final Subparser subparser) { @@ -46,54 +39,53 @@ public class SendReactionCommand implements LocalCommand { } @Override - public int handleCommand(final Namespace ns, final Manager m) { - if (!m.isRegistered()) { - System.err.println("User is not registered."); - return 1; + public void handleCommand(final Namespace ns, final Signal signal) throws CommandException { + final List recipients = ns.getList("recipient"); + final var groupIdString = ns.getString("group"); + + final var noRecipients = recipients == null || recipients.isEmpty(); + if (noRecipients && groupIdString == null) { + throw new UserErrorException("No recipients given"); + } + if (!noRecipients && groupIdString != null) { + throw new UserErrorException("You cannot specify recipients by phone number and groups at the same time"); } - if ((ns.getList("recipient") == null || ns.getList("recipient").size() == 0) && ns.getString("group") == null) { - System.err.println("No recipients given"); - System.err.println("Aborting sending."); - return 1; - } + final var emoji = ns.getString("emoji"); + final boolean isRemove = ns.getBoolean("remove"); + final var targetAuthor = ns.getString("target_author"); + final long targetTimestamp = ns.getLong("target_timestamp"); - String emoji = ns.getString("emoji"); - boolean isRemove = ns.getBoolean("remove"); - String targetAuthor = ns.getString("target_author"); - long targetTimestamp = ns.getLong("target_timestamp"); + final var writer = new PlainTextWriterImpl(System.out); + + byte[] groupId = null; + if (groupIdString != null) { + try { + groupId = Util.decodeGroupId(groupIdString).serialize(); + } catch (GroupIdFormatException e) { + throw new UserErrorException("Invalid group id: " + e.getMessage()); + } + } try { - final Pair> results; - if (ns.getString("group") != null) { - GroupId groupId = Util.decodeGroupId(ns.getString("group")); - results = m.sendGroupMessageReaction(emoji, isRemove, targetAuthor, targetTimestamp, groupId); + long timestamp; + if (groupId != null) { + timestamp = signal.sendGroupMessageReaction(emoji, isRemove, targetAuthor, targetTimestamp, groupId); } else { - results = m.sendMessageReaction(emoji, - isRemove, - targetAuthor, - targetTimestamp, - ns.getList("recipient")); + timestamp = signal.sendMessageReaction(emoji, isRemove, targetAuthor, targetTimestamp, recipients); } - return handleTimestampAndSendMessageResults(results.first(), results.second()); - } catch (IOException e) { - handleIOException(e); - return 3; + writer.println("{}", timestamp); } catch (AssertionError e) { handleAssertionError(e); - return 1; - } catch (GroupNotFoundException e) { - handleGroupNotFoundException(e); - return 1; - } catch (NotAGroupMemberException e) { - handleNotAGroupMemberException(e); - return 1; - } catch (GroupIdFormatException e) { - handleGroupIdFormatException(e); - return 1; - } catch (InvalidNumberException e) { - handleInvalidNumberException(e); - return 1; + throw e; + } catch (UnknownObject e) { + throw new UserErrorException("Failed to find dbus object, maybe missing the -u flag: " + e.getMessage()); + } catch (Signal.Error.InvalidNumber e) { + throw new UserErrorException("Invalid number: " + e.getMessage()); + } catch (Signal.Error.GroupNotFound e) { + throw new UserErrorException("Failed to send to group: " + e.getMessage()); + } catch (DBusExecutionException e) { + throw new UnexpectedErrorException("Failed to send message: " + e.getMessage()); } } } diff --git a/src/main/java/org/asamk/signal/commands/SetPinCommand.java b/src/main/java/org/asamk/signal/commands/SetPinCommand.java index 9351dad0..56b4b8a4 100644 --- a/src/main/java/org/asamk/signal/commands/SetPinCommand.java +++ b/src/main/java/org/asamk/signal/commands/SetPinCommand.java @@ -3,8 +3,12 @@ package org.asamk.signal.commands; import net.sourceforge.argparse4j.inf.Namespace; import net.sourceforge.argparse4j.inf.Subparser; +import org.asamk.signal.commands.exceptions.CommandException; +import org.asamk.signal.commands.exceptions.IOErrorException; +import org.asamk.signal.commands.exceptions.UnexpectedErrorException; import org.asamk.signal.manager.Manager; import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.internal.contacts.crypto.UnauthenticatedResponseException; import java.io.IOException; @@ -17,18 +21,14 @@ public class SetPinCommand implements LocalCommand { } @Override - public int handleCommand(final Namespace ns, final Manager m) { - if (!m.isRegistered()) { - System.err.println("User is not registered."); - return 1; - } + public void handleCommand(final Namespace ns, final Manager m) throws CommandException { try { - String registrationLockPin = ns.getString("registrationLockPin"); + var registrationLockPin = ns.getString("registrationLockPin"); m.setRegistrationLockPin(Optional.of(registrationLockPin)); - return 0; + } catch (UnauthenticatedResponseException e) { + throw new UnexpectedErrorException("Set pin error failed with unauthenticated response: " + e.getMessage()); } catch (IOException e) { - System.err.println("Set pin error: " + e.getMessage()); - return 3; + throw new IOErrorException("Set pin error: " + e.getMessage()); } } } diff --git a/src/main/java/org/asamk/signal/commands/TrustCommand.java b/src/main/java/org/asamk/signal/commands/TrustCommand.java index 076a86db..08fe6a41 100644 --- a/src/main/java/org/asamk/signal/commands/TrustCommand.java +++ b/src/main/java/org/asamk/signal/commands/TrustCommand.java @@ -1,12 +1,12 @@ package org.asamk.signal.commands; import net.sourceforge.argparse4j.impl.Arguments; -import net.sourceforge.argparse4j.inf.MutuallyExclusiveGroup; import net.sourceforge.argparse4j.inf.Namespace; import net.sourceforge.argparse4j.inf.Subparser; +import org.asamk.signal.commands.exceptions.CommandException; +import org.asamk.signal.commands.exceptions.UserErrorException; import org.asamk.signal.manager.Manager; -import org.asamk.signal.util.ErrorUtils; import org.asamk.signal.util.Hex; import org.whispersystems.signalservice.api.util.InvalidNumberException; @@ -17,7 +17,7 @@ public class TrustCommand implements LocalCommand { @Override public void attachToSubparser(final Subparser subparser) { subparser.addArgument("number").help("Specify the phone number, for which to set the trust.").required(true); - MutuallyExclusiveGroup mutTrust = subparser.addMutuallyExclusiveGroup(); + var mutTrust = subparser.addMutuallyExclusiveGroup(); mutTrust.addArgument("-a", "--trust-all-known-keys") .help("Trust all known keys of this user, only use this for testing.") .action(Arguments.storeTrue()); @@ -26,20 +26,15 @@ public class TrustCommand implements LocalCommand { } @Override - public int handleCommand(final Namespace ns, final Manager m) { - if (!m.isRegistered()) { - System.err.println("User is not registered."); - return 1; - } - String number = ns.getString("number"); + public void handleCommand(final Namespace ns, final Manager m) throws CommandException { + var number = ns.getString("number"); if (ns.getBoolean("trust_all_known_keys")) { - boolean res = m.trustIdentityAllKeys(number); + var res = m.trustIdentityAllKeys(number); if (!res) { - System.err.println("Failed to set the trust for this number, make sure the number is correct."); - return 1; + throw new UserErrorException("Failed to set the trust for this number, make sure the number is correct."); } } else { - String safetyNumber = ns.getString("verified_safety_number"); + var safetyNumber = ns.getString("verified_safety_number"); if (safetyNumber != null) { safetyNumber = safetyNumber.replaceAll(" ", ""); if (safetyNumber.length() == 66) { @@ -47,46 +42,38 @@ public class TrustCommand implements LocalCommand { try { fingerprintBytes = Hex.toByteArray(safetyNumber.toLowerCase(Locale.ROOT)); } catch (Exception e) { - System.err.println( + throw new UserErrorException( "Failed to parse the fingerprint, make sure the fingerprint is a correctly encoded hex string without additional characters."); - return 1; } boolean res; try { res = m.trustIdentityVerified(number, fingerprintBytes); } catch (InvalidNumberException e) { - ErrorUtils.handleInvalidNumberException(e); - return 1; + throw new UserErrorException("Failed to parse recipient: " + e.getMessage()); } if (!res) { - System.err.println( + throw new UserErrorException( "Failed to set the trust for the fingerprint of this number, make sure the number and the fingerprint are correct."); - return 1; } } else if (safetyNumber.length() == 60) { boolean res; try { res = m.trustIdentityVerifiedSafetyNumber(number, safetyNumber); } catch (InvalidNumberException e) { - ErrorUtils.handleInvalidNumberException(e); - return 1; + throw new UserErrorException("Failed to parse recipient: " + e.getMessage()); } if (!res) { - System.err.println( + throw new UserErrorException( "Failed to set the trust for the safety number of this phone number, make sure the phone number and the safety number are correct."); - return 1; } } else { - System.err.println( + throw new UserErrorException( "Safety number has invalid format, either specify the old hex fingerprint or the new safety number"); - return 1; } } else { - System.err.println( + throw new UserErrorException( "You need to specify the fingerprint/safety number you have verified with -v SAFETY_NUMBER"); - return 1; } } - return 0; } } diff --git a/src/main/java/org/asamk/signal/commands/UnblockCommand.java b/src/main/java/org/asamk/signal/commands/UnblockCommand.java index 73e578ac..6e067ee5 100644 --- a/src/main/java/org/asamk/signal/commands/UnblockCommand.java +++ b/src/main/java/org/asamk/signal/commands/UnblockCommand.java @@ -3,15 +3,19 @@ package org.asamk.signal.commands; import net.sourceforge.argparse4j.inf.Namespace; import net.sourceforge.argparse4j.inf.Subparser; -import org.asamk.signal.manager.GroupId; -import org.asamk.signal.manager.GroupIdFormatException; -import org.asamk.signal.manager.GroupNotFoundException; +import org.asamk.signal.commands.exceptions.CommandException; import org.asamk.signal.manager.Manager; +import org.asamk.signal.manager.groups.GroupIdFormatException; +import org.asamk.signal.manager.groups.GroupNotFoundException; import org.asamk.signal.util.Util; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.whispersystems.signalservice.api.util.InvalidNumberException; public class UnblockCommand implements LocalCommand { + private final static Logger logger = LoggerFactory.getLogger(UnblockCommand.class); + @Override public void attachToSubparser(final Subparser subparser) { subparser.addArgument("contact").help("Contact number").nargs("*"); @@ -20,31 +24,26 @@ public class UnblockCommand implements LocalCommand { } @Override - public int handleCommand(final Namespace ns, final Manager m) { - if (!m.isRegistered()) { - System.err.println("User is not registered."); - return 1; - } - - for (String contact_number : ns.getList("contact")) { + public void handleCommand(final Namespace ns, final Manager m) throws CommandException { + for (var contactNumber : ns.getList("contact")) { try { - m.setContactBlocked(contact_number, false); + m.setContactBlocked(contactNumber, false); } catch (InvalidNumberException e) { - System.err.println(e.getMessage()); + logger.warn("Invalid number: {}", contactNumber); } } if (ns.getList("group") != null) { - for (String groupIdString : ns.getList("group")) { + for (var groupIdString : ns.getList("group")) { try { - GroupId groupId = Util.decodeGroupId(groupIdString); + var groupId = Util.decodeGroupId(groupIdString); m.setGroupBlocked(groupId, false); - } catch (GroupIdFormatException | GroupNotFoundException e) { - System.err.println(e.getMessage()); + } catch (GroupIdFormatException e) { + logger.warn("Invalid group id: {}", groupIdString); + } catch (GroupNotFoundException e) { + logger.warn("Unknown group id: {}", groupIdString); } } } - - return 0; } } diff --git a/src/main/java/org/asamk/signal/commands/UnregisterCommand.java b/src/main/java/org/asamk/signal/commands/UnregisterCommand.java index 7a7616bd..1846eba1 100644 --- a/src/main/java/org/asamk/signal/commands/UnregisterCommand.java +++ b/src/main/java/org/asamk/signal/commands/UnregisterCommand.java @@ -3,6 +3,8 @@ package org.asamk.signal.commands; import net.sourceforge.argparse4j.inf.Namespace; import net.sourceforge.argparse4j.inf.Subparser; +import org.asamk.signal.commands.exceptions.CommandException; +import org.asamk.signal.commands.exceptions.IOErrorException; import org.asamk.signal.manager.Manager; import java.io.IOException; @@ -15,17 +17,11 @@ public class UnregisterCommand implements LocalCommand { } @Override - public int handleCommand(final Namespace ns, final Manager m) { - if (!m.isRegistered()) { - System.err.println("User is not registered."); - return 1; - } + public void handleCommand(final Namespace ns, final Manager m) throws CommandException { try { m.unregister(); - return 0; } catch (IOException e) { - System.err.println("Unregister error: " + e.getMessage()); - return 3; + throw new IOErrorException("Unregister error: " + e.getMessage()); } } } diff --git a/src/main/java/org/asamk/signal/commands/UpdateAccountCommand.java b/src/main/java/org/asamk/signal/commands/UpdateAccountCommand.java index 79459fe6..13723f09 100644 --- a/src/main/java/org/asamk/signal/commands/UpdateAccountCommand.java +++ b/src/main/java/org/asamk/signal/commands/UpdateAccountCommand.java @@ -3,6 +3,8 @@ package org.asamk.signal.commands; import net.sourceforge.argparse4j.inf.Namespace; import net.sourceforge.argparse4j.inf.Subparser; +import org.asamk.signal.commands.exceptions.CommandException; +import org.asamk.signal.commands.exceptions.IOErrorException; import org.asamk.signal.manager.Manager; import java.io.IOException; @@ -15,17 +17,11 @@ public class UpdateAccountCommand implements LocalCommand { } @Override - public int handleCommand(final Namespace ns, final Manager m) { - if (!m.isRegistered()) { - System.err.println("User is not registered."); - return 1; - } + public void handleCommand(final Namespace ns, final Manager m) throws CommandException { try { m.updateAccountAttributes(); - return 0; } catch (IOException e) { - System.err.println("UpdateAccount error: " + e.getMessage()); - return 3; + throw new IOErrorException("UpdateAccount error: " + e.getMessage()); } } } diff --git a/src/main/java/org/asamk/signal/commands/UpdateContactCommand.java b/src/main/java/org/asamk/signal/commands/UpdateContactCommand.java index da090209..c8ad613b 100644 --- a/src/main/java/org/asamk/signal/commands/UpdateContactCommand.java +++ b/src/main/java/org/asamk/signal/commands/UpdateContactCommand.java @@ -3,6 +3,9 @@ package org.asamk.signal.commands; import net.sourceforge.argparse4j.inf.Namespace; import net.sourceforge.argparse4j.inf.Subparser; +import org.asamk.signal.commands.exceptions.CommandException; +import org.asamk.signal.commands.exceptions.IOErrorException; +import org.asamk.signal.commands.exceptions.UserErrorException; import org.asamk.signal.manager.Manager; import org.whispersystems.signalservice.api.util.InvalidNumberException; @@ -22,30 +25,21 @@ public class UpdateContactCommand implements LocalCommand { } @Override - public int handleCommand(final Namespace ns, final Manager m) { - if (!m.isRegistered()) { - System.err.println("User is not registered."); - return 1; - } - - String number = ns.getString("number"); - String name = ns.getString("name"); + public void handleCommand(final Namespace ns, final Manager m) throws CommandException { + var number = ns.getString("number"); + var name = ns.getString("name"); try { m.setContactName(number, name); - Integer expiration = ns.getInt("expiration"); + var expiration = ns.getInt("expiration"); if (expiration != null) { m.setExpirationTimer(number, expiration); } } catch (InvalidNumberException e) { - System.err.println("Invalid contact number: " + e.getMessage()); - return 1; + throw new UserErrorException("Invalid contact number: " + e.getMessage()); } catch (IOException e) { - System.err.println("Update contact error: " + e.getMessage()); - return 3; + throw new IOErrorException("Update contact error: " + e.getMessage()); } - - return 0; } } diff --git a/src/main/java/org/asamk/signal/commands/UpdateGroupCommand.java b/src/main/java/org/asamk/signal/commands/UpdateGroupCommand.java index dae06b86..204dcfe2 100644 --- a/src/main/java/org/asamk/signal/commands/UpdateGroupCommand.java +++ b/src/main/java/org/asamk/signal/commands/UpdateGroupCommand.java @@ -4,19 +4,26 @@ import net.sourceforge.argparse4j.inf.Namespace; import net.sourceforge.argparse4j.inf.Subparser; import org.asamk.Signal; -import org.asamk.signal.manager.GroupIdFormatException; +import org.asamk.signal.PlainTextWriterImpl; +import org.asamk.signal.commands.exceptions.CommandException; +import org.asamk.signal.commands.exceptions.UnexpectedErrorException; +import org.asamk.signal.commands.exceptions.UserErrorException; +import org.asamk.signal.manager.groups.GroupIdFormatException; import org.asamk.signal.util.Util; import org.freedesktop.dbus.exceptions.DBusExecutionException; -import org.whispersystems.util.Base64; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import java.util.ArrayList; +import java.util.Base64; import java.util.List; import static org.asamk.signal.util.ErrorUtils.handleAssertionError; -import static org.asamk.signal.util.ErrorUtils.handleGroupIdFormatException; public class UpdateGroupCommand implements DbusCommand { + private final static Logger logger = LoggerFactory.getLogger(UpdateGroupCommand.class); + @Override public void attachToSubparser(final Subparser subparser) { subparser.addArgument("-g", "--group").help("Specify the recipient group ID."); @@ -26,26 +33,21 @@ public class UpdateGroupCommand implements DbusCommand { } @Override - public int handleCommand(final Namespace ns, final Signal signal) { - if (!signal.isRegistered()) { - System.err.println("User is not registered."); - return 1; - } - + public void handleCommand(final Namespace ns, final Signal signal) throws CommandException { + final var writer = new PlainTextWriterImpl(System.out); byte[] groupId = null; if (ns.getString("group") != null) { try { groupId = Util.decodeGroupId(ns.getString("group")).serialize(); } catch (GroupIdFormatException e) { - handleGroupIdFormatException(e); - return 1; + throw new UserErrorException("Invalid group id:" + e.getMessage()); } } if (groupId == null) { groupId = new byte[0]; } - String groupName = ns.getString("name"); + var groupName = ns.getString("name"); if (groupName == null) { groupName = ""; } @@ -55,26 +57,23 @@ public class UpdateGroupCommand implements DbusCommand { groupMembers = new ArrayList<>(); } - String groupAvatar = ns.getString("avatar"); + var groupAvatar = ns.getString("avatar"); if (groupAvatar == null) { groupAvatar = ""; } try { - byte[] newGroupId = signal.updateGroup(groupId, groupName, groupMembers, groupAvatar); + var newGroupId = signal.updateGroup(groupId, groupName, groupMembers, groupAvatar); if (groupId.length != newGroupId.length) { - System.out.println("Creating new group \"" + Base64.encodeBytes(newGroupId) + "\" …"); + writer.println("Created new group: \"{}\"", Base64.getEncoder().encodeToString(newGroupId)); } - return 0; } catch (AssertionError e) { handleAssertionError(e); - return 1; + throw e; } catch (Signal.Error.AttachmentInvalid e) { - System.err.println("Failed to add avatar attachment for group\": " + e.getMessage()); - return 1; + throw new UserErrorException("Failed to add avatar attachment for group\": " + e.getMessage()); } catch (DBusExecutionException e) { - System.err.println("Failed to send message: " + e.getMessage()); - return 1; + throw new UnexpectedErrorException("Failed to send message: " + e.getMessage()); } } } diff --git a/src/main/java/org/asamk/signal/commands/UpdateProfileCommand.java b/src/main/java/org/asamk/signal/commands/UpdateProfileCommand.java index 1e332fb4..c3fc2e88 100644 --- a/src/main/java/org/asamk/signal/commands/UpdateProfileCommand.java +++ b/src/main/java/org/asamk/signal/commands/UpdateProfileCommand.java @@ -1,11 +1,13 @@ package org.asamk.signal.commands; import net.sourceforge.argparse4j.impl.Arguments; -import net.sourceforge.argparse4j.inf.MutuallyExclusiveGroup; import net.sourceforge.argparse4j.inf.Namespace; import net.sourceforge.argparse4j.inf.Subparser; +import org.asamk.signal.commands.exceptions.CommandException; +import org.asamk.signal.commands.exceptions.IOErrorException; import org.asamk.signal.manager.Manager; +import org.whispersystems.libsignal.util.guava.Optional; import java.io.File; import java.io.IOException; @@ -14,34 +16,33 @@ public class UpdateProfileCommand implements LocalCommand { @Override public void attachToSubparser(final Subparser subparser) { - final MutuallyExclusiveGroup avatarOptions = subparser.addMutuallyExclusiveGroup().required(true); + subparser.addArgument("--name").help("New profile name"); + subparser.addArgument("--about").help("New profile about text"); + subparser.addArgument("--about-emoji").help("New profile about emoji"); + + final var avatarOptions = subparser.addMutuallyExclusiveGroup(); avatarOptions.addArgument("--avatar").help("Path to new profile avatar"); avatarOptions.addArgument("--remove-avatar").action(Arguments.storeTrue()); - subparser.addArgument("--name").required(true).help("New profile name"); - - subparser.help("Set a name and avatar image for the user profile"); + subparser.help("Set a name, about and avatar image for the user profile"); } @Override - public int handleCommand(final Namespace ns, final Manager m) { - if (!m.isRegistered()) { - System.err.println("User is not registered."); - return 1; - } - - String name = ns.getString("name"); - String avatarPath = ns.getString("avatar"); + public void handleCommand(final Namespace ns, final Manager m) throws CommandException { + var name = ns.getString("name"); + var about = ns.getString("about"); + var aboutEmoji = ns.getString("about_emoji"); + var avatarPath = ns.getString("avatar"); boolean removeAvatar = ns.getBoolean("remove_avatar"); - try { - File avatarFile = removeAvatar ? null : new File(avatarPath); - m.setProfile(name, avatarFile); - } catch (IOException e) { - System.err.println("UpdateAccount error: " + e.getMessage()); - return 3; - } + Optional avatarFile = removeAvatar + ? Optional.absent() + : avatarPath == null ? null : Optional.of(new File(avatarPath)); - return 0; + try { + m.setProfile(name, about, aboutEmoji, avatarFile); + } catch (IOException e) { + throw new IOErrorException("Update profile error: " + e.getMessage()); + } } } diff --git a/src/main/java/org/asamk/signal/commands/UploadStickerPackCommand.java b/src/main/java/org/asamk/signal/commands/UploadStickerPackCommand.java index f9f5d95b..1ed14e21 100644 --- a/src/main/java/org/asamk/signal/commands/UploadStickerPackCommand.java +++ b/src/main/java/org/asamk/signal/commands/UploadStickerPackCommand.java @@ -3,14 +3,22 @@ package org.asamk.signal.commands; import net.sourceforge.argparse4j.inf.Namespace; import net.sourceforge.argparse4j.inf.Subparser; +import org.asamk.signal.PlainTextWriterImpl; +import org.asamk.signal.commands.exceptions.CommandException; +import org.asamk.signal.commands.exceptions.IOErrorException; +import org.asamk.signal.commands.exceptions.UserErrorException; import org.asamk.signal.manager.Manager; import org.asamk.signal.manager.StickerPackInvalidException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import java.io.File; import java.io.IOException; public class UploadStickerPackCommand implements LocalCommand { + private final static Logger logger = LoggerFactory.getLogger(UploadStickerPackCommand.class); + @Override public void attachToSubparser(final Subparser subparser) { subparser.addArgument("path") @@ -18,18 +26,17 @@ public class UploadStickerPackCommand implements LocalCommand { } @Override - public int handleCommand(final Namespace ns, final Manager m) { + public void handleCommand(final Namespace ns, final Manager m) throws CommandException { + final var writer = new PlainTextWriterImpl(System.out); + var path = new File(ns.getString("path")); + try { - File path = new File(ns.getString("path")); - String url = m.uploadStickerPack(path); - System.out.println(url); - return 0; + var url = m.uploadStickerPack(path); + writer.println("{}", url); } catch (IOException e) { - System.err.println("Upload error: " + e.getMessage()); - return 3; + throw new IOErrorException("Upload error: " + e.getMessage()); } catch (StickerPackInvalidException e) { - System.err.println("Invalid sticker pack: " + e.getMessage()); - return 3; + throw new UserErrorException("Invalid sticker pack: " + e.getMessage()); } } } diff --git a/src/main/java/org/asamk/signal/commands/VerifyCommand.java b/src/main/java/org/asamk/signal/commands/VerifyCommand.java index b6ad100b..151d966a 100644 --- a/src/main/java/org/asamk/signal/commands/VerifyCommand.java +++ b/src/main/java/org/asamk/signal/commands/VerifyCommand.java @@ -3,12 +3,18 @@ package org.asamk.signal.commands; import net.sourceforge.argparse4j.inf.Namespace; import net.sourceforge.argparse4j.inf.Subparser; -import org.asamk.signal.manager.Manager; +import org.asamk.signal.commands.exceptions.CommandException; +import org.asamk.signal.commands.exceptions.IOErrorException; +import org.asamk.signal.commands.exceptions.UnexpectedErrorException; +import org.asamk.signal.commands.exceptions.UserErrorException; +import org.asamk.signal.manager.RegistrationManager; +import org.whispersystems.signalservice.api.KeyBackupServicePinException; +import org.whispersystems.signalservice.api.KeyBackupSystemNoDataException; import org.whispersystems.signalservice.internal.push.LockedException; import java.io.IOException; -public class VerifyCommand implements LocalCommand { +public class VerifyCommand implements RegistrationCommand { @Override public void attachToSubparser(final Subparser subparser) { @@ -17,24 +23,24 @@ public class VerifyCommand implements LocalCommand { } @Override - public int handleCommand(final Namespace ns, final Manager m) { - if (m.isRegistered()) { - System.err.println("User registration is already verified"); - return 1; - } + public void handleCommand(final Namespace ns, final RegistrationManager m) throws CommandException { + var verificationCode = ns.getString("verificationCode"); + var pin = ns.getString("pin"); + try { - String verificationCode = ns.getString("verificationCode"); - String pin = ns.getString("pin"); - m.verifyAccount(verificationCode, pin); - return 0; + final var manager = m.verifyAccount(verificationCode, pin); + manager.close(); } catch (LockedException e) { - System.err.println("Verification failed! This number is locked with a pin. Hours remaining until reset: " - + (e.getTimeRemaining() / 1000 / 60 / 60)); - System.err.println("Use '--pin PIN_CODE' to specify the registration lock PIN"); - return 3; + throw new UserErrorException( + "Verification failed! This number is locked with a pin. Hours remaining until reset: " + + (e.getTimeRemaining() / 1000 / 60 / 60) + + "\nUse '--pin PIN_CODE' to specify the registration lock PIN"); + } catch (KeyBackupServicePinException e) { + throw new UserErrorException("Verification failed! Invalid pin, tries remaining: " + e.getTriesRemaining()); + } catch (KeyBackupSystemNoDataException e) { + throw new UnexpectedErrorException("Verification failed! No KBS data."); } catch (IOException e) { - System.err.println("Verify error: " + e.getMessage()); - return 3; + throw new IOErrorException("Verify error: " + e.getMessage()); } } } diff --git a/src/main/java/org/asamk/signal/commands/exceptions/CommandException.java b/src/main/java/org/asamk/signal/commands/exceptions/CommandException.java new file mode 100644 index 00000000..c82ef542 --- /dev/null +++ b/src/main/java/org/asamk/signal/commands/exceptions/CommandException.java @@ -0,0 +1,8 @@ +package org.asamk.signal.commands.exceptions; + +public class CommandException extends Exception { + + public CommandException(final String message) { + super(message); + } +} diff --git a/src/main/java/org/asamk/signal/commands/exceptions/IOErrorException.java b/src/main/java/org/asamk/signal/commands/exceptions/IOErrorException.java new file mode 100644 index 00000000..e405600c --- /dev/null +++ b/src/main/java/org/asamk/signal/commands/exceptions/IOErrorException.java @@ -0,0 +1,8 @@ +package org.asamk.signal.commands.exceptions; + +public final class IOErrorException extends CommandException { + + public IOErrorException(final String message) { + super(message); + } +} diff --git a/src/main/java/org/asamk/signal/commands/exceptions/UnexpectedErrorException.java b/src/main/java/org/asamk/signal/commands/exceptions/UnexpectedErrorException.java new file mode 100644 index 00000000..b6f231df --- /dev/null +++ b/src/main/java/org/asamk/signal/commands/exceptions/UnexpectedErrorException.java @@ -0,0 +1,8 @@ +package org.asamk.signal.commands.exceptions; + +public final class UnexpectedErrorException extends CommandException { + + public UnexpectedErrorException(final String message) { + super(message); + } +} diff --git a/src/main/java/org/asamk/signal/commands/exceptions/UntrustedKeyErrorException.java b/src/main/java/org/asamk/signal/commands/exceptions/UntrustedKeyErrorException.java new file mode 100644 index 00000000..c215f414 --- /dev/null +++ b/src/main/java/org/asamk/signal/commands/exceptions/UntrustedKeyErrorException.java @@ -0,0 +1,8 @@ +package org.asamk.signal.commands.exceptions; + +public final class UntrustedKeyErrorException extends CommandException { + + public UntrustedKeyErrorException(final String message) { + super(message); + } +} diff --git a/src/main/java/org/asamk/signal/commands/exceptions/UserErrorException.java b/src/main/java/org/asamk/signal/commands/exceptions/UserErrorException.java new file mode 100644 index 00000000..84e957cc --- /dev/null +++ b/src/main/java/org/asamk/signal/commands/exceptions/UserErrorException.java @@ -0,0 +1,8 @@ +package org.asamk.signal.commands.exceptions; + +public final class UserErrorException extends CommandException { + + public UserErrorException(final String message) { + super(message); + } +} diff --git a/src/main/java/org/asamk/signal/dbus/DbusSignalImpl.java b/src/main/java/org/asamk/signal/dbus/DbusSignalImpl.java index cbb72835..6b22029b 100644 --- a/src/main/java/org/asamk/signal/dbus/DbusSignalImpl.java +++ b/src/main/java/org/asamk/signal/dbus/DbusSignalImpl.java @@ -1,24 +1,28 @@ package org.asamk.signal.dbus; import org.asamk.Signal; +import org.asamk.signal.BaseConfig; import org.asamk.signal.manager.AttachmentInvalidException; -import org.asamk.signal.manager.GroupId; -import org.asamk.signal.manager.GroupNotFoundException; import org.asamk.signal.manager.Manager; -import org.asamk.signal.manager.NotAGroupMemberException; -import org.asamk.signal.storage.groups.GroupInfo; +import org.asamk.signal.manager.groups.GroupId; +import org.asamk.signal.manager.groups.GroupInviteLinkUrl; +import org.asamk.signal.manager.groups.GroupNotFoundException; +import org.asamk.signal.manager.groups.NotAGroupMemberException; import org.asamk.signal.util.ErrorUtils; import org.freedesktop.dbus.exceptions.DBusExecutionException; -import org.whispersystems.libsignal.util.Pair; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.groupsv2.GroupLinkNotActiveException; import org.whispersystems.signalservice.api.messages.SendMessageResult; import org.whispersystems.signalservice.api.push.SignalServiceAddress; import org.whispersystems.signalservice.api.util.InvalidNumberException; +import java.io.File; import java.io.IOException; import java.util.ArrayList; -import java.util.Collections; import java.util.List; +import java.util.Objects; import java.util.stream.Collectors; +import java.util.stream.Stream; public class DbusSignalImpl implements Signal { @@ -40,23 +44,44 @@ public class DbusSignalImpl implements Signal { @Override public long sendMessage(final String message, final List attachments, final String recipient) { - List recipients = new ArrayList<>(1); + var recipients = new ArrayList(1); recipients.add(recipient); return sendMessage(message, attachments, recipients); } + private static void checkSendMessageResult(long timestamp, SendMessageResult result) throws DBusExecutionException { + var error = ErrorUtils.getErrorMessageFromSendMessageResult(result); + + if (error == null) { + return; + } + + final var message = timestamp + "\nFailed to send message:\n" + error + '\n'; + + if (result.getIdentityFailure() != null) { + throw new Error.UntrustedIdentity(message); + } else { + throw new Error.Failure(message); + } + } + private static void checkSendMessageResults( long timestamp, List results ) throws DBusExecutionException { - List errors = ErrorUtils.getErrorMessagesFromSendMessageResults(results); + if (results.size() == 1) { + checkSendMessageResult(timestamp, results.get(0)); + return; + } + + var errors = ErrorUtils.getErrorMessagesFromSendMessageResults(results); if (errors.size() == 0) { return; } - StringBuilder message = new StringBuilder(); + var message = new StringBuilder(); message.append(timestamp).append('\n'); message.append("Failed to send (some) messages:\n"); - for (String error : errors) { + for (var error : errors) { message.append(error).append('\n'); } @@ -66,7 +91,7 @@ public class DbusSignalImpl implements Signal { @Override public long sendMessage(final String message, final List attachments, final List recipients) { try { - final Pair> results = m.sendMessage(message, attachments, recipients); + final var results = m.sendMessage(message, attachments, recipients); checkSendMessageResults(results.first(), results.second()); return results.first(); } catch (InvalidNumberException e) { @@ -78,10 +103,88 @@ public class DbusSignalImpl implements Signal { } } + @Override + public long sendRemoteDeleteMessage( + final long targetSentTimestamp, final String recipient + ) { + var recipients = new ArrayList(1); + recipients.add(recipient); + return sendRemoteDeleteMessage(targetSentTimestamp, recipients); + } + + @Override + public long sendRemoteDeleteMessage( + final long targetSentTimestamp, final List recipients + ) { + try { + final var results = m.sendRemoteDeleteMessage(targetSentTimestamp, recipients); + checkSendMessageResults(results.first(), results.second()); + return results.first(); + } catch (IOException e) { + throw new Error.Failure(e.getMessage()); + } catch (InvalidNumberException e) { + throw new Error.InvalidNumber(e.getMessage()); + } + } + + @Override + public long sendGroupRemoteDeleteMessage( + final long targetSentTimestamp, final byte[] groupId + ) { + try { + final var results = m.sendGroupRemoteDeleteMessage(targetSentTimestamp, GroupId.unknownVersion(groupId)); + checkSendMessageResults(results.first(), results.second()); + return results.first(); + } catch (IOException e) { + throw new Error.Failure(e.getMessage()); + } catch (GroupNotFoundException | NotAGroupMemberException e) { + throw new Error.GroupNotFound(e.getMessage()); + } + } + + @Override + public long sendMessageReaction( + final String emoji, final boolean remove, final String targetAuthor, final long targetSentTimestamp, final String recipient + ) { + var recipients = new ArrayList(1); + recipients.add(recipient); + return sendMessageReaction(emoji, remove, targetAuthor, targetSentTimestamp, recipients); + } + + @Override + public long sendMessageReaction( + final String emoji, final boolean remove, final String targetAuthor, final long targetSentTimestamp, final List recipients + ) { + try { + final var results = m.sendMessageReaction(emoji, remove, targetAuthor, targetSentTimestamp, recipients); + checkSendMessageResults(results.first(), results.second()); + return results.first(); + } catch (InvalidNumberException e) { + throw new Error.InvalidNumber(e.getMessage()); + } catch (IOException e) { + throw new Error.Failure(e.getMessage()); + } + } + + @Override + public long sendNoteToSelfMessage( + final String message, final List attachments + ) throws Error.AttachmentInvalid, Error.Failure, Error.UntrustedIdentity { + try { + final var results = m.sendSelfMessage(message, attachments); + checkSendMessageResult(results.first(), results.second()); + return results.first(); + } catch (AttachmentInvalidException e) { + throw new Error.AttachmentInvalid(e.getMessage()); + } catch (IOException e) { + throw new Error.Failure(e.getMessage()); + } + } + @Override public void sendEndSessionMessage(final List recipients) { try { - final Pair> results = m.sendEndSessionMessage(recipients); + final var results = m.sendEndSessionMessage(recipients); checkSendMessageResults(results.first(), results.second()); } catch (IOException e) { throw new Error.Failure(e.getMessage()); @@ -93,9 +196,7 @@ public class DbusSignalImpl implements Signal { @Override public long sendGroupMessage(final String message, final List attachments, final byte[] groupId) { try { - Pair> results = m.sendGroupMessage(message, - attachments, - GroupId.unknownVersion(groupId)); + var results = m.sendGroupMessage(message, attachments, GroupId.unknownVersion(groupId)); checkSendMessageResults(results.first(), results.second()); return results.first(); } catch (IOException e) { @@ -107,11 +208,30 @@ public class DbusSignalImpl implements Signal { } } + @Override + public long sendGroupMessageReaction( + final String emoji, final boolean remove, final String targetAuthor, final long targetSentTimestamp, final byte[] groupId + ) { + try { + final var results = m.sendGroupMessageReaction(emoji, remove, targetAuthor, targetSentTimestamp, GroupId.unknownVersion(groupId)); + checkSendMessageResults(results.first(), results.second()); + return results.first(); + } catch (IOException e) { + throw new Error.Failure(e.getMessage()); + } catch (InvalidNumberException e) { + throw new Error.InvalidNumber(e.getMessage()); + } catch (GroupNotFoundException | NotAGroupMemberException e) { + throw new Error.GroupNotFound(e.getMessage()); + } + } + + // Since contact names might be empty if not defined, also potentially return + // the profile name @Override public String getContactName(final String number) { try { - return m.getContactName(number); - } catch (InvalidNumberException e) { + return m.getContactOrProfileName(number); + } catch (Exception e) { throw new Error.InvalidNumber(e.getMessage()); } } @@ -145,9 +265,9 @@ public class DbusSignalImpl implements Signal { @Override public List getGroupIds() { - List groups = m.getGroups(); - List ids = new ArrayList<>(groups.size()); - for (GroupInfo group : groups) { + var groups = m.getGroups(); + var ids = new ArrayList(groups.size()); + for (var group : groups) { ids.add(group.getGroupId().serialize()); } return ids; @@ -155,7 +275,7 @@ public class DbusSignalImpl implements Signal { @Override public String getGroupName(final byte[] groupId) { - GroupInfo group = m.getGroup(GroupId.unknownVersion(groupId)); + var group = m.getGroup(GroupId.unknownVersion(groupId)); if (group == null) { return ""; } else { @@ -165,9 +285,9 @@ public class DbusSignalImpl implements Signal { @Override public List getGroupMembers(final byte[] groupId) { - GroupInfo group = m.getGroup(GroupId.unknownVersion(groupId)); + var group = m.getGroup(GroupId.unknownVersion(groupId)); if (group == null) { - return Collections.emptyList(); + return List.of(); } else { return group.getMembers() .stream() @@ -192,9 +312,10 @@ public class DbusSignalImpl implements Signal { if (avatar.isEmpty()) { avatar = null; } - final Pair> results = m.updateGroup(groupId == null - ? null - : GroupId.unknownVersion(groupId), name, members, avatar); + final var results = m.updateGroup(groupId == null ? null : GroupId.unknownVersion(groupId), + name, + members, + avatar == null ? null : new File(avatar)); checkSendMessageResults(0, results.second()); return results.first().serialize(); } catch (IOException e) { @@ -212,4 +333,127 @@ public class DbusSignalImpl implements Signal { public boolean isRegistered() { return true; } + + @Override + public void updateProfile( + final String name, + final String about, + final String aboutEmoji, + String avatarPath, + final boolean removeAvatar + ) { + try { + if (avatarPath.isEmpty()) { + avatarPath = null; + } + Optional avatarFile = removeAvatar + ? Optional.absent() + : avatarPath == null ? null : Optional.of(new File(avatarPath)); + m.setProfile(name, about, aboutEmoji, avatarFile); + } catch (IOException e) { + throw new Error.Failure(e.getMessage()); + } + } + + // Provide option to query a version string in order to react on potential + // future interface changes + @Override + public String version() { + return BaseConfig.PROJECT_VERSION; + } + + // Create a unique list of Numbers from Identities and Contacts to really get + // all numbers the system knows + @Override + public List listNumbers() { + return Stream.concat(m.getIdentities().stream().map(i -> i.getAddress().getNumber().orNull()), + m.getContacts().stream().map(c -> c.number)) + .filter(Objects::nonNull) + .distinct() + .collect(Collectors.toList()); + } + + @Override + public List getContactNumber(final String name) { + // Contact names have precedence. + var numbers = new ArrayList(); + var contacts = m.getContacts(); + for (var c : contacts) { + if (c.name != null && c.name.equals(name)) { + numbers.add(c.number); + } + } + // Try profiles if no contact name was found + for (var identity : m.getIdentities()) { + final var address = identity.getAddress(); + var number = address.getNumber().orNull(); + if (number != null) { + var profile = m.getRecipientProfile(address); + if (profile != null && profile.getDisplayName().equals(name)) { + numbers.add(number); + } + } + } + return numbers; + } + + @Override + public void quitGroup(final byte[] groupId) { + var group = GroupId.unknownVersion(groupId); + try { + m.sendQuitGroupMessage(group); + } catch (GroupNotFoundException | NotAGroupMemberException e) { + throw new Error.GroupNotFound(e.getMessage()); + } catch (IOException e) { + throw new Error.Failure(e.getMessage()); + } + } + + @Override + public void joinGroup(final String groupLink) { + try { + final var linkUrl = GroupInviteLinkUrl.fromUri(groupLink); + if (linkUrl == null) { + throw new Error.Failure("Group link is invalid:"); + } + m.joinGroup(linkUrl); + } catch (GroupInviteLinkUrl.InvalidGroupLinkException | GroupLinkNotActiveException e) { + throw new Error.Failure("Group link is invalid: " + e.getMessage()); + } catch (GroupInviteLinkUrl.UnknownGroupLinkVersionException e) { + throw new Error.Failure("Group link was created with an incompatible version: " + e.getMessage()); + } catch (IOException e) { + throw new Error.Failure(e.getMessage()); + } + } + + @Override + public boolean isContactBlocked(final String number) { + var contacts = m.getContacts(); + for (var c : contacts) { + if (c.number.equals(number)) { + return c.blocked; + } + } + return false; + } + + @Override + public boolean isGroupBlocked(final byte[] groupId) { + var group = m.getGroup(GroupId.unknownVersion(groupId)); + if (group == null) { + return false; + } else { + return group.isBlocked(); + } + } + + @Override + public boolean isMember(final byte[] groupId) { + var group = m.getGroup(GroupId.unknownVersion(groupId)); + if (group == null) { + return false; + } else { + return group.isMember(m.getSelfAddress()); + } + } } diff --git a/src/main/java/org/asamk/signal/json/JsonAttachment.java b/src/main/java/org/asamk/signal/json/JsonAttachment.java index 1949171a..a96fc534 100644 --- a/src/main/java/org/asamk/signal/json/JsonAttachment.java +++ b/src/main/java/org/asamk/signal/json/JsonAttachment.java @@ -1,31 +1,43 @@ package org.asamk.signal.json; +import com.fasterxml.jackson.annotation.JsonProperty; + import org.whispersystems.signalservice.api.messages.SignalServiceAttachment; -import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentPointer; class JsonAttachment { - String contentType; - String filename; - String id; - int size; + @JsonProperty + final String contentType; + + @JsonProperty + final String filename; + + @JsonProperty + final String id; + + @JsonProperty + final Long size; JsonAttachment(SignalServiceAttachment attachment) { this.contentType = attachment.getContentType(); - final SignalServiceAttachmentPointer pointer = attachment.asPointer(); if (attachment.isPointer()) { - this.id = String.valueOf(pointer.getRemoteId()); - if (pointer.getFileName().isPresent()) { - this.filename = pointer.getFileName().get(); - } - if (pointer.getSize().isPresent()) { - this.size = pointer.getSize().get(); - } + final var pointer = attachment.asPointer(); + this.id = pointer.getRemoteId().toString(); + this.filename = pointer.getFileName().orNull(); + this.size = pointer.getSize().transform(Integer::longValue).orNull(); + } else { + final var stream = attachment.asStream(); + this.id = null; + this.filename = stream.getFileName().orNull(); + this.size = stream.getLength(); } } JsonAttachment(String filename) { this.filename = filename; + this.contentType = null; + this.id = null; + this.size = null; } } diff --git a/src/main/java/org/asamk/signal/json/JsonCallMessage.java b/src/main/java/org/asamk/signal/json/JsonCallMessage.java index c1b1d443..885c38a4 100644 --- a/src/main/java/org/asamk/signal/json/JsonCallMessage.java +++ b/src/main/java/org/asamk/signal/json/JsonCallMessage.java @@ -1,5 +1,8 @@ package org.asamk.signal.json; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + import org.whispersystems.signalservice.api.messages.calls.AnswerMessage; import org.whispersystems.signalservice.api.messages.calls.BusyMessage; import org.whispersystems.signalservice.api.messages.calls.HangupMessage; @@ -11,27 +14,31 @@ import java.util.List; class JsonCallMessage { - OfferMessage offerMessage; - AnswerMessage answerMessage; - BusyMessage busyMessage; - HangupMessage hangupMessage; - List iceUpdateMessages; + @JsonProperty + @JsonInclude(JsonInclude.Include.NON_NULL) + final OfferMessage offerMessage; + + @JsonProperty + @JsonInclude(JsonInclude.Include.NON_NULL) + final AnswerMessage answerMessage; + + @JsonProperty + @JsonInclude(JsonInclude.Include.NON_NULL) + final BusyMessage busyMessage; + + @JsonProperty + @JsonInclude(JsonInclude.Include.NON_NULL) + final HangupMessage hangupMessage; + + @JsonProperty + @JsonInclude(JsonInclude.Include.NON_NULL) + final List iceUpdateMessages; JsonCallMessage(SignalServiceCallMessage callMessage) { - if (callMessage.getOfferMessage().isPresent()) { - this.offerMessage = callMessage.getOfferMessage().get(); - } - if (callMessage.getAnswerMessage().isPresent()) { - this.answerMessage = callMessage.getAnswerMessage().get(); - } - if (callMessage.getBusyMessage().isPresent()) { - this.busyMessage = callMessage.getBusyMessage().get(); - } - if (callMessage.getHangupMessage().isPresent()) { - this.hangupMessage = callMessage.getHangupMessage().get(); - } - if (callMessage.getIceUpdateMessages().isPresent()) { - this.iceUpdateMessages = callMessage.getIceUpdateMessages().get(); - } + this.offerMessage = callMessage.getOfferMessage().orNull(); + this.answerMessage = callMessage.getAnswerMessage().orNull(); + this.busyMessage = callMessage.getBusyMessage().orNull(); + this.hangupMessage = callMessage.getHangupMessage().orNull(); + this.iceUpdateMessages = callMessage.getIceUpdateMessages().orNull(); } } diff --git a/src/main/java/org/asamk/signal/json/JsonContactAddress.java b/src/main/java/org/asamk/signal/json/JsonContactAddress.java new file mode 100644 index 00000000..712dd4f3 --- /dev/null +++ b/src/main/java/org/asamk/signal/json/JsonContactAddress.java @@ -0,0 +1,48 @@ +package org.asamk.signal.json; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import org.asamk.signal.util.Util; +import org.whispersystems.signalservice.api.messages.shared.SharedContact; + +public class JsonContactAddress { + + @JsonProperty + private final SharedContact.PostalAddress.Type type; + + @JsonProperty + private final String label; + + @JsonProperty + private final String street; + + @JsonProperty + private final String pobox; + + @JsonProperty + private final String neighborhood; + + @JsonProperty + private final String city; + + @JsonProperty + private final String region; + + @JsonProperty + private final String postcode; + + @JsonProperty + private final String country; + + public JsonContactAddress(SharedContact.PostalAddress address) { + type = address.getType(); + label = Util.getStringIfNotBlank(address.getLabel()); + street = Util.getStringIfNotBlank(address.getStreet()); + pobox = Util.getStringIfNotBlank(address.getPobox()); + neighborhood = Util.getStringIfNotBlank(address.getNeighborhood()); + city = Util.getStringIfNotBlank(address.getCity()); + region = Util.getStringIfNotBlank(address.getRegion()); + postcode = Util.getStringIfNotBlank(address.getPostcode()); + country = Util.getStringIfNotBlank(address.getCountry()); + } +} diff --git a/src/main/java/org/asamk/signal/json/JsonContactAvatar.java b/src/main/java/org/asamk/signal/json/JsonContactAvatar.java new file mode 100644 index 00000000..3ed55f6f --- /dev/null +++ b/src/main/java/org/asamk/signal/json/JsonContactAvatar.java @@ -0,0 +1,19 @@ +package org.asamk.signal.json; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import org.whispersystems.signalservice.api.messages.shared.SharedContact; + +public class JsonContactAvatar { + + @JsonProperty + private final JsonAttachment attachment; + + @JsonProperty + private final boolean isProfile; + + public JsonContactAvatar(SharedContact.Avatar avatar) { + attachment = new JsonAttachment(avatar.getAttachment()); + isProfile = avatar.isProfile(); + } +} diff --git a/src/main/java/org/asamk/signal/json/JsonContactEmail.java b/src/main/java/org/asamk/signal/json/JsonContactEmail.java new file mode 100644 index 00000000..070cfb72 --- /dev/null +++ b/src/main/java/org/asamk/signal/json/JsonContactEmail.java @@ -0,0 +1,24 @@ +package org.asamk.signal.json; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import org.asamk.signal.util.Util; +import org.whispersystems.signalservice.api.messages.shared.SharedContact; + +public class JsonContactEmail { + + @JsonProperty + private final String value; + + @JsonProperty + private final SharedContact.Email.Type type; + + @JsonProperty + private final String label; + + public JsonContactEmail(SharedContact.Email email) { + value = email.getValue(); + type = email.getType(); + label = Util.getStringIfNotBlank(email.getLabel()); + } +} diff --git a/src/main/java/org/asamk/signal/json/JsonContactName.java b/src/main/java/org/asamk/signal/json/JsonContactName.java new file mode 100644 index 00000000..9da27825 --- /dev/null +++ b/src/main/java/org/asamk/signal/json/JsonContactName.java @@ -0,0 +1,36 @@ +package org.asamk.signal.json; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import org.asamk.signal.util.Util; +import org.whispersystems.signalservice.api.messages.shared.SharedContact; + +public class JsonContactName { + + @JsonProperty + private final String display; + + @JsonProperty + private final String given; + + @JsonProperty + private final String family; + + @JsonProperty + private final String prefix; + + @JsonProperty + private final String suffix; + + @JsonProperty + private final String middle; + + public JsonContactName(SharedContact.Name name) { + display = Util.getStringIfNotBlank(name.getDisplay()); + given = Util.getStringIfNotBlank(name.getGiven()); + family = Util.getStringIfNotBlank(name.getFamily()); + prefix = Util.getStringIfNotBlank(name.getPrefix()); + suffix = Util.getStringIfNotBlank(name.getSuffix()); + middle = Util.getStringIfNotBlank(name.getMiddle()); + } +} diff --git a/src/main/java/org/asamk/signal/json/JsonContactPhone.java b/src/main/java/org/asamk/signal/json/JsonContactPhone.java new file mode 100644 index 00000000..fce75843 --- /dev/null +++ b/src/main/java/org/asamk/signal/json/JsonContactPhone.java @@ -0,0 +1,24 @@ +package org.asamk.signal.json; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import org.asamk.signal.util.Util; +import org.whispersystems.signalservice.api.messages.shared.SharedContact; + +public class JsonContactPhone { + + @JsonProperty + private final String value; + + @JsonProperty + private final SharedContact.Phone.Type type; + + @JsonProperty + private final String label; + + public JsonContactPhone(SharedContact.Phone phone) { + value = phone.getValue(); + type = phone.getType(); + label = Util.getStringIfNotBlank(phone.getLabel()); + } +} diff --git a/src/main/java/org/asamk/signal/json/JsonDataMessage.java b/src/main/java/org/asamk/signal/json/JsonDataMessage.java index 9a975864..a089ae96 100644 --- a/src/main/java/org/asamk/signal/json/JsonDataMessage.java +++ b/src/main/java/org/asamk/signal/json/JsonDataMessage.java @@ -1,5 +1,8 @@ package org.asamk.signal.json; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + import org.asamk.Signal; import org.asamk.signal.manager.Manager; import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage; @@ -27,9 +30,46 @@ class JsonReaction { class JsonDataMessage { - long timestamp; - String message; - int expiresInSeconds; + @JsonProperty + final long timestamp; + + @JsonProperty + final String message; + + @JsonProperty + final Integer expiresInSeconds; + + @JsonProperty + @JsonInclude(JsonInclude.Include.NON_NULL) + final Boolean viewOnce; + + @JsonProperty + @JsonInclude(JsonInclude.Include.NON_NULL) + final JsonReaction reaction; + + @JsonProperty + @JsonInclude(JsonInclude.Include.NON_NULL) + final JsonQuote quote; + + @JsonProperty + @JsonInclude(JsonInclude.Include.NON_NULL) + final List mentions; + + @JsonProperty + @JsonInclude(JsonInclude.Include.NON_NULL) + final List attachments; + + @JsonProperty + @JsonInclude(JsonInclude.Include.NON_NULL) + final JsonSticker sticker; + + @JsonProperty + @JsonInclude(JsonInclude.Include.NON_NULL) + final JsonRemoteDelete remoteDelete; + + @JsonProperty + @JsonInclude(JsonInclude.Include.NON_NULL) + final List contacts; JsonReaction reaction; JsonQuote quote; @@ -38,28 +78,33 @@ class JsonDataMessage { JsonGroupInfo groupInfo; JsonReaction reaction; SignalServiceDataMessage.Quote quote; + @JsonProperty + @JsonInclude(JsonInclude.Include.NON_NULL) + final JsonGroupInfo groupInfo; JsonDataMessage(SignalServiceDataMessage dataMessage, Manager m) { this.timestamp = dataMessage.getTimestamp(); if (dataMessage.getGroupContext().isPresent()) { - if (dataMessage.getGroupContext().get().getGroupV1().isPresent()) { - SignalServiceGroup groupInfo = dataMessage.getGroupContext().get().getGroupV1().get(); + final var groupContext = dataMessage.getGroupContext().get(); + if (groupContext.getGroupV1().isPresent()) { + var groupInfo = groupContext.getGroupV1().get(); this.groupInfo = new JsonGroupInfo(groupInfo); - } else if (dataMessage.getGroupContext().get().getGroupV2().isPresent()) { - SignalServiceGroupV2 groupInfo = dataMessage.getGroupContext().get().getGroupV2().get(); + } else if (groupContext.getGroupV2().isPresent()) { + var groupInfo = groupContext.getGroupV2().get(); this.groupInfo = new JsonGroupInfo(groupInfo); + } else { + this.groupInfo = null; } + } else { + this.groupInfo = null; } - if (dataMessage.getBody().isPresent()) { - this.message = dataMessage.getBody().get(); - } + this.message = dataMessage.getBody().orNull(); this.expiresInSeconds = dataMessage.getExpiresInSeconds(); - if (dataMessage.getReaction().isPresent()) { - this.reaction = new JsonReaction(dataMessage.getReaction().get(), m); - } - if (dataMessage.getQuote().isPresent()) { - this.quote = new JsonQuote(dataMessage.getQuote().get(), m); - } + this.viewOnce = dataMessage.isViewOnce(); + this.reaction = dataMessage.getReaction().isPresent() + ? new JsonReaction(dataMessage.getReaction().get(), m) + : null; + this.quote = dataMessage.getQuote().isPresent() ? new JsonQuote(dataMessage.getQuote().get(), m) : null; if (dataMessage.getMentions().isPresent()) { this.mentions = dataMessage.getMentions() .get() @@ -69,6 +114,8 @@ class JsonDataMessage { } else { this.mentions = List.of(); } + remoteDelete = dataMessage.getRemoteDelete().isPresent() ? new JsonRemoteDelete(dataMessage.getRemoteDelete() + .get()) : null; if (dataMessage.getAttachments().isPresent()) { this.attachments = dataMessage.getAttachments() .get() @@ -78,6 +125,7 @@ class JsonDataMessage { } else { this.attachments = List.of(); } +<<<<<<< HEAD if (dataMessage.getReaction().isPresent()) { final SignalServiceDataMessage.Reaction reaction = dataMessage.getReaction().get(); this.reaction = new JsonReaction(reaction); @@ -89,6 +137,20 @@ class JsonDataMessage { this.emoji = ""; this.targetAuthor = ""; this.targetTimestamp = 0; +======= + this.sticker = dataMessage.getSticker().isPresent() ? new JsonSticker(dataMessage.getSticker().get()) : null; + + if (dataMessage.getSharedContacts().isPresent()) { + this.contacts = dataMessage.getSharedContacts() + .get() + .stream() + .map(JsonSharedContact::new) + .collect(Collectors.toList()); + } else { + this.contacts = List.of(); + } + } +>>>>>>> upstream/master } @@ -107,10 +169,15 @@ class JsonDataMessage { public JsonDataMessage(Signal.MessageReceived messageReceived) { timestamp = messageReceived.getTimestamp(); message = messageReceived.getMessage(); - groupInfo = new JsonGroupInfo(messageReceived.getGroupId()); - reaction = null; // TODO Replace these 3 with the proper commands + groupInfo = messageReceived.getGroupId().length > 0 ? new JsonGroupInfo(messageReceived.getGroupId()) : null; + expiresInSeconds = null; + viewOnce = null; + remoteDelete = null; + reaction = null; // TODO Replace these 5 with the proper commands quote = null; mentions = null; + sticker = null; + contacts = null; attachments = messageReceived.getAttachments().stream().map(JsonAttachment::new).collect(Collectors.toList()); } // i don't understand what SyncMessages are so i'm going to ignore them @@ -118,10 +185,15 @@ class JsonDataMessage { public JsonDataMessage(Signal.SyncMessageReceived messageReceived) { timestamp = messageReceived.getTimestamp(); message = messageReceived.getMessage(); - groupInfo = new JsonGroupInfo(messageReceived.getGroupId()); - reaction = null; // TODO Replace these 3 with the proper commands + groupInfo = messageReceived.getGroupId().length > 0 ? new JsonGroupInfo(messageReceived.getGroupId()) : null; + expiresInSeconds = null; + viewOnce = null; + remoteDelete = null; + reaction = null; // TODO Replace these 5 with the proper commands quote = null; mentions = null; + sticker = null; + contacts = null; attachments = messageReceived.getAttachments().stream().map(JsonAttachment::new).collect(Collectors.toList()); } } diff --git a/src/main/java/org/asamk/signal/json/JsonError.java b/src/main/java/org/asamk/signal/json/JsonError.java index 29d85c8b..d8b3e5f5 100644 --- a/src/main/java/org/asamk/signal/json/JsonError.java +++ b/src/main/java/org/asamk/signal/json/JsonError.java @@ -1,8 +1,11 @@ package org.asamk.signal.json; +import com.fasterxml.jackson.annotation.JsonProperty; + public class JsonError { - String message; + @JsonProperty + final String message; public JsonError(Throwable exception) { this.message = exception.getMessage(); diff --git a/src/main/java/org/asamk/signal/json/JsonGroupInfo.java b/src/main/java/org/asamk/signal/json/JsonGroupInfo.java index 9709be20..d79941ed 100644 --- a/src/main/java/org/asamk/signal/json/JsonGroupInfo.java +++ b/src/main/java/org/asamk/signal/json/JsonGroupInfo.java @@ -1,41 +1,59 @@ package org.asamk.signal.json; -import org.asamk.signal.manager.GroupUtils; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +import org.asamk.signal.manager.groups.GroupUtils; import org.whispersystems.signalservice.api.messages.SignalServiceGroup; import org.whispersystems.signalservice.api.messages.SignalServiceGroupV2; import org.whispersystems.signalservice.api.push.SignalServiceAddress; -import org.whispersystems.util.Base64; -import java.util.ArrayList; +import java.util.Base64; import java.util.List; +import java.util.stream.Collectors; class JsonGroupInfo { - String groupId; - List members; - String name; - String type; + @JsonProperty + final String groupId; + + @JsonProperty + final String type; + + @JsonProperty + @JsonInclude(JsonInclude.Include.NON_NULL) + final String name; + + @JsonProperty + @JsonInclude(JsonInclude.Include.NON_NULL) + final List members; JsonGroupInfo(SignalServiceGroup groupInfo) { - this.groupId = Base64.encodeBytes(groupInfo.getGroupId()); - if (groupInfo.getMembers().isPresent()) { - this.members = new ArrayList<>(groupInfo.getMembers().get().size()); - for (SignalServiceAddress address : groupInfo.getMembers().get()) { - this.members.add(address.getLegacyIdentifier()); - } - } - if (groupInfo.getName().isPresent()) { - this.name = groupInfo.getName().get(); - } + this.groupId = Base64.getEncoder().encodeToString(groupInfo.getGroupId()); this.type = groupInfo.getType().toString(); + this.name = groupInfo.getName().orNull(); + if (groupInfo.getMembers().isPresent()) { + this.members = groupInfo.getMembers() + .get() + .stream() + .map(SignalServiceAddress::getLegacyIdentifier) + .collect(Collectors.toList()); + } else { + this.members = null; + } } JsonGroupInfo(SignalServiceGroupV2 groupInfo) { this.groupId = GroupUtils.getGroupIdV2(groupInfo.getMasterKey()).toBase64(); this.type = groupInfo.hasSignedGroupChange() ? "UPDATE" : "DELIVER"; + this.members = null; + this.name = null; } JsonGroupInfo(byte[] groupId) { - this.groupId = Base64.encodeBytes(groupId); + this.groupId = Base64.getEncoder().encodeToString(groupId); + this.type = "DELIVER"; + this.members = null; + this.name = null; } } diff --git a/src/main/java/org/asamk/signal/json/JsonMention.java b/src/main/java/org/asamk/signal/json/JsonMention.java index 302128ed..b6243041 100644 --- a/src/main/java/org/asamk/signal/json/JsonMention.java +++ b/src/main/java/org/asamk/signal/json/JsonMention.java @@ -1,14 +1,21 @@ package org.asamk.signal.json; +import com.fasterxml.jackson.annotation.JsonProperty; + import org.asamk.signal.manager.Manager; import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage; import org.whispersystems.signalservice.api.push.SignalServiceAddress; public class JsonMention { - String name; - int start; - int length; + @JsonProperty + final String name; + + @JsonProperty + final int start; + + @JsonProperty + final int length; JsonMention(SignalServiceDataMessage.Mention mention, Manager m) { this.name = m.resolveSignalServiceAddress(new SignalServiceAddress(mention.getUuid(), null)) diff --git a/src/main/java/org/asamk/signal/json/JsonMessageEnvelope.java b/src/main/java/org/asamk/signal/json/JsonMessageEnvelope.java index 9c796cf0..4e7d911b 100644 --- a/src/main/java/org/asamk/signal/json/JsonMessageEnvelope.java +++ b/src/main/java/org/asamk/signal/json/JsonMessageEnvelope.java @@ -1,15 +1,18 @@ package org.asamk.signal.json; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + import org.asamk.Signal; //import org.whispersystems.signalservice.api.messages.SignalServiceTypingMessage; import org.asamk.signal.manager.Manager; import org.whispersystems.signalservice.api.messages.SignalServiceContent; import org.whispersystems.signalservice.api.messages.SignalServiceEnvelope; -import org.whispersystems.signalservice.api.push.SignalServiceAddress; import java.util.List; public class JsonMessageEnvelope { +<<<<<<< HEAD String source; int sourceDevice; String relay; @@ -19,18 +22,66 @@ public class JsonMessageEnvelope { JsonCallMessage callMessage; JsonReceiptMessage receiptMessage; // String typingAction; +======= + + @JsonProperty + final String source; + + @JsonProperty + final Integer sourceDevice; + + @JsonProperty + @JsonInclude(JsonInclude.Include.NON_NULL) + final String relay; + + @JsonProperty + final long timestamp; + + @JsonProperty + @JsonInclude(JsonInclude.Include.NON_NULL) + final JsonDataMessage dataMessage; + + @JsonProperty + @JsonInclude(JsonInclude.Include.NON_NULL) + final JsonSyncMessage syncMessage; + + @JsonProperty + @JsonInclude(JsonInclude.Include.NON_NULL) + final JsonCallMessage callMessage; + + @JsonProperty + @JsonInclude(JsonInclude.Include.NON_NULL) + final JsonReceiptMessage receiptMessage; + + @JsonProperty + @JsonInclude(JsonInclude.Include.NON_NULL) + final JsonTypingMessage typingMessage; +>>>>>>> upstream/master public JsonMessageEnvelope(SignalServiceEnvelope envelope, SignalServiceContent content, Manager m) { if (!envelope.isUnidentifiedSender() && envelope.hasSource()) { - SignalServiceAddress source = envelope.getSourceAddress(); + var source = envelope.getSourceAddress(); this.source = source.getLegacyIdentifier(); - this.relay = source.getRelay().isPresent() ? source.getRelay().get() : null; + this.sourceDevice = envelope.getSourceDevice(); + this.relay = source.getRelay().orNull(); + } else if (envelope.isUnidentifiedSender() && content != null) { + this.source = content.getSender().getLegacyIdentifier(); + this.sourceDevice = content.getSenderDevice(); + this.relay = null; + } else { + this.source = null; + this.sourceDevice = null; + this.relay = null; } - this.sourceDevice = envelope.getSourceDevice(); this.timestamp = envelope.getTimestamp(); if (envelope.isReceipt()) { this.receiptMessage = JsonReceiptMessage.deliveryReceipt(timestamp, List.of(timestamp)); + } else if (content != null && content.getReceiptMessage().isPresent()) { + this.receiptMessage = new JsonReceiptMessage(content.getReceiptMessage().get()); + } else { + this.receiptMessage = null; } +<<<<<<< HEAD if (content != null) { if (envelope.isUnidentifiedSender()) { this.source = content.getSender().getLegacyIdentifier(); @@ -53,23 +104,56 @@ public class JsonMessageEnvelope { this.typingAction = content.getTypingMessage().get(); } */ } +======= + this.typingMessage = content != null && content.getTypingMessage().isPresent() + ? new JsonTypingMessage(content.getTypingMessage().get()) + : null; + + this.dataMessage = content != null && content.getDataMessage().isPresent() + ? new JsonDataMessage(content.getDataMessage().get(), m) + : null; + this.syncMessage = content != null && content.getSyncMessage().isPresent() + ? new JsonSyncMessage(content.getSyncMessage().get(), m) + : null; + this.callMessage = content != null && content.getCallMessage().isPresent() + ? new JsonCallMessage(content.getCallMessage().get()) + : null; +>>>>>>> upstream/master } public JsonMessageEnvelope(Signal.MessageReceived messageReceived) { source = messageReceived.getSender(); + sourceDevice = null; + relay = null; timestamp = messageReceived.getTimestamp(); + receiptMessage = null; dataMessage = new JsonDataMessage(messageReceived); + syncMessage = null; + callMessage = null; + typingMessage = null; } public JsonMessageEnvelope(Signal.ReceiptReceived receiptReceived) { source = receiptReceived.getSender(); + sourceDevice = null; + relay = null; timestamp = receiptReceived.getTimestamp(); receiptMessage = JsonReceiptMessage.deliveryReceipt(timestamp, List.of(timestamp)); + dataMessage = null; + syncMessage = null; + callMessage = null; + typingMessage = null; } public JsonMessageEnvelope(Signal.SyncMessageReceived messageReceived) { source = messageReceived.getSource(); + sourceDevice = null; + relay = null; timestamp = messageReceived.getTimestamp(); + receiptMessage = null; + dataMessage = null; syncMessage = new JsonSyncMessage(messageReceived); + callMessage = null; + typingMessage = null; } } diff --git a/src/main/java/org/asamk/signal/json/JsonQuote.java b/src/main/java/org/asamk/signal/json/JsonQuote.java index 10cd0bf4..8165bcfa 100644 --- a/src/main/java/org/asamk/signal/json/JsonQuote.java +++ b/src/main/java/org/asamk/signal/json/JsonQuote.java @@ -1,5 +1,8 @@ package org.asamk.signal.json; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + import org.asamk.signal.manager.Manager; import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage; @@ -9,12 +12,21 @@ import java.util.stream.Collectors; public class JsonQuote { - long id; - String author; - String text; + @JsonProperty + final long id; - List mentions; - List attachments; + @JsonProperty + final String author; + + @JsonProperty + final String text; + + @JsonProperty + @JsonInclude(JsonInclude.Include.NON_NULL) + final List mentions; + + @JsonProperty + final List attachments; JsonQuote(SignalServiceDataMessage.Quote quote, Manager m) { this.id = quote.getId(); @@ -26,6 +38,8 @@ public class JsonQuote { .stream() .map(quotedMention -> new JsonMention(quotedMention, m)) .collect(Collectors.toList()); + } else { + this.mentions = null; } if (quote.getAttachments().size() > 0) { diff --git a/src/main/java/org/asamk/signal/json/JsonQuotedAttachment.java b/src/main/java/org/asamk/signal/json/JsonQuotedAttachment.java index bcbbe2a5..f3f809dc 100644 --- a/src/main/java/org/asamk/signal/json/JsonQuotedAttachment.java +++ b/src/main/java/org/asamk/signal/json/JsonQuotedAttachment.java @@ -1,12 +1,21 @@ package org.asamk.signal.json; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage; public class JsonQuotedAttachment { - String contentType; - String filename; - JsonAttachment thumbnail; + @JsonProperty + final String contentType; + + @JsonProperty + final String filename; + + @JsonProperty + @JsonInclude(JsonInclude.Include.NON_NULL) + final JsonAttachment thumbnail; JsonQuotedAttachment(SignalServiceDataMessage.Quote.QuotedAttachment quotedAttachment) { contentType = quotedAttachment.getContentType(); diff --git a/src/main/java/org/asamk/signal/json/JsonReaction.java b/src/main/java/org/asamk/signal/json/JsonReaction.java index 5e978fe0..2fa15b48 100644 --- a/src/main/java/org/asamk/signal/json/JsonReaction.java +++ b/src/main/java/org/asamk/signal/json/JsonReaction.java @@ -1,14 +1,23 @@ package org.asamk.signal.json; +import com.fasterxml.jackson.annotation.JsonProperty; + import org.asamk.signal.manager.Manager; import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage.Reaction; public class JsonReaction { - String emoji; - String targetAuthor; - long targetSentTimestamp; - boolean isRemove; + @JsonProperty + final String emoji; + + @JsonProperty + final String targetAuthor; + + @JsonProperty + final long targetSentTimestamp; + + @JsonProperty + final boolean isRemove; JsonReaction(Reaction reaction, Manager m) { this.emoji = reaction.getEmoji(); diff --git a/src/main/java/org/asamk/signal/json/JsonReceiptMessage.java b/src/main/java/org/asamk/signal/json/JsonReceiptMessage.java index ccd5960b..e32009e1 100644 --- a/src/main/java/org/asamk/signal/json/JsonReceiptMessage.java +++ b/src/main/java/org/asamk/signal/json/JsonReceiptMessage.java @@ -1,25 +1,29 @@ package org.asamk.signal.json; +import com.fasterxml.jackson.annotation.JsonProperty; + import org.whispersystems.signalservice.api.messages.SignalServiceReceiptMessage; import java.util.List; class JsonReceiptMessage { - long when; - boolean isDelivery; - boolean isRead; - List timestamps; + @JsonProperty + final long when; + + @JsonProperty + final boolean isDelivery; + + @JsonProperty + final boolean isRead; + + @JsonProperty + final List timestamps; JsonReceiptMessage(SignalServiceReceiptMessage receiptMessage) { - this.when = receiptMessage.getWhen(); - if (receiptMessage.isDeliveryReceipt()) { - this.isDelivery = true; - } - if (receiptMessage.isReadReceipt()) { - this.isRead = true; - } + this.isDelivery = receiptMessage.isDeliveryReceipt(); + this.isRead = receiptMessage.isReadReceipt(); this.timestamps = receiptMessage.getTimestamps(); } diff --git a/src/main/java/org/asamk/signal/json/JsonRemoteDelete.java b/src/main/java/org/asamk/signal/json/JsonRemoteDelete.java new file mode 100644 index 00000000..a498a0e5 --- /dev/null +++ b/src/main/java/org/asamk/signal/json/JsonRemoteDelete.java @@ -0,0 +1,15 @@ +package org.asamk.signal.json; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage; + +class JsonRemoteDelete { + + @JsonProperty + final long timestamp; + + JsonRemoteDelete(SignalServiceDataMessage.RemoteDelete remoteDelete) { + this.timestamp = remoteDelete.getTargetSentTimestamp(); + } +} diff --git a/src/main/java/org/asamk/signal/json/JsonSharedContact.java b/src/main/java/org/asamk/signal/json/JsonSharedContact.java new file mode 100644 index 00000000..ea15b574 --- /dev/null +++ b/src/main/java/org/asamk/signal/json/JsonSharedContact.java @@ -0,0 +1,62 @@ +package org.asamk.signal.json; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +import org.whispersystems.signalservice.api.messages.shared.SharedContact; + +import java.util.List; +import java.util.stream.Collectors; + +public class JsonSharedContact { + + @JsonProperty + final JsonContactName name; + + @JsonProperty + final JsonContactAvatar avatar; + + @JsonProperty + @JsonInclude(JsonInclude.Include.NON_NULL) + final List phone; + + @JsonProperty + @JsonInclude(JsonInclude.Include.NON_NULL) + final List email; + + @JsonProperty + @JsonInclude(JsonInclude.Include.NON_NULL) + final List address; + + @JsonProperty + final String organization; + + public JsonSharedContact(SharedContact contact) { + name = new JsonContactName(contact.getName()); + if (contact.getAvatar().isPresent()) { + avatar = new JsonContactAvatar(contact.getAvatar().get()); + } else { + avatar = null; + } + + if (contact.getPhone().isPresent()) { + phone = contact.getPhone().get().stream().map(JsonContactPhone::new).collect(Collectors.toList()); + } else { + phone = null; + } + + if (contact.getEmail().isPresent()) { + email = contact.getEmail().get().stream().map(JsonContactEmail::new).collect(Collectors.toList()); + } else { + email = null; + } + + if (contact.getAddress().isPresent()) { + address = contact.getAddress().get().stream().map(JsonContactAddress::new).collect(Collectors.toList()); + } else { + address = null; + } + + organization = contact.getOrganization().orNull(); + } +} diff --git a/src/main/java/org/asamk/signal/json/JsonSticker.java b/src/main/java/org/asamk/signal/json/JsonSticker.java new file mode 100644 index 00000000..e56ddf3f --- /dev/null +++ b/src/main/java/org/asamk/signal/json/JsonSticker.java @@ -0,0 +1,25 @@ +package org.asamk.signal.json; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage; + +import java.util.Base64; + +public class JsonSticker { + + @JsonProperty + final String packId; + + @JsonProperty + final String packKey; + + @JsonProperty + final int stickerId; + + public JsonSticker(SignalServiceDataMessage.Sticker sticker) { + this.packId = Base64.getEncoder().encodeToString(sticker.getPackId()); + this.packKey = Base64.getEncoder().encodeToString(sticker.getPackKey()); + this.stickerId = sticker.getStickerId(); + } +} diff --git a/src/main/java/org/asamk/signal/json/JsonSyncDataMessage.java b/src/main/java/org/asamk/signal/json/JsonSyncDataMessage.java index 7ea75bbd..d93351f1 100644 --- a/src/main/java/org/asamk/signal/json/JsonSyncDataMessage.java +++ b/src/main/java/org/asamk/signal/json/JsonSyncDataMessage.java @@ -1,18 +1,23 @@ package org.asamk.signal.json; +import com.fasterxml.jackson.annotation.JsonProperty; + import org.asamk.Signal; import org.asamk.signal.manager.Manager; import org.whispersystems.signalservice.api.messages.multidevice.SentTranscriptMessage; +import org.whispersystems.signalservice.api.push.SignalServiceAddress; class JsonSyncDataMessage extends JsonDataMessage { - String destination; + @JsonProperty + final String destination; JsonSyncDataMessage(SentTranscriptMessage transcriptMessage, Manager m) { super(transcriptMessage.getMessage(), m); - if (transcriptMessage.getDestination().isPresent()) { - this.destination = transcriptMessage.getDestination().get().getLegacyIdentifier(); - } + + this.destination = transcriptMessage.getDestination() + .transform(SignalServiceAddress::getLegacyIdentifier) + .orNull(); } JsonSyncDataMessage(Signal.SyncMessageReceived messageReceived) { diff --git a/src/main/java/org/asamk/signal/json/JsonSyncMessage.java b/src/main/java/org/asamk/signal/json/JsonSyncMessage.java index f29bc02e..7ba277d5 100644 --- a/src/main/java/org/asamk/signal/json/JsonSyncMessage.java +++ b/src/main/java/org/asamk/signal/json/JsonSyncMessage.java @@ -1,13 +1,16 @@ package org.asamk.signal.json; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + import org.asamk.Signal; import org.asamk.signal.manager.Manager; -import org.whispersystems.signalservice.api.messages.multidevice.ReadMessage; import org.whispersystems.signalservice.api.messages.multidevice.SignalServiceSyncMessage; import org.whispersystems.signalservice.api.push.SignalServiceAddress; -import java.util.ArrayList; +import java.util.Base64; import java.util.List; +import java.util.stream.Collectors; enum JsonSyncMessageType { CONTACTS_SYNC, @@ -17,23 +20,57 @@ enum JsonSyncMessageType { class JsonSyncMessage { - JsonSyncDataMessage sentMessage; - List blockedNumbers; - List readMessages; - JsonSyncMessageType type; + @JsonProperty + @JsonInclude(JsonInclude.Include.NON_NULL) + final JsonSyncDataMessage sentMessage; + + @JsonProperty + @JsonInclude(JsonInclude.Include.NON_NULL) + final List blockedNumbers; + + @JsonProperty + @JsonInclude(JsonInclude.Include.NON_NULL) + final List blockedGroupIds; + + @JsonProperty + @JsonInclude(JsonInclude.Include.NON_NULL) + final List readMessages; + + @JsonProperty + @JsonInclude(JsonInclude.Include.NON_NULL) + final JsonSyncMessageType type; JsonSyncMessage(SignalServiceSyncMessage syncMessage, Manager m) { - if (syncMessage.getSent().isPresent()) { - this.sentMessage = new JsonSyncDataMessage(syncMessage.getSent().get(), m); - } + this.sentMessage = syncMessage.getSent().isPresent() + ? new JsonSyncDataMessage(syncMessage.getSent().get(), m) + : null; if (syncMessage.getBlockedList().isPresent()) { - this.blockedNumbers = new ArrayList<>(syncMessage.getBlockedList().get().getAddresses().size()); - for (SignalServiceAddress address : syncMessage.getBlockedList().get().getAddresses()) { - this.blockedNumbers.add(address.getLegacyIdentifier()); - } + final var base64 = Base64.getEncoder(); + this.blockedNumbers = syncMessage.getBlockedList() + .get() + .getAddresses() + .stream() + .map(SignalServiceAddress::getLegacyIdentifier) + .collect(Collectors.toList()); + this.blockedGroupIds = syncMessage.getBlockedList() + .get() + .getGroupIds() + .stream() + .map(base64::encodeToString) + .collect(Collectors.toList()); + } else { + this.blockedNumbers = null; + this.blockedGroupIds = null; } if (syncMessage.getRead().isPresent()) { - this.readMessages = syncMessage.getRead().get(); + this.readMessages = syncMessage.getRead() + .get() + .stream() + .map(message -> new JsonSyncReadMessage(message.getSender().getLegacyIdentifier(), + message.getTimestamp())) + .collect(Collectors.toList()); + } else { + this.readMessages = null; } if (syncMessage.getContacts().isPresent()) { @@ -42,10 +79,16 @@ class JsonSyncMessage { this.type = JsonSyncMessageType.GROUPS_SYNC; } else if (syncMessage.getRequest().isPresent()) { this.type = JsonSyncMessageType.REQUEST_SYNC; + } else { + this.type = null; } } JsonSyncMessage(Signal.SyncMessageReceived messageReceived) { - sentMessage = new JsonSyncDataMessage(messageReceived); + this.sentMessage = new JsonSyncDataMessage(messageReceived); + this.blockedNumbers = null; + this.blockedGroupIds = null; + this.readMessages = null; + this.type = null; } } diff --git a/src/main/java/org/asamk/signal/json/JsonSyncReadMessage.java b/src/main/java/org/asamk/signal/json/JsonSyncReadMessage.java new file mode 100644 index 00000000..d65b0672 --- /dev/null +++ b/src/main/java/org/asamk/signal/json/JsonSyncReadMessage.java @@ -0,0 +1,17 @@ +package org.asamk.signal.json; + +import com.fasterxml.jackson.annotation.JsonProperty; + +class JsonSyncReadMessage { + + @JsonProperty + final String sender; + + @JsonProperty + final long timestamp; + + public JsonSyncReadMessage(final String sender, final long timestamp) { + this.sender = sender; + this.timestamp = timestamp; + } +} diff --git a/src/main/java/org/asamk/signal/json/JsonTypingMessage.java b/src/main/java/org/asamk/signal/json/JsonTypingMessage.java new file mode 100644 index 00000000..7e3b1a44 --- /dev/null +++ b/src/main/java/org/asamk/signal/json/JsonTypingMessage.java @@ -0,0 +1,28 @@ +package org.asamk.signal.json; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +import org.whispersystems.signalservice.api.messages.SignalServiceTypingMessage; + +import java.util.Base64; + +class JsonTypingMessage { + + @JsonProperty + final String action; + + @JsonProperty + final long timestamp; + + @JsonProperty + @JsonInclude(JsonInclude.Include.NON_NULL) + final String groupId; + + JsonTypingMessage(SignalServiceTypingMessage typingMessage) { + this.action = typingMessage.getAction().name(); + this.timestamp = typingMessage.getTimestamp(); + final var encoder = Base64.getEncoder(); + this.groupId = typingMessage.getGroupId().transform(encoder::encodeToString).orNull(); + } +} diff --git a/src/main/java/org/asamk/signal/util/DateUtils.java b/src/main/java/org/asamk/signal/util/DateUtils.java index c9b92529..7f2974ae 100644 --- a/src/main/java/org/asamk/signal/util/DateUtils.java +++ b/src/main/java/org/asamk/signal/util/DateUtils.java @@ -13,8 +13,8 @@ public class DateUtils { } public static String formatTimestamp(long timestamp) { - Date date = new Date(timestamp); - final DateFormat df = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'"); // Quoted "Z" to indicate UTC, no timezone offset + var date = new Date(timestamp); + final DateFormat df = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSX"); // Quoted "Z" to indicate UTC, no timezone offset df.setTimeZone(tzUTC); return timestamp + " (" + df.format(date) + ")"; } diff --git a/src/main/java/org/asamk/signal/util/ErrorUtils.java b/src/main/java/org/asamk/signal/util/ErrorUtils.java index 44d505be..595509a6 100644 --- a/src/main/java/org/asamk/signal/util/ErrorUtils.java +++ b/src/main/java/org/asamk/signal/util/ErrorUtils.java @@ -1,83 +1,68 @@ package org.asamk.signal.util; -import org.asamk.signal.manager.GroupIdFormatException; -import org.asamk.signal.manager.GroupNotFoundException; -import org.asamk.signal.manager.NotAGroupMemberException; +import org.asamk.signal.PlainTextWriter; +import org.asamk.signal.commands.exceptions.CommandException; +import org.asamk.signal.commands.exceptions.IOErrorException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.whispersystems.signalservice.api.messages.SendMessageResult; -import org.whispersystems.signalservice.api.util.InvalidNumberException; -import java.io.IOException; import java.util.ArrayList; import java.util.List; public class ErrorUtils { + private final static Logger logger = LoggerFactory.getLogger(ErrorUtils.class); + private ErrorUtils() { } public static void handleAssertionError(AssertionError e) { - System.err.println("Failed to send/receive message (Assertion): " + e.getMessage()); - e.printStackTrace(); - System.err.println( - "If you use an Oracle JRE please check if you have unlimited strength crypto enabled, see README"); + logger.warn("If you use an Oracle JRE please check if you have unlimited strength crypto enabled, see README"); } - public static int handleTimestampAndSendMessageResults(long timestamp, List results) { + public static void handleTimestampAndSendMessageResults( + PlainTextWriter writer, long timestamp, List results + ) throws CommandException { if (timestamp != 0) { - System.out.println(timestamp); + writer.println("{}", timestamp); } - List errors = getErrorMessagesFromSendMessageResults(results); - return handleSendMessageResultErrors(errors); + var errors = getErrorMessagesFromSendMessageResults(results); + handleSendMessageResultErrors(errors); } public static List getErrorMessagesFromSendMessageResults(List results) { - List errors = new ArrayList<>(); - for (SendMessageResult result : results) { - if (result.isNetworkFailure()) { - errors.add(String.format("Network failure for \"%s\"", result.getAddress().getLegacyIdentifier())); - } else if (result.isUnregisteredFailure()) { - errors.add(String.format("Unregistered user \"%s\"", result.getAddress().getLegacyIdentifier())); - } else if (result.getIdentityFailure() != null) { - errors.add(String.format("Untrusted Identity for \"%s\"", result.getAddress().getLegacyIdentifier())); + var errors = new ArrayList(); + for (var result : results) { + var error = getErrorMessageFromSendMessageResult(result); + if (error != null) { + errors.add(error); } } return errors; } - private static int handleSendMessageResultErrors(List errors) { + public static String getErrorMessageFromSendMessageResult(SendMessageResult result) { + if (result.isNetworkFailure()) { + return String.format("Network failure for \"%s\"", result.getAddress().getLegacyIdentifier()); + } else if (result.isUnregisteredFailure()) { + return String.format("Unregistered user \"%s\"", result.getAddress().getLegacyIdentifier()); + } else if (result.getIdentityFailure() != null) { + return String.format("Untrusted Identity for \"%s\"", result.getAddress().getLegacyIdentifier()); + } + return null; + } + + private static void handleSendMessageResultErrors(List errors) throws CommandException { if (errors.size() == 0) { - return 0; + return; } - System.err.println("Failed to send (some) messages:"); - for (String error : errors) { - System.err.println(error); + var message = new StringBuilder(); + message.append("Failed to send (some) messages:\n"); + for (var error : errors) { + message.append(error).append("\n"); } - return 3; - } - - public static void handleIOException(IOException e) { - System.err.println("Failed to send message: " + e.getMessage()); - } - - public static void handleGroupNotFoundException(GroupNotFoundException e) { - System.err.println("Failed to send to group: " + e.getMessage()); - System.err.println("Aborting sending."); - } - - public static void handleNotAGroupMemberException(NotAGroupMemberException e) { - System.err.println("Failed to send to group: " + e.getMessage()); - System.err.println("Update the group on another device to readd the user to this group."); - System.err.println("Aborting sending."); - } - - public static void handleGroupIdFormatException(GroupIdFormatException e) { - System.err.println(e.getMessage()); - System.err.println("Aborting sending."); - } - - public static void handleInvalidNumberException(InvalidNumberException e) { - System.err.println("Failed to parse recipient: " + e.getMessage()); - System.err.println("Aborting sending."); + throw new IOErrorException(message.toString()); } } diff --git a/src/main/java/org/asamk/signal/util/Hex.java b/src/main/java/org/asamk/signal/util/Hex.java index 46609ceb..f5f7a6ad 100644 --- a/src/main/java/org/asamk/signal/util/Hex.java +++ b/src/main/java/org/asamk/signal/util/Hex.java @@ -8,8 +8,8 @@ public class Hex { } public static String toString(byte[] bytes) { - StringBuffer buf = new StringBuffer(); - for (final byte aByte : bytes) { + var buf = new StringBuffer(); + for (final var aByte : bytes) { appendHexChar(buf, aByte); buf.append(" "); } @@ -17,8 +17,8 @@ public class Hex { } public static String toStringCondensed(byte[] bytes) { - StringBuffer buf = new StringBuffer(); - for (final byte aByte : bytes) { + var buf = new StringBuffer(); + for (final var aByte : bytes) { appendHexChar(buf, aByte); } return buf.toString(); @@ -30,9 +30,9 @@ public class Hex { } public static byte[] toByteArray(String s) { - int len = s.length(); - byte[] data = new byte[len / 2]; - for (int i = 0; i < len; i += 2) { + var len = s.length(); + var data = new byte[len / 2]; + for (var i = 0; i < len; i += 2) { data[i / 2] = (byte) ((Character.digit(s.charAt(i), 16) << 4) + Character.digit(s.charAt(i + 1), 16)); } return data; diff --git a/src/main/java/org/asamk/signal/util/IOUtils.java b/src/main/java/org/asamk/signal/util/IOUtils.java index 59727a9a..5505e518 100644 --- a/src/main/java/org/asamk/signal/util/IOUtils.java +++ b/src/main/java/org/asamk/signal/util/IOUtils.java @@ -1,38 +1,19 @@ package org.asamk.signal.util; -import org.whispersystems.signalservice.internal.util.Util; - -import java.io.ByteArrayOutputStream; import java.io.File; -import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; -import java.io.OutputStream; import java.io.StringWriter; import java.nio.charset.Charset; -import java.nio.file.Files; -import java.nio.file.Path; -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 { private IOUtils() { } - public static File createTempFile() throws IOException { - return File.createTempFile("signal_tmp_", ".tmp"); - } - public static String readAll(InputStream in, Charset charset) throws IOException { - StringWriter output = new StringWriter(); - byte[] buffer = new byte[4096]; + var output = new StringWriter(); + var buffer = new byte[4096]; int n; while (-1 != (n = in.read(buffer))) { output.write(new String(buffer, 0, n, charset)); @@ -40,57 +21,12 @@ public class IOUtils { return output.toString(); } - public static byte[] readFully(InputStream in) throws IOException { - ByteArrayOutputStream baos = new ByteArrayOutputStream(); - Util.copy(in, baos); - return baos.toByteArray(); - } - - public static void createPrivateDirectories(File file) throws IOException { - if (file.exists()) { - return; - } - - final Path path = file.toPath(); - try { - Set 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 Path file = path.toPath(); - try { - Set perms = EnumSet.of(OWNER_READ, OWNER_WRITE); - Files.createFile(file, PosixFilePermissions.asFileAttribute(perms)); - } catch (UnsupportedOperationException e) { - Files.createFile(file); - } - } - public static File getDataHomeDir() { - String dataHome = System.getenv("XDG_DATA_HOME"); + var dataHome = System.getenv("XDG_DATA_HOME"); if (dataHome != null) { return new File(dataHome); } return new File(new File(System.getProperty("user.home"), ".local"), "share"); } - - public static void copyStreamToFile(InputStream input, File outputFile) throws IOException { - copyStreamToFile(input, outputFile, 8192); - } - - public static void copyStreamToFile(InputStream input, File outputFile, int bufferSize) throws IOException { - try (OutputStream output = new FileOutputStream(outputFile)) { - byte[] buffer = new byte[bufferSize]; - int read; - - while ((read = input.read(buffer)) != -1) { - output.write(buffer, 0, read); - } - } - } } diff --git a/src/main/java/org/asamk/signal/util/RandomUtils.java b/src/main/java/org/asamk/signal/util/RandomUtils.java index 19c3f18c..6af4ec12 100644 --- a/src/main/java/org/asamk/signal/util/RandomUtils.java +++ b/src/main/java/org/asamk/signal/util/RandomUtils.java @@ -6,7 +6,7 @@ import java.security.SecureRandom; public class RandomUtils { private static final ThreadLocal LOCAL_RANDOM = ThreadLocal.withInitial(() -> { - SecureRandom rand = getSecureRandomUnseeded(); + var rand = getSecureRandomUnseeded(); // Let the SecureRandom seed it self initially rand.nextBoolean(); diff --git a/src/main/java/org/asamk/signal/util/Util.java b/src/main/java/org/asamk/signal/util/Util.java index 3cd5619a..4de49ec6 100644 --- a/src/main/java/org/asamk/signal/util/Util.java +++ b/src/main/java/org/asamk/signal/util/Util.java @@ -1,66 +1,33 @@ package org.asamk.signal.util; -import com.fasterxml.jackson.databind.JsonNode; - -import org.asamk.signal.manager.GroupId; -import org.asamk.signal.manager.GroupIdFormatException; -import org.whispersystems.signalservice.api.push.SignalServiceAddress; -import org.whispersystems.signalservice.api.util.InvalidNumberException; -import org.whispersystems.signalservice.api.util.PhoneNumberFormatter; -import org.whispersystems.signalservice.api.util.UuidUtil; - -import java.io.InvalidObjectException; +import org.asamk.signal.manager.groups.GroupId; +import org.asamk.signal.manager.groups.GroupIdFormatException; +import org.whispersystems.libsignal.util.guava.Optional; public class Util { private Util() { } + public static String getStringIfNotBlank(Optional value) { + var string = value.orNull(); + if (string == null || string.isBlank()) { + return null; + } + return string; + } + public static String formatSafetyNumber(String digits) { - final int partCount = 12; - int partSize = digits.length() / partCount; - StringBuilder f = new StringBuilder(digits.length() + partCount); - for (int i = 0; i < partCount; i++) { + final var partCount = 12; + var partSize = digits.length() / partCount; + var f = new StringBuilder(digits.length() + partCount); + for (var i = 0; i < partCount; i++) { f.append(digits, i * partSize, (i * partSize) + partSize).append(" "); } return f.toString(); } - public static String join(CharSequence separator, Iterable list) { - StringBuilder buf = new StringBuilder(); - for (CharSequence str : list) { - if (buf.length() > 0) { - buf.append(separator); - } - buf.append(str); - } - - return buf.toString(); - } - - public static JsonNode getNotNullNode(JsonNode parent, String name) throws InvalidObjectException { - JsonNode node = parent.get(name); - if (node == null) { - throw new InvalidObjectException(String.format("Incorrect file format: expected parameter %s not found ", - name)); - } - - return node; - } - public static GroupId decodeGroupId(String groupId) throws GroupIdFormatException { return GroupId.fromBase64(groupId); } - - public static String canonicalizeNumber(String number, String localNumber) throws InvalidNumberException { - return PhoneNumberFormatter.formatNumber(number, localNumber); - } - - public static SignalServiceAddress getSignalServiceAddressFromIdentifier(final String identifier) { - if (UuidUtil.isUuid(identifier)) { - return new SignalServiceAddress(UuidUtil.parseOrNull(identifier), null); - } else { - return new SignalServiceAddress(null, identifier); - } - } }