try to merge again

This commit is contained in:
technillogue 2021-04-24 22:23:52 -04:00
parent 6d18f311e6
commit 685fce477c
184 changed files with 14906 additions and 1705 deletions

1
.gitignore vendored
View file

@ -10,3 +10,4 @@ local.properties
.project
.settings/
out/
.DS_Store

View file

@ -2,6 +2,73 @@
## [Unreleased]
## [0.8.1] - 2021-03-02
### Added
- New dbus commands: updateProfile, listNumbers, getContactNumber, quitGroup, isContactBlocked, isGroupBlocked, isMember, joinGroup (Thanks @bublath)
- Additional output for json format: shared contacts (Thanks @Atomic-Bean)
- Improved plain text output to be more consistent and synced messages are now indented
### Fixed
- Issue with broken sessions with linked devices
### Changed
- Behavior of `trust` command improved, when trusting a new identity key all other known keys for
the same number are removed.
## [0.8.0] - 2021-02-14
**Attention**: For all signal protocol functionality an additional native library is now required: [libsignal-client](https://github.com/signalapp/libsignal-client/).
See https://github.com/AsamK/signal-cli/wiki/Provide-native-lib-for-libsignal for more information.
### Added
- Experimental support for building a GraalVM native image
- Support for setting profile about text and emoji
### Fixed
- Incorrect error message when removing a non-existent profile avatar
## [0.7.4] - 2021-01-19
### Changed
- Notify linked devices after profile has been updated
### Fixed
- After registering a new account, receiving messages didn't work
You may have to register and verify again to fix the issue.
- Creating v1 groups works again
## [0.7.3] - 2021-01-17
### Added
- `getUserStatus` command to check if a user is registered on Signal (Thanks @Atomic-Bean)
- Global `--verbose` flag to increase log level
- Global `--output=json` flag, currently supported by `receive`, `daemon`, `getUserStatus`, `listGroups`
- `--note-to-self` flag for `send` command to send a note to linked devices
- More info for received messages in json output: stickers, viewOnce, typing, remoteDelete
### Changed
- signal-cli can now be used without the username `-u` flag
For daemon command all local users will be exposed as dbus objects.
If only one local user exists, all other commands will use that user,
otherwise a user has to be specified.
- Messages sent to self number will be sent as normal Signal messages again, to
send a sync message, use the new `--note-to-self` flag
- Ignore messages with group context sent by non group member
- Profile key is sent along with all direct messages
- In json output unnecessary fields that are null are now omitted
### Fixed
- Disable registration lock before removing the PIN
- Fix PIN hash version to match the official clients.
If you had previously set a PIN you need to set it again to be able to unlock the registration lock later.
- Issue with saving account file after linking
## [0.7.2] - 2020-12-31
### Added
- Implement new registration lock PIN with `setPin` and `removePin` (with KBS)
- Include quotes, mentions and reactions in json output (Thanks @Atomic-Bean)
### Fixed
- Retrieve avatars for v2 groups
- Download attachment thumbnail for quoted attachments
## [0.7.1] - 2020-12-21
### Added
- Accept group invitation with `updateGroup -g GROUP_ID`

12
CONTRIBUTING.md Normal file
View file

@ -0,0 +1,12 @@
# Question
If you have a question you can ask it in the [GitHub discussions page](https://github.com/AsamK/signal-cli/discussions)
# Report a bug
- Search [existing issues](https://github.com/AsamK/signal-cli/issues?q=is%3Aissue) if it has been reported already
- If you're unable to find an open issue addressing the problem, [open a new one](https://github.com/AsamK/signal-cli/issues/new).
- Be sure to include a **title and clear description**, as much relevant information as possible.
- Run the failing command with `--verbose` flag to get a more detailed log output and include that in the bug report
# Pull request
- Code style should match the existing code, IntelliJ users can use the auto formatter
- Separate PRs should be opened for each implemented feature or bug fix

1
FUNDING.yml Normal file
View file

@ -0,0 +1 @@
liberapay: asamk

View file

@ -19,11 +19,21 @@ expect the wiki and builds to be broken for now.
signal-cli is a commandline interface for [libsignal-service-java](https://github.com/WhisperSystems/libsignal-service-java). It supports registering, verifying, sending and receiving messages.
To be able to link to an existing Signal-Android/signal-cli instance, signal-cli uses a [patched libsignal-service-java](https://github.com/technillogue/libsignal-service-java), because libsignal-service-java does not yet support [provisioning as a slave device](https://github.com/WhisperSystems/libsignal-service-java/pull/21).
For registering you need a phone number where you can receive SMS or incoming calls.
signal-cli is primarily intended to be used on servers to notify admins of important events. For this use-case, it has a dbus interface, that can be used to send messages from any programming language that has dbus bindings.
signal-cli is primarily intended to be used on servers to notify admins of important events. For this use-case, it has a dbus interface ([man page](https://github.com/AsamK/signal-cli/blob/master/man/signal-cli-dbus.5.adoc)), that can be used to send messages from any programming language that has dbus bindings.
## Installation
<<<<<<< HEAD
You can [build signal-cli](#building) yourself, or use the [provided binary files](https://github.com/technillogue/signal-cli/releases/latest), which should work on Linux, macOS and Windows. For Arch Linux there is also a [package in AUR](https://aur.archlinux.org/packages/signal-cli/) and there is a [FreeBSD port](https://www.freshports.org/net-im/signal-cli) available as well. You need to have at least JRE 11 installed, to run signal-cli.
=======
You can [build signal-cli](#building) yourself, or use the [provided binary files](https://github.com/AsamK/signal-cli/releases/latest), which should work on Linux, macOS and Windows. For Arch Linux there is also a [package in AUR](https://aur.archlinux.org/packages/signal-cli/) and there is a [FreeBSD port](https://www.freshports.org/net-im/signal-cli) available as well.
System requirements:
- at least Java Runtime Environment (JRE) 11
- native libraries: libzkgroup, libsignal-client
Those are bundled for x86_64 Linux, for other systems/architectures see: [Provide native lib for libsignal](https://github.com/AsamK/signal-cli/wiki/Provide-native-lib-for-libsignal)
>>>>>>> upstream/master
### Install system-wide on Linux
See [latest version](https://github.com/technillogue/signal-cli/releases).
@ -39,7 +49,9 @@ You can find further instructions on the Wiki:
## Usage
Important: The USERNAME (your phone number) must include the country calling code, i.e. the number must start with a "+" sign. (See [Wikipedia](https://en.wikipedia.org/wiki/List_of_country_calling_codes) for a list of all country codes.)
For a complete usage overview please read the [man page](https://github.com/AsamK/signal-cli/blob/master/man/signal-cli.1.adoc) and the [wiki](https://github.com/AsamK/signal-cli/wiki).
Important: The USERNAME is your phone number in international format and must include the country calling code. Hence it should start with a "+" sign. (See [Wikipedia](https://en.wikipedia.org/wiki/List_of_country_calling_codes) for a list of all country codes.)
* Register a number (with SMS verification)
@ -63,7 +75,10 @@ Important: The USERNAME (your phone number) must include the country calling cod
signal-cli -u USERNAME receive
<<<<<<< HEAD
For more information read the [man page](https://github.com/technillogue/signal-cli/blob/master/man/signal-cli.1.adoc) and the [wiki](https://github.com/technillogue/signal-cli/wiki).
=======
>>>>>>> upstream/master
## Storage
@ -98,6 +113,19 @@ dependencies. If you have a recent gradle version installed, you can replace `./
./gradlew distTar
### Building a native binary with GraalVM (EXPERIMENTAL)
It is possible to build a native binary with [GraalVM](https://www.graalvm.org).
This is still experimental and will not work in all situations.
1. [Install GraalVM and setup the enviroment](https://www.graalvm.org/docs/getting-started/#install-graalvm)
2. [Install prerequisites](https://www.graalvm.org/reference-manual/native-image/#prerequisites)
3. Execute Gradle:
./gradlew assembleNativeImage
The binary is available at *build/native-image/signal-cli*
## Troubleshooting
If you use a version of the Oracle JRE and get an InvalidKeyException you need to enable unlimited strength crypto. See https://stackoverflow.com/questions/6481627/java-security-illegal-key-size-or-default-parameters for instructions.

96
build.gradle.kts Normal file
View file

@ -0,0 +1,96 @@
plugins {
java
application
eclipse
`check-lib-versions`
}
version = "0.8.1"
java {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
application {
mainClass.set("org.asamk.signal.Main")
}
repositories {
mavenLocal()
mavenCentral()
}
dependencies {
implementation("org.bouncycastle:bcprov-jdk15on:1.68")
implementation("net.sourceforge.argparse4j:argparse4j:0.8.1")
implementation("com.github.hypfvieh:dbus-java:3.2.4")
implementation("org.slf4j:slf4j-simple:1.7.30")
implementation(project(":lib"))
}
configurations {
implementation {
resolutionStrategy.failOnVersionConflict()
}
}
tasks.withType<JavaCompile> {
options.encoding = "UTF-8"
}
tasks.withType<Jar> {
manifest {
attributes(
"Implementation-Title" to project.name,
"Implementation-Version" to project.version,
"Main-Class" to application.mainClass.get()
)
}
}
tasks.withType<JavaExec> {
val appArgs: String? by project
if (appArgs != null) {
// allow passing command-line arguments to the main application e.g.:
// $ gradle run -PappArgs="['-u', '+...', 'daemon', '--json']"
args = groovy.util.Eval.me(appArgs) as MutableList<String>
}
}
val assembleNativeImage by tasks.registering {
dependsOn("assemble")
var graalVMHome = ""
doFirst {
graalVMHome = System.getenv("GRAALVM_HOME")
?: throw GradleException("Required GRAALVM_HOME environment variable not set.")
}
doLast {
val nativeBinaryOutputPath = "$buildDir/native-image"
val nativeBinaryName = "signal-cli"
mkdir(nativeBinaryOutputPath)
exec {
workingDir = File(".")
commandLine("$graalVMHome/bin/native-image",
"-H:Path=$nativeBinaryOutputPath",
"-H:Name=$nativeBinaryName",
"-H:JNIConfigurationFiles=graalvm-config-dir/jni-config.json",
"-H:DynamicProxyConfigurationFiles=graalvm-config-dir/proxy-config.json",
"-H:ResourceConfigurationFiles=graalvm-config-dir/resource-config.json",
"-H:ReflectionConfigurationFiles=graalvm-config-dir/reflect-config.json",
"--no-fallback",
"--allow-incomplete-classpath",
"--report-unsupported-elements-at-runtime",
"--enable-url-protocols=http,https",
"--enable-https",
"--enable-all-security-services",
"-cp",
sourceSets.main.get().runtimeClasspath.asPath,
application.mainClass.get())
}
}
}

16
buildSrc/build.gradle.kts Normal file
View file

@ -0,0 +1,16 @@
plugins {
`kotlin-dsl`
}
repositories {
mavenCentral()
}
gradlePlugin {
plugins {
register("check-lib-versions") {
id = "check-lib-versions"
implementationClass = "CheckLibVersionsPlugin"
}
}
}

View file

@ -0,0 +1,39 @@
import groovy.util.XmlSlurper
import groovy.util.slurpersupport.GPathResult
import org.codehaus.groovy.runtime.ResourceGroovyMethods
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.api.Task
import org.gradle.api.artifacts.Dependency
class CheckLibVersionsPlugin : Plugin<Project> {
override fun apply(project: Project) {
project.task("checkLibVersions") {
description = "Find any 3rd party libraries which have released new versions to the central Maven repo since we last upgraded."
doLast {
project.configurations.flatMap { it.allDependencies }
.toSet()
.forEach { checkDependency(it) }
}
}
}
private fun Task.checkDependency(dependency: Dependency) {
val version = dependency.version
val group = dependency.group
val path = group?.replace(".", "/") ?: ""
val name = dependency.name
val metaDataUrl = "https://repo1.maven.org/maven2/$path/$name/maven-metadata.xml"
try {
val url = ResourceGroovyMethods.toURL(metaDataUrl)
val metaDataText = ResourceGroovyMethods.getText(url)
val metadata = XmlSlurper().parseText(metaDataText)
val newest = (metadata.getProperty("versioning") as GPathResult).getProperty("latest")
if (version != newest.toString()) {
println("UPGRADE {\"group\": \"$group\", \"name\": \"$name\", \"current\": \"$version\", \"latest\": \"$newest\"}")
}
} catch (e: Throwable) {
logger.debug("Unable to download or parse $metaDataUrl: $e.message")
}
}
}

16
data/signal-cli.service Normal file
View file

@ -0,0 +1,16 @@
[Unit]
Description=Send secure messages to Signal clients
Requires=dbus.socket
After=dbus.socket
Wants=network-online.target
After=network-online.target
[Service]
Type=dbus
Environment="SIGNAL_CLI_OPTS=-Xms2m"
ExecStart=%dir%/bin/signal-cli --config /var/lib/signal-cli daemon --system
User=signal-cli
BusName=org.asamk.Signal
[Install]
Alias=dbus-org.asamk.Signal.service

View file

@ -13,4 +13,4 @@ User=signal-cli
BusName=org.asamk.Signal
[Install]
WantedBy=multi-user.target
Alias=dbus-org.asamk.Signal.service

View file

@ -0,0 +1,97 @@
[
{
"name":"java.lang.ClassLoader",
"methods":[{"name":"getPlatformClassLoader","parameterTypes":[] }]
},
{
"name":"java.lang.IllegalStateException",
"methods":[{"name":"<init>","parameterTypes":["java.lang.String"] }]
},
{
"name":"java.lang.NoSuchMethodError"
},
{
"name":"java.lang.UnsatisfiedLinkError",
"methods":[{"name":"<init>","parameterTypes":["java.lang.String"] }]
},
{
"name":"org.asamk.signal.manager.storage.protocol.JsonSignalProtocolStore",
"methods":[
{"name":"getIdentity","parameterTypes":["org.whispersystems.libsignal.SignalProtocolAddress"] },
{"name":"getIdentityKeyPair","parameterTypes":[] },
{"name":"getLocalRegistrationId","parameterTypes":[] },
{"name":"isTrustedIdentity","parameterTypes":["org.whispersystems.libsignal.SignalProtocolAddress","org.whispersystems.libsignal.IdentityKey","org.whispersystems.libsignal.state.IdentityKeyStore$Direction"] },
{"name":"loadPreKey","parameterTypes":["int"] },
{"name":"loadSession","parameterTypes":["org.whispersystems.libsignal.SignalProtocolAddress"] },
{"name":"loadSignedPreKey","parameterTypes":["int"] },
{"name":"removePreKey","parameterTypes":["int"] },
{"name":"saveIdentity","parameterTypes":["org.whispersystems.libsignal.SignalProtocolAddress","org.whispersystems.libsignal.IdentityKey"] },
{"name":"storeSession","parameterTypes":["org.whispersystems.libsignal.SignalProtocolAddress","org.whispersystems.libsignal.state.SessionRecord"] }
]
},
{
"name":"org.whispersystems.libsignal.DuplicateMessageException",
"methods":[{"name":"<init>","parameterTypes":["java.lang.String"] }]
},
{
"name":"org.whispersystems.libsignal.IdentityKey",
"methods":[
{"name":"<init>","parameterTypes":["byte[]"] },
{"name":"serialize","parameterTypes":[] }
]
},
{
"name":"org.whispersystems.libsignal.IdentityKeyPair",
"methods":[{"name":"serialize","parameterTypes":[] }]
},
{
"name":"org.whispersystems.libsignal.InvalidMessageException",
"methods":[{"name":"<init>","parameterTypes":["java.lang.String"] }]
},
{
"name":"org.whispersystems.libsignal.SignalProtocolAddress",
"methods":[{"name":"<init>","parameterTypes":["java.lang.String","int"] }]
},
{
"name":"org.whispersystems.libsignal.protocol.PreKeySignalMessage",
"methods":[{"name":"<init>","parameterTypes":["long"] }]
},
{
"name":"org.whispersystems.libsignal.protocol.SignalMessage",
"methods":[{"name":"<init>","parameterTypes":["long"] }]
},
{
"name":"org.whispersystems.libsignal.state.IdentityKeyStore"
},
{
"name":"org.whispersystems.libsignal.state.IdentityKeyStore$Direction",
"fields":[
{"name":"RECEIVING"},
{"name":"SENDING"}
]
},
{
"name":"org.whispersystems.libsignal.state.PreKeyRecord",
"methods":[{"name":"nativeHandle","parameterTypes":[] }]
},
{
"name":"org.whispersystems.libsignal.state.PreKeyStore"
},
{
"name":"org.whispersystems.libsignal.state.SessionRecord",
"methods":[
{"name":"<init>","parameterTypes":["byte[]"] },
{"name":"nativeHandle","parameterTypes":[] }
]
},
{
"name":"org.whispersystems.libsignal.state.SessionStore"
},
{
"name":"org.whispersystems.libsignal.state.SignedPreKeyRecord",
"methods":[{"name":"nativeHandle","parameterTypes":[] }]
},
{
"name":"org.whispersystems.libsignal.state.SignedPreKeyStore"
}
]

View file

@ -0,0 +1,3 @@
[
["org.freedesktop.DBus"]
]

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,13 @@
{
"resources":{
"includes":[
{"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_DE\\E"},
{"pattern":"\\Qlibsignal_jni.so\\E"},
{"pattern":"\\Qlibzkgroup.so\\E"},
{"pattern":"\\Qorg/asamk/signal/manager/config/ias.store\\E"},
{"pattern":"\\Qorg/asamk/signal/manager/config/whisper.store\\E"},
{"pattern":"\\Qorg/slf4j/impl/StaticLoggerBinder.class\\E"},
{"pattern":"com/google/i18n/phonenumbers/data/.*"}
]},
"bundles":[{"name":"net.sourceforge.argparse4j.internal.ArgumentParserImpl"}]
}

View file

@ -0,0 +1,2 @@
[
]

View file

@ -1,5 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-6.6.1-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-7.0-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

31
lib/build.gradle.kts Normal file
View file

@ -0,0 +1,31 @@
plugins {
`java-library`
`check-lib-versions`
}
java {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
repositories {
mavenLocal()
mavenCentral()
}
dependencies {
api("com.github.turasa:signal-service-java:2.15.3_unofficial_19")
implementation("com.google.protobuf:protobuf-javalite:3.10.0")
implementation("org.bouncycastle:bcprov-jdk15on:1.68")
implementation("org.slf4j:slf4j-api:1.7.30")
}
configurations {
implementation {
resolutionStrategy.failOnVersionConflict()
}
}
tasks.withType<JavaCompile> {
options.encoding = "UTF-8"
}

View file

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

View file

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

View file

@ -0,0 +1,93 @@
package org.asamk.signal.manager;
import org.asamk.signal.manager.groups.GroupId;
import org.asamk.signal.manager.util.IOUtils;
import org.asamk.signal.manager.util.Utils;
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
import org.whispersystems.signalservice.api.util.StreamDetails;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.file.Files;
public class AvatarStore {
private final File avatarsPath;
public AvatarStore(final File avatarsPath) {
this.avatarsPath = avatarsPath;
}
public StreamDetails retrieveContactAvatar(SignalServiceAddress address) throws IOException {
return retrieveAvatar(getContactAvatarFile(address));
}
public StreamDetails retrieveProfileAvatar(SignalServiceAddress address) throws IOException {
return retrieveAvatar(getProfileAvatarFile(address));
}
public StreamDetails retrieveGroupAvatar(GroupId groupId) throws IOException {
final var groupAvatarFile = getGroupAvatarFile(groupId);
return retrieveAvatar(groupAvatarFile);
}
public void storeContactAvatar(SignalServiceAddress address, AvatarStorer storer) throws IOException {
storeAvatar(getContactAvatarFile(address), storer);
}
public void storeProfileAvatar(SignalServiceAddress address, AvatarStorer storer) throws IOException {
storeAvatar(getProfileAvatarFile(address), storer);
}
public void storeGroupAvatar(GroupId groupId, AvatarStorer storer) throws IOException {
storeAvatar(getGroupAvatarFile(groupId), storer);
}
public void deleteProfileAvatar(SignalServiceAddress address) throws IOException {
deleteAvatar(getProfileAvatarFile(address));
}
private StreamDetails retrieveAvatar(final File avatarFile) throws IOException {
if (!avatarFile.exists()) {
return null;
}
return Utils.createStreamDetailsFromFile(avatarFile);
}
private void storeAvatar(final File avatarFile, final AvatarStorer storer) throws IOException {
createAvatarsDir();
try (OutputStream output = new FileOutputStream(avatarFile)) {
storer.store(output);
}
}
private void deleteAvatar(final File avatarFile) throws IOException {
if (avatarFile.exists()) {
Files.delete(avatarFile.toPath());
}
}
private File getGroupAvatarFile(GroupId groupId) {
return new File(avatarsPath, "group-" + groupId.toBase64().replace("/", "_"));
}
private File getContactAvatarFile(SignalServiceAddress address) {
return new File(avatarsPath, "contact-" + address.getLegacyIdentifier());
}
private File getProfileAvatarFile(SignalServiceAddress address) {
return new File(avatarsPath, "profile-" + address.getLegacyIdentifier());
}
private void createAvatarsDir() throws IOException {
IOUtils.createPrivateDirectories(avatarsPath);
}
@FunctionalInterface
public interface AvatarStorer {
void store(OutputStream outputStream) throws IOException;
}
}

View file

@ -0,0 +1,76 @@
package org.asamk.signal.manager;
import org.whispersystems.libsignal.InvalidKeyException;
import org.whispersystems.libsignal.ecc.Curve;
import org.whispersystems.libsignal.ecc.ECPublicKey;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URLDecoder;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.HashMap;
import java.util.Map;
import static org.whispersystems.signalservice.internal.util.Util.isEmpty;
public class DeviceLinkInfo {
final String deviceIdentifier;
final ECPublicKey deviceKey;
public static DeviceLinkInfo parseDeviceLinkUri(URI linkUri) throws InvalidKeyException {
final var rawQuery = linkUri.getRawQuery();
if (isEmpty(rawQuery)) {
throw new RuntimeException("Invalid device link uri");
}
var query = getQueryMap(rawQuery);
var deviceIdentifier = query.get("uuid");
var publicKeyEncoded = query.get("pub_key");
if (isEmpty(deviceIdentifier) || isEmpty(publicKeyEncoded)) {
throw new RuntimeException("Invalid device link uri");
}
final byte[] publicKeyBytes;
try {
publicKeyBytes = Base64.getDecoder().decode(publicKeyEncoded);
} catch (IllegalArgumentException e) {
throw new RuntimeException("Invalid device link uri", e);
}
var deviceKey = Curve.decodePoint(publicKeyBytes, 0);
return new DeviceLinkInfo(deviceIdentifier, deviceKey);
}
private static Map<String, String> getQueryMap(String query) {
var params = query.split("&");
var map = new HashMap<String, String>();
for (var param : params) {
final var paramParts = param.split("=");
var name = URLDecoder.decode(paramParts[0], StandardCharsets.UTF_8);
var value = URLDecoder.decode(paramParts[1], StandardCharsets.UTF_8);
map.put(name, value);
}
return map;
}
public DeviceLinkInfo(final String deviceIdentifier, final ECPublicKey deviceKey) {
this.deviceIdentifier = deviceIdentifier;
this.deviceKey = deviceKey;
}
public URI createDeviceLinkUri() {
final var deviceKeyString = Base64.getEncoder().encodeToString(deviceKey.serialize()).replace("=", "");
try {
return new URI("tsdevice:/?uuid="
+ URLEncoder.encode(deviceIdentifier, StandardCharsets.UTF_8)
+ "&pub_key="
+ URLEncoder.encode(deviceKeyString, StandardCharsets.UTF_8));
} catch (URISyntaxException e) {
throw new AssertionError(e);
}
}
}

View file

@ -0,0 +1,159 @@
package org.asamk.signal.manager;
import org.asamk.signal.manager.groups.GroupIdV1;
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
import java.util.Objects;
interface HandleAction {
void execute(Manager m) throws Throwable;
}
class SendReceiptAction implements HandleAction {
private final SignalServiceAddress address;
private final long timestamp;
public SendReceiptAction(final SignalServiceAddress address, final long timestamp) {
this.address = address;
this.timestamp = timestamp;
}
@Override
public void execute(Manager m) throws Throwable {
m.sendReceipt(address, timestamp);
}
@Override
public boolean equals(final Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
final var that = (SendReceiptAction) o;
return timestamp == that.timestamp && address.equals(that.address);
}
@Override
public int hashCode() {
return Objects.hash(address, timestamp);
}
}
class SendSyncContactsAction implements HandleAction {
private static final SendSyncContactsAction INSTANCE = new SendSyncContactsAction();
private SendSyncContactsAction() {
}
public static SendSyncContactsAction create() {
return INSTANCE;
}
@Override
public void execute(Manager m) throws Throwable {
m.sendContacts();
}
}
class SendSyncGroupsAction implements HandleAction {
private static final SendSyncGroupsAction INSTANCE = new SendSyncGroupsAction();
private SendSyncGroupsAction() {
}
public static SendSyncGroupsAction create() {
return INSTANCE;
}
@Override
public void execute(Manager m) throws Throwable {
m.sendGroups();
}
}
class SendSyncBlockedListAction implements HandleAction {
private static final SendSyncBlockedListAction INSTANCE = new SendSyncBlockedListAction();
private SendSyncBlockedListAction() {
}
public static SendSyncBlockedListAction create() {
return INSTANCE;
}
@Override
public void execute(Manager m) throws Throwable {
m.sendBlockedList();
}
}
class SendGroupInfoRequestAction implements HandleAction {
private final SignalServiceAddress address;
private final GroupIdV1 groupId;
public SendGroupInfoRequestAction(final SignalServiceAddress address, final GroupIdV1 groupId) {
this.address = address;
this.groupId = groupId;
}
@Override
public void execute(Manager m) throws Throwable {
m.sendGroupInfoRequest(groupId, address);
}
@Override
public boolean equals(final Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
final var that = (SendGroupInfoRequestAction) o;
if (!address.equals(that.address)) return false;
return groupId.equals(that.groupId);
}
@Override
public int hashCode() {
var result = address.hashCode();
result = 31 * result + groupId.hashCode();
return result;
}
}
class SendGroupInfoAction implements HandleAction {
private final SignalServiceAddress address;
private final GroupIdV1 groupId;
public SendGroupInfoAction(final SignalServiceAddress address, final GroupIdV1 groupId) {
this.address = address;
this.groupId = groupId;
}
@Override
public void execute(Manager m) throws Throwable {
m.sendGroupInfoMessage(groupId, address);
}
@Override
public boolean equals(final Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
final var that = (SendGroupInfoAction) o;
if (!address.equals(that.address)) return false;
return groupId.equals(that.groupId);
}
@Override
public int hashCode() {
var result = address.hashCode();
result = 31 * result + groupId.hashCode();
return result;
}
}

View file

@ -0,0 +1,29 @@
package org.asamk.signal.manager;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.List;
public class JsonStickerPack {
@JsonProperty
public String title;
@JsonProperty
public String author;
@JsonProperty
public JsonSticker cover;
@JsonProperty
public List<JsonSticker> stickers;
public static class JsonSticker {
@JsonProperty
public String emoji;
@JsonProperty
public String file;
}
}

View file

@ -0,0 +1,41 @@
package org.asamk.signal.manager;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.libsignal.logging.SignalProtocolLogger;
import org.whispersystems.libsignal.logging.SignalProtocolLoggerProvider;
public class LibSignalLogger implements SignalProtocolLogger {
private final static Logger logger = LoggerFactory.getLogger("LibSignal");
public static void initLogger() {
SignalProtocolLoggerProvider.setProvider(new LibSignalLogger());
}
private LibSignalLogger() {
}
@Override
public void log(final int priority, final String tag, final String message) {
final var logMessage = String.format("[%s]: %s", tag, message);
switch (priority) {
case SignalProtocolLogger.VERBOSE:
logger.trace(logMessage);
break;
case SignalProtocolLogger.DEBUG:
logger.debug(logMessage);
break;
case SignalProtocolLogger.INFO:
logger.info(logMessage);
break;
case SignalProtocolLogger.WARN:
logger.warn(logMessage);
break;
case SignalProtocolLogger.ERROR:
case SignalProtocolLogger.ASSERT:
logger.error(logMessage);
break;
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,8 @@
package org.asamk.signal.manager;
public class NotRegisteredException extends Exception {
public NotRegisteredException() {
super("User is not registered.");
}
}

View file

@ -0,0 +1,34 @@
package org.asamk.signal.manager;
import java.io.File;
public class PathConfig {
private final File dataPath;
private final File attachmentsPath;
private final File avatarsPath;
public static PathConfig createDefault(final File settingsPath) {
return new PathConfig(new File(settingsPath, "data"),
new File(settingsPath, "attachments"),
new File(settingsPath, "avatars"));
}
private PathConfig(final File dataPath, final File attachmentsPath, final File avatarsPath) {
this.dataPath = dataPath;
this.attachmentsPath = attachmentsPath;
this.avatarsPath = avatarsPath;
}
public File getDataPath() {
return dataPath;
}
public File getAttachmentsPath() {
return attachmentsPath;
}
public File getAvatarsPath() {
return avatarsPath;
}
}

View file

@ -0,0 +1,170 @@
/*
Copyright (C) 2015-2021 AsamK and contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.asamk.signal.manager;
import org.asamk.signal.manager.config.ServiceConfig;
import org.asamk.signal.manager.config.ServiceEnvironment;
import org.asamk.signal.manager.config.ServiceEnvironmentConfig;
import org.asamk.signal.manager.storage.SignalAccount;
import org.asamk.signal.manager.util.KeyUtils;
import org.signal.zkgroup.InvalidInputException;
import org.signal.zkgroup.profiles.ProfileKey;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.libsignal.IdentityKeyPair;
import org.whispersystems.libsignal.InvalidKeyException;
import org.whispersystems.libsignal.util.KeyHelper;
import org.whispersystems.signalservice.api.SignalServiceAccountManager;
import org.whispersystems.signalservice.api.groupsv2.ClientZkOperations;
import org.whispersystems.signalservice.api.groupsv2.GroupsV2Operations;
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
import org.whispersystems.signalservice.api.util.SleepTimer;
import org.whispersystems.signalservice.api.util.UptimeSleepTimer;
import org.whispersystems.signalservice.internal.util.DynamicCredentialsProvider;
import java.io.File;
import java.io.IOException;
import java.net.URI;
import java.util.concurrent.TimeoutException;
public class ProvisioningManager {
private final static Logger logger = LoggerFactory.getLogger(ProvisioningManager.class);
private final PathConfig pathConfig;
private final ServiceEnvironmentConfig serviceEnvironmentConfig;
private final String userAgent;
private final SignalServiceAccountManager accountManager;
private final IdentityKeyPair identityKey;
private final int registrationId;
private final String password;
ProvisioningManager(PathConfig pathConfig, ServiceEnvironmentConfig serviceEnvironmentConfig, String userAgent) {
this.pathConfig = pathConfig;
this.serviceEnvironmentConfig = serviceEnvironmentConfig;
this.userAgent = userAgent;
identityKey = KeyUtils.generateIdentityKeyPair();
registrationId = KeyHelper.generateRegistrationId(false);
password = KeyUtils.createPassword();
final SleepTimer timer = new UptimeSleepTimer();
GroupsV2Operations groupsV2Operations;
try {
groupsV2Operations = new GroupsV2Operations(ClientZkOperations.create(serviceEnvironmentConfig.getSignalServiceConfiguration()));
} catch (Throwable ignored) {
groupsV2Operations = null;
}
accountManager = new SignalServiceAccountManager(serviceEnvironmentConfig.getSignalServiceConfiguration(),
new DynamicCredentialsProvider(null, null, password, SignalServiceAddress.DEFAULT_DEVICE_ID),
userAgent,
groupsV2Operations,
ServiceConfig.AUTOMATIC_NETWORK_RETRY,
timer);
}
public static ProvisioningManager init(
File settingsPath, ServiceEnvironment serviceEnvironment, String userAgent
) {
var pathConfig = PathConfig.createDefault(settingsPath);
final var serviceConfiguration = ServiceConfig.getServiceEnvironmentConfig(serviceEnvironment, userAgent);
return new ProvisioningManager(pathConfig, serviceConfiguration, userAgent);
}
public URI getDeviceLinkUri() throws TimeoutException, IOException {
var deviceUuid = accountManager.getNewDeviceUuid();
return new DeviceLinkInfo(deviceUuid, identityKey.getPublicKey().getPublicKey()).createDeviceLinkUri();
}
public Manager finishDeviceLink(String deviceName) throws IOException, InvalidKeyException, TimeoutException, UserAlreadyExists {
var ret = accountManager.finishNewDeviceRegistration(identityKey, false, true, registrationId, deviceName);
var username = ret.getNumber();
// TODO do this check before actually registering
if (SignalAccount.userExists(pathConfig.getDataPath(), username)) {
throw new UserAlreadyExists(username, SignalAccount.getFileName(pathConfig.getDataPath(), username));
}
// Create new account with the synced identity
var profileKeyBytes = ret.getProfileKey();
ProfileKey profileKey;
if (profileKeyBytes == null) {
profileKey = KeyUtils.createProfileKey();
} else {
try {
profileKey = new ProfileKey(profileKeyBytes);
} catch (InvalidInputException e) {
throw new IOException("Received invalid profileKey", e);
}
}
SignalAccount account = null;
try {
account = SignalAccount.createLinkedAccount(pathConfig.getDataPath(),
username,
ret.getUuid(),
password,
ret.getDeviceId(),
ret.getIdentity(),
registrationId,
profileKey);
account.save();
Manager m = null;
try {
m = new Manager(account, pathConfig, serviceEnvironmentConfig, userAgent);
try {
m.refreshPreKeys();
} catch (Exception e) {
logger.error("Failed to refresh prekeys.");
throw e;
}
try {
m.requestSyncGroups();
m.requestSyncContacts();
m.requestSyncBlocked();
m.requestSyncConfiguration();
m.requestSyncKeys();
} catch (Exception e) {
logger.error("Failed to request sync messages from linked device.");
throw e;
}
account.save();
final var result = m;
account = null;
m = null;
return result;
} finally {
if (m != null) {
m.close();
}
}
} finally {
if (account != null) {
account.close();
}
}
}
}

View file

@ -0,0 +1,214 @@
/*
Copyright (C) 2015-2021 AsamK and contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.asamk.signal.manager;
import org.asamk.signal.manager.config.ServiceConfig;
import org.asamk.signal.manager.config.ServiceEnvironment;
import org.asamk.signal.manager.config.ServiceEnvironmentConfig;
import org.asamk.signal.manager.helper.PinHelper;
import org.asamk.signal.manager.storage.SignalAccount;
import org.asamk.signal.manager.util.KeyUtils;
import org.whispersystems.libsignal.util.KeyHelper;
import org.whispersystems.libsignal.util.guava.Optional;
import org.whispersystems.signalservice.api.KeyBackupServicePinException;
import org.whispersystems.signalservice.api.KeyBackupSystemNoDataException;
import org.whispersystems.signalservice.api.SignalServiceAccountManager;
import org.whispersystems.signalservice.api.groupsv2.ClientZkOperations;
import org.whispersystems.signalservice.api.groupsv2.GroupsV2Operations;
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
import org.whispersystems.signalservice.api.util.SleepTimer;
import org.whispersystems.signalservice.api.util.UptimeSleepTimer;
import org.whispersystems.signalservice.api.util.UuidUtil;
import org.whispersystems.signalservice.internal.push.LockedException;
import org.whispersystems.signalservice.internal.push.VerifyAccountResponse;
import org.whispersystems.signalservice.internal.util.DynamicCredentialsProvider;
import java.io.Closeable;
import java.io.File;
import java.io.IOException;
import java.util.Locale;
public class RegistrationManager implements Closeable {
private SignalAccount account;
private final PathConfig pathConfig;
private final ServiceEnvironmentConfig serviceEnvironmentConfig;
private final String userAgent;
private final SignalServiceAccountManager accountManager;
private final PinHelper pinHelper;
public RegistrationManager(
SignalAccount account,
PathConfig pathConfig,
ServiceEnvironmentConfig serviceEnvironmentConfig,
String userAgent
) {
this.account = account;
this.pathConfig = pathConfig;
this.serviceEnvironmentConfig = serviceEnvironmentConfig;
this.userAgent = userAgent;
final SleepTimer timer = new UptimeSleepTimer();
GroupsV2Operations groupsV2Operations;
try {
groupsV2Operations = new GroupsV2Operations(ClientZkOperations.create(serviceEnvironmentConfig.getSignalServiceConfiguration()));
} catch (Throwable ignored) {
groupsV2Operations = null;
}
this.accountManager = new SignalServiceAccountManager(serviceEnvironmentConfig.getSignalServiceConfiguration(),
new DynamicCredentialsProvider(
// Using empty UUID, because registering doesn't work otherwise
null, account.getUsername(), account.getPassword(), SignalServiceAddress.DEFAULT_DEVICE_ID),
userAgent,
groupsV2Operations,
ServiceConfig.AUTOMATIC_NETWORK_RETRY,
timer);
final var keyBackupService = accountManager.getKeyBackupService(ServiceConfig.getIasKeyStore(),
serviceEnvironmentConfig.getKeyBackupConfig().getEnclaveName(),
serviceEnvironmentConfig.getKeyBackupConfig().getServiceId(),
serviceEnvironmentConfig.getKeyBackupConfig().getMrenclave(),
10);
this.pinHelper = new PinHelper(keyBackupService);
}
public static RegistrationManager init(
String username, File settingsPath, ServiceEnvironment serviceEnvironment, String userAgent
) throws IOException {
var pathConfig = PathConfig.createDefault(settingsPath);
final var serviceConfiguration = ServiceConfig.getServiceEnvironmentConfig(serviceEnvironment, userAgent);
if (!SignalAccount.userExists(pathConfig.getDataPath(), username)) {
var identityKey = KeyUtils.generateIdentityKeyPair();
var registrationId = KeyHelper.generateRegistrationId(false);
var profileKey = KeyUtils.createProfileKey();
var account = SignalAccount.create(pathConfig.getDataPath(),
username,
identityKey,
registrationId,
profileKey);
account.save();
return new RegistrationManager(account, pathConfig, serviceConfiguration, userAgent);
}
var account = SignalAccount.load(pathConfig.getDataPath(), username);
return new RegistrationManager(account, pathConfig, serviceConfiguration, userAgent);
}
public void register(boolean voiceVerification, String captcha) throws IOException {
if (account.getPassword() == null) {
account.setPassword(KeyUtils.createPassword());
}
if (voiceVerification) {
accountManager.requestVoiceVerificationCode(Locale.getDefault(),
Optional.fromNullable(captcha),
Optional.absent());
} else {
accountManager.requestSmsVerificationCode(false, Optional.fromNullable(captcha), Optional.absent());
}
account.save();
}
public Manager verifyAccount(
String verificationCode, String pin
) throws IOException, KeyBackupSystemNoDataException, KeyBackupServicePinException {
verificationCode = verificationCode.replace("-", "");
VerifyAccountResponse response;
try {
response = verifyAccountWithCode(verificationCode, pin, null);
account.setPinMasterKey(null);
} catch (LockedException e) {
if (pin == null) {
throw e;
}
var registrationLockData = pinHelper.getRegistrationLockData(pin, e);
if (registrationLockData == null) {
throw e;
}
var registrationLock = registrationLockData.getMasterKey().deriveRegistrationLock();
try {
response = verifyAccountWithCode(verificationCode, null, registrationLock);
} catch (LockedException _e) {
throw new AssertionError("KBS Pin appeared to matched but reg lock still failed!");
}
account.setPinMasterKey(registrationLockData.getMasterKey());
}
// TODO response.isStorageCapable()
//accountManager.setGcmId(Optional.of(GoogleCloudMessaging.getInstance(this).register(REGISTRATION_ID)));
account.setDeviceId(SignalServiceAddress.DEFAULT_DEVICE_ID);
account.setMultiDevice(false);
account.setRegistered(true);
account.setUuid(UuidUtil.parseOrNull(response.getUuid()));
account.setRegistrationLockPin(pin);
account.getSignalProtocolStore().archiveAllSessions();
account.getSignalProtocolStore()
.saveIdentity(account.getSelfAddress(),
account.getSignalProtocolStore().getIdentityKeyPair().getPublicKey(),
TrustLevel.TRUSTED_VERIFIED);
Manager m = null;
try {
m = new Manager(account, pathConfig, serviceEnvironmentConfig, userAgent);
m.refreshPreKeys();
account.save();
final var result = m;
account = null;
m = null;
return result;
} finally {
if (m != null) {
m.close();
}
}
}
private VerifyAccountResponse verifyAccountWithCode(
final String verificationCode, final String legacyPin, final String registrationLock
) throws IOException {
return accountManager.verifyAccountWithCode(verificationCode,
null,
account.getSignalProtocolStore().getLocalRegistrationId(),
true,
legacyPin,
registrationLock,
account.getSelfUnidentifiedAccessKey(),
account.isUnrestrictedUnidentifiedAccess(),
ServiceConfig.capabilities,
account.isDiscoverableByPhoneNumber());
}
@Override
public void close() throws IOException {
if (account != null) {
account.close();
account = null;
}
}
}

View file

@ -0,0 +1,8 @@
package org.asamk.signal.manager;
public class StickerPackInvalidException extends Exception {
public StickerPackInvalidException(String message) {
super(message);
}
}

View file

@ -0,0 +1,42 @@
package org.asamk.signal.manager;
import org.whispersystems.signalservice.api.messages.multidevice.VerifiedMessage;
public enum TrustLevel {
UNTRUSTED,
TRUSTED_UNVERIFIED,
TRUSTED_VERIFIED;
private static TrustLevel[] cachedValues = null;
public static TrustLevel fromInt(int i) {
if (TrustLevel.cachedValues == null) {
TrustLevel.cachedValues = TrustLevel.values();
}
return TrustLevel.cachedValues[i];
}
public static TrustLevel fromVerifiedState(VerifiedMessage.VerifiedState verifiedState) {
switch (verifiedState) {
case DEFAULT:
return TRUSTED_UNVERIFIED;
case UNVERIFIED:
return UNTRUSTED;
case VERIFIED:
return TRUSTED_VERIFIED;
}
throw new RuntimeException("Unknown verified state: " + verifiedState);
}
public VerifiedMessage.VerifiedState toVerifiedState() {
switch (this) {
case TRUSTED_UNVERIFIED:
return VerifiedMessage.VerifiedState.DEFAULT;
case UNTRUSTED:
return VerifiedMessage.VerifiedState.UNVERIFIED;
case TRUSTED_VERIFIED:
return VerifiedMessage.VerifiedState.VERIFIED;
}
throw new RuntimeException("Unknown verified state: " + this);
}
}

View file

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

View file

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

View file

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

View file

@ -0,0 +1,85 @@
package org.asamk.signal.manager.config;
import org.bouncycastle.util.encoders.Hex;
import org.whispersystems.libsignal.InvalidKeyException;
import org.whispersystems.libsignal.ecc.Curve;
import org.whispersystems.libsignal.ecc.ECPublicKey;
import org.whispersystems.libsignal.util.guava.Optional;
import org.whispersystems.signalservice.api.push.TrustStore;
import org.whispersystems.signalservice.internal.configuration.SignalCdnUrl;
import org.whispersystems.signalservice.internal.configuration.SignalContactDiscoveryUrl;
import org.whispersystems.signalservice.internal.configuration.SignalKeyBackupServiceUrl;
import org.whispersystems.signalservice.internal.configuration.SignalProxy;
import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration;
import org.whispersystems.signalservice.internal.configuration.SignalServiceUrl;
import org.whispersystems.signalservice.internal.configuration.SignalStorageUrl;
import java.util.Base64;
import java.util.List;
import java.util.Map;
import okhttp3.Dns;
import okhttp3.Interceptor;
class LiveConfig {
private final static byte[] UNIDENTIFIED_SENDER_TRUST_ROOT = Base64.getDecoder()
.decode("BXu6QIKVz5MA8gstzfOgRQGqyLqOwNKHL6INkv3IHWMF");
private final static String CDS_MRENCLAVE = "c98e00a4e3ff977a56afefe7362a27e4961e4f19e211febfbb19b897e6b80b15";
private final static String KEY_BACKUP_ENCLAVE_NAME = "fe7c1bfae98f9b073d220366ea31163ee82f6d04bead774f71ca8e5c40847bfe";
private final static byte[] KEY_BACKUP_SERVICE_ID = Hex.decode(
"fe7c1bfae98f9b073d220366ea31163ee82f6d04bead774f71ca8e5c40847bfe");
private final static String KEY_BACKUP_MRENCLAVE = "a3baab19ef6ce6f34ab9ebb25ba722725ae44a8872dc0ff08ad6d83a9489de87";
private final static String URL = "https://textsecure-service.whispersystems.org";
private final static String CDN_URL = "https://cdn.signal.org";
private final static String CDN2_URL = "https://cdn2.signal.org";
private final static String SIGNAL_CONTACT_DISCOVERY_URL = "https://api.directory.signal.org";
private final static String SIGNAL_KEY_BACKUP_URL = "https://api.backup.signal.org";
private final static String STORAGE_URL = "https://storage.signal.org";
private final static TrustStore TRUST_STORE = new WhisperTrustStore();
private final static Optional<Dns> dns = Optional.absent();
private final static Optional<SignalProxy> proxy = Optional.absent();
private final static byte[] zkGroupServerPublicParams = Base64.getDecoder()
.decode("AMhf5ywVwITZMsff/eCyudZx9JDmkkkbV6PInzG4p8x3VqVJSFiMvnvlEKWuRob/1eaIetR31IYeAbm0NdOuHH8Qi+Rexi1wLlpzIo1gstHWBfZzy1+qHRV5A4TqPp15YzBPm0WSggW6PbSn+F4lf57VCnHF7p8SvzAA2ZZJPYJURt8X7bbg+H3i+PEjH9DXItNEqs2sNcug37xZQDLm7X0=");
static SignalServiceConfiguration createDefaultServiceConfiguration(
final List<Interceptor> interceptors
) {
return new SignalServiceConfiguration(new SignalServiceUrl[]{new SignalServiceUrl(URL, TRUST_STORE)},
Map.of(0,
new SignalCdnUrl[]{new SignalCdnUrl(CDN_URL, TRUST_STORE)},
2,
new SignalCdnUrl[]{new SignalCdnUrl(CDN2_URL, TRUST_STORE)}),
new SignalContactDiscoveryUrl[]{new SignalContactDiscoveryUrl(SIGNAL_CONTACT_DISCOVERY_URL,
TRUST_STORE)},
new SignalKeyBackupServiceUrl[]{new SignalKeyBackupServiceUrl(SIGNAL_KEY_BACKUP_URL, TRUST_STORE)},
new SignalStorageUrl[]{new SignalStorageUrl(STORAGE_URL, TRUST_STORE)},
interceptors,
dns,
proxy,
zkGroupServerPublicParams);
}
static ECPublicKey getUnidentifiedSenderTrustRoot() {
try {
return Curve.decodePoint(UNIDENTIFIED_SENDER_TRUST_ROOT, 0);
} catch (InvalidKeyException e) {
throw new AssertionError(e);
}
}
static KeyBackupConfig createKeyBackupConfig() {
return new KeyBackupConfig(KEY_BACKUP_ENCLAVE_NAME, KEY_BACKUP_SERVICE_ID, KEY_BACKUP_MRENCLAVE);
}
static String getCdsMrenclave() {
return CDS_MRENCLAVE;
}
private LiveConfig() {
}
}

View file

@ -0,0 +1,85 @@
package org.asamk.signal.manager.config;
import org.bouncycastle.util.encoders.Hex;
import org.whispersystems.libsignal.InvalidKeyException;
import org.whispersystems.libsignal.ecc.Curve;
import org.whispersystems.libsignal.ecc.ECPublicKey;
import org.whispersystems.libsignal.util.guava.Optional;
import org.whispersystems.signalservice.api.push.TrustStore;
import org.whispersystems.signalservice.internal.configuration.SignalCdnUrl;
import org.whispersystems.signalservice.internal.configuration.SignalContactDiscoveryUrl;
import org.whispersystems.signalservice.internal.configuration.SignalKeyBackupServiceUrl;
import org.whispersystems.signalservice.internal.configuration.SignalProxy;
import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration;
import org.whispersystems.signalservice.internal.configuration.SignalServiceUrl;
import org.whispersystems.signalservice.internal.configuration.SignalStorageUrl;
import java.util.Base64;
import java.util.List;
import java.util.Map;
import okhttp3.Dns;
import okhttp3.Interceptor;
class SandboxConfig {
private final static byte[] UNIDENTIFIED_SENDER_TRUST_ROOT = Base64.getDecoder()
.decode("BbqY1DzohE4NUZoVF+L18oUPrK3kILllLEJh2UnPSsEx");
private final static String CDS_MRENCLAVE = "c98e00a4e3ff977a56afefe7362a27e4961e4f19e211febfbb19b897e6b80b15";
private final static String KEY_BACKUP_ENCLAVE_NAME = "823a3b2c037ff0cbe305cc48928cfcc97c9ed4a8ca6d49af6f7d6981fb60a4e9";
private final static byte[] KEY_BACKUP_SERVICE_ID = Hex.decode(
"51a56084c0b21c6b8f62b1bc792ec9bedac4c7c3964bb08ddcab868158c09982");
private final static String KEY_BACKUP_MRENCLAVE = "a3baab19ef6ce6f34ab9ebb25ba722725ae44a8872dc0ff08ad6d83a9489de87";
private final static String URL = "https://textsecure-service-staging.whispersystems.org";
private final static String CDN_URL = "https://cdn-staging.signal.org";
private final static String CDN2_URL = "https://cdn2-staging.signal.org";
private final static String SIGNAL_CONTACT_DISCOVERY_URL = "https://api-staging.directory.signal.org";
private final static String SIGNAL_KEY_BACKUP_URL = "https://api-staging.backup.signal.org";
private final static String STORAGE_URL = "https://storage-staging.signal.org";
private final static TrustStore TRUST_STORE = new WhisperTrustStore();
private final static Optional<Dns> dns = Optional.absent();
private final static Optional<SignalProxy> proxy = Optional.absent();
private final static byte[] zkGroupServerPublicParams = Base64.getDecoder()
.decode("ABSY21VckQcbSXVNCGRYJcfWHiAMZmpTtTELcDmxgdFbtp/bWsSxZdMKzfCp8rvIs8ocCU3B37fT3r4Mi5qAemeGeR2X+/YmOGR5ofui7tD5mDQfstAI9i+4WpMtIe8KC3wU5w3Inq3uNWVmoGtpKndsNfwJrCg0Hd9zmObhypUnSkfYn2ooMOOnBpfdanRtrvetZUayDMSC5iSRcXKpdls=");
static SignalServiceConfiguration createDefaultServiceConfiguration(
final List<Interceptor> interceptors
) {
return new SignalServiceConfiguration(new SignalServiceUrl[]{new SignalServiceUrl(URL, TRUST_STORE)},
Map.of(0,
new SignalCdnUrl[]{new SignalCdnUrl(CDN_URL, TRUST_STORE)},
2,
new SignalCdnUrl[]{new SignalCdnUrl(CDN2_URL, TRUST_STORE)}),
new SignalContactDiscoveryUrl[]{new SignalContactDiscoveryUrl(SIGNAL_CONTACT_DISCOVERY_URL,
TRUST_STORE)},
new SignalKeyBackupServiceUrl[]{new SignalKeyBackupServiceUrl(SIGNAL_KEY_BACKUP_URL, TRUST_STORE)},
new SignalStorageUrl[]{new SignalStorageUrl(STORAGE_URL, TRUST_STORE)},
interceptors,
dns,
proxy,
zkGroupServerPublicParams);
}
static ECPublicKey getUnidentifiedSenderTrustRoot() {
try {
return Curve.decodePoint(UNIDENTIFIED_SENDER_TRUST_ROOT, 0);
} catch (InvalidKeyException e) {
throw new AssertionError(e);
}
}
static KeyBackupConfig createKeyBackupConfig() {
return new KeyBackupConfig(KEY_BACKUP_ENCLAVE_NAME, KEY_BACKUP_SERVICE_ID, KEY_BACKUP_MRENCLAVE);
}
static String getCdsMrenclave() {
return CDS_MRENCLAVE;
}
private SandboxConfig() {
}
}

View file

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

View file

@ -0,0 +1,6 @@
package org.asamk.signal.manager.config;
public enum ServiceEnvironment {
LIVE,
SANDBOX,
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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() + ")");
}
}

View file

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

View file

@ -0,0 +1,387 @@
package org.asamk.signal.manager.helper;
import com.google.protobuf.InvalidProtocolBufferException;
import org.asamk.signal.manager.groups.GroupLinkPassword;
import org.asamk.signal.manager.groups.GroupUtils;
import org.asamk.signal.manager.storage.groups.GroupInfoV2;
import org.asamk.signal.manager.storage.profiles.SignalProfile;
import org.asamk.signal.manager.util.IOUtils;
import org.signal.storageservice.protos.groups.AccessControl;
import org.signal.storageservice.protos.groups.GroupChange;
import org.signal.storageservice.protos.groups.Member;
import org.signal.storageservice.protos.groups.local.DecryptedGroup;
import org.signal.storageservice.protos.groups.local.DecryptedGroupChange;
import org.signal.storageservice.protos.groups.local.DecryptedGroupJoinInfo;
import org.signal.storageservice.protos.groups.local.DecryptedPendingMember;
import org.signal.zkgroup.InvalidInputException;
import org.signal.zkgroup.VerificationFailedException;
import org.signal.zkgroup.groups.GroupMasterKey;
import org.signal.zkgroup.groups.GroupSecretParams;
import org.signal.zkgroup.groups.UuidCiphertext;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.libsignal.util.Pair;
import org.whispersystems.libsignal.util.guava.Optional;
import org.whispersystems.signalservice.api.groupsv2.DecryptedGroupUtil;
import org.whispersystems.signalservice.api.groupsv2.GroupCandidate;
import org.whispersystems.signalservice.api.groupsv2.GroupLinkNotActiveException;
import org.whispersystems.signalservice.api.groupsv2.GroupsV2Api;
import org.whispersystems.signalservice.api.groupsv2.GroupsV2AuthorizationString;
import org.whispersystems.signalservice.api.groupsv2.GroupsV2Operations;
import org.whispersystems.signalservice.api.groupsv2.InvalidGroupStateException;
import org.whispersystems.signalservice.api.groupsv2.NotAbleToApplyGroupV2ChangeException;
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
import org.whispersystems.signalservice.api.util.UuidUtil;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.Collection;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;
public class GroupHelper {
private final static Logger logger = LoggerFactory.getLogger(GroupHelper.class);
private final ProfileKeyCredentialProvider profileKeyCredentialProvider;
private final ProfileProvider profileProvider;
private final SelfAddressProvider selfAddressProvider;
private final GroupsV2Operations groupsV2Operations;
private final GroupsV2Api groupsV2Api;
private final GroupAuthorizationProvider groupAuthorizationProvider;
public GroupHelper(
final ProfileKeyCredentialProvider profileKeyCredentialProvider,
final ProfileProvider profileProvider,
final SelfAddressProvider selfAddressProvider,
final GroupsV2Operations groupsV2Operations,
final GroupsV2Api groupsV2Api,
final GroupAuthorizationProvider groupAuthorizationProvider
) {
this.profileKeyCredentialProvider = profileKeyCredentialProvider;
this.profileProvider = profileProvider;
this.selfAddressProvider = selfAddressProvider;
this.groupsV2Operations = groupsV2Operations;
this.groupsV2Api = groupsV2Api;
this.groupAuthorizationProvider = groupAuthorizationProvider;
}
public DecryptedGroup getDecryptedGroup(final GroupSecretParams groupSecretParams) {
try {
final var groupsV2AuthorizationString = groupAuthorizationProvider.getAuthorizationForToday(
groupSecretParams);
return groupsV2Api.getGroup(groupSecretParams, groupsV2AuthorizationString);
} catch (IOException | VerificationFailedException | InvalidGroupStateException e) {
logger.warn("Failed to retrieve Group V2 info, ignoring: {}", e.getMessage());
return null;
}
}
public DecryptedGroupJoinInfo getDecryptedGroupJoinInfo(
GroupMasterKey groupMasterKey, GroupLinkPassword password
) throws IOException, GroupLinkNotActiveException {
var groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupMasterKey);
return groupsV2Api.getGroupJoinInfo(groupSecretParams,
Optional.fromNullable(password).transform(GroupLinkPassword::serialize),
groupAuthorizationProvider.getAuthorizationForToday(groupSecretParams));
}
public GroupInfoV2 createGroupV2(
String name, Collection<SignalServiceAddress> members, File avatarFile
) throws IOException {
final var avatarBytes = readAvatarBytes(avatarFile);
final var newGroup = buildNewGroupV2(name, members, avatarBytes);
if (newGroup == null) {
return null;
}
final var groupSecretParams = newGroup.getGroupSecretParams();
final GroupsV2AuthorizationString groupAuthForToday;
final DecryptedGroup decryptedGroup;
try {
groupAuthForToday = groupAuthorizationProvider.getAuthorizationForToday(groupSecretParams);
groupsV2Api.putNewGroup(newGroup, groupAuthForToday);
decryptedGroup = groupsV2Api.getGroup(groupSecretParams, groupAuthForToday);
} catch (IOException | VerificationFailedException | InvalidGroupStateException e) {
logger.warn("Failed to create V2 group: {}", e.getMessage());
return null;
}
if (decryptedGroup == null) {
logger.warn("Failed to create V2 group, unknown error!");
return null;
}
final var groupId = GroupUtils.getGroupIdV2(groupSecretParams);
final var masterKey = groupSecretParams.getMasterKey();
var g = new GroupInfoV2(groupId, masterKey);
g.setGroup(decryptedGroup);
return g;
}
private byte[] readAvatarBytes(final File avatarFile) throws IOException {
final byte[] avatarBytes;
try (InputStream avatar = avatarFile == null ? null : new FileInputStream(avatarFile)) {
avatarBytes = avatar == null ? null : IOUtils.readFully(avatar);
}
return avatarBytes;
}
private GroupsV2Operations.NewGroup buildNewGroupV2(
String name, Collection<SignalServiceAddress> members, byte[] avatar
) {
final var profileKeyCredential = profileKeyCredentialProvider.getProfileKeyCredential(selfAddressProvider.getSelfAddress());
if (profileKeyCredential == null) {
logger.warn("Cannot create a V2 group as self does not have a versioned profile");
return null;
}
if (!areMembersValid(members)) return null;
var self = new GroupCandidate(selfAddressProvider.getSelfAddress().getUuid().orNull(),
Optional.fromNullable(profileKeyCredential));
var candidates = members.stream()
.map(member -> new GroupCandidate(member.getUuid().get(),
Optional.fromNullable(profileKeyCredentialProvider.getProfileKeyCredential(member))))
.collect(Collectors.toSet());
final var groupSecretParams = GroupSecretParams.generate();
return groupsV2Operations.createNewGroup(groupSecretParams,
name,
Optional.fromNullable(avatar),
self,
candidates,
Member.Role.DEFAULT,
0);
}
private boolean areMembersValid(final Collection<SignalServiceAddress> members) {
final var noUuidCapability = members.stream()
.filter(address -> !address.getUuid().isPresent())
.map(SignalServiceAddress::getLegacyIdentifier)
.collect(Collectors.toSet());
if (noUuidCapability.size() > 0) {
logger.warn("Cannot create a V2 group as some members don't have a UUID: {}",
String.join(", ", noUuidCapability));
return false;
}
final var noGv2Capability = members.stream()
.map(profileProvider::getProfile)
.filter(profile -> profile != null && !profile.getCapabilities().gv2)
.collect(Collectors.toSet());
if (noGv2Capability.size() > 0) {
logger.warn("Cannot create a V2 group as some members don't support Groups V2: {}",
noGv2Capability.stream().map(SignalProfile::getDisplayName).collect(Collectors.joining(", ")));
return false;
}
return true;
}
public Pair<DecryptedGroup, GroupChange> updateGroupV2(
GroupInfoV2 groupInfoV2, String name, File avatarFile
) throws IOException {
final var groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupInfoV2.getMasterKey());
var groupOperations = groupsV2Operations.forGroup(groupSecretParams);
var change = name != null ? groupOperations.createModifyGroupTitle(name) : GroupChange.Actions.newBuilder();
if (avatarFile != null) {
final var avatarBytes = readAvatarBytes(avatarFile);
var avatarCdnKey = groupsV2Api.uploadAvatar(avatarBytes,
groupSecretParams,
groupAuthorizationProvider.getAuthorizationForToday(groupSecretParams));
change.setModifyAvatar(GroupChange.Actions.ModifyAvatarAction.newBuilder().setAvatar(avatarCdnKey));
}
final var uuid = this.selfAddressProvider.getSelfAddress().getUuid();
if (uuid.isPresent()) {
change.setSourceUuid(UuidUtil.toByteString(uuid.get()));
}
return commitChange(groupInfoV2, change);
}
public Pair<DecryptedGroup, GroupChange> updateGroupV2(
GroupInfoV2 groupInfoV2, Set<SignalServiceAddress> newMembers
) throws IOException {
final var groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupInfoV2.getMasterKey());
var groupOperations = groupsV2Operations.forGroup(groupSecretParams);
if (!areMembersValid(newMembers)) {
throw new IOException("Failed to update group");
}
var candidates = newMembers.stream()
.map(member -> new GroupCandidate(member.getUuid().get(),
Optional.fromNullable(profileKeyCredentialProvider.getProfileKeyCredential(member))))
.collect(Collectors.toSet());
final var change = groupOperations.createModifyGroupMembershipChange(candidates,
selfAddressProvider.getSelfAddress().getUuid().get());
final var uuid = this.selfAddressProvider.getSelfAddress().getUuid();
if (uuid.isPresent()) {
change.setSourceUuid(UuidUtil.toByteString(uuid.get()));
}
return commitChange(groupInfoV2, change);
}
public Pair<DecryptedGroup, GroupChange> leaveGroup(GroupInfoV2 groupInfoV2) throws IOException {
var pendingMembersList = groupInfoV2.getGroup().getPendingMembersList();
final var selfUuid = selfAddressProvider.getSelfAddress().getUuid().get();
var selfPendingMember = DecryptedGroupUtil.findPendingByUuid(pendingMembersList, selfUuid);
if (selfPendingMember.isPresent()) {
return revokeInvites(groupInfoV2, Set.of(selfPendingMember.get()));
} else {
return ejectMembers(groupInfoV2, Set.of(selfUuid));
}
}
public GroupChange joinGroup(
GroupMasterKey groupMasterKey,
GroupLinkPassword groupLinkPassword,
DecryptedGroupJoinInfo decryptedGroupJoinInfo
) throws IOException {
final var groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupMasterKey);
final var groupOperations = groupsV2Operations.forGroup(groupSecretParams);
final var selfAddress = this.selfAddressProvider.getSelfAddress();
final var profileKeyCredential = profileKeyCredentialProvider.getProfileKeyCredential(selfAddress);
if (profileKeyCredential == null) {
throw new IOException("Cannot join a V2 group as self does not have a versioned profile");
}
var requestToJoin = decryptedGroupJoinInfo.getAddFromInviteLink() == AccessControl.AccessRequired.ADMINISTRATOR;
var change = requestToJoin
? groupOperations.createGroupJoinRequest(profileKeyCredential)
: groupOperations.createGroupJoinDirect(profileKeyCredential);
change.setSourceUuid(UuidUtil.toByteString(selfAddress.getUuid().get()));
return commitChange(groupSecretParams, decryptedGroupJoinInfo.getRevision(), change, groupLinkPassword);
}
public Pair<DecryptedGroup, GroupChange> acceptInvite(GroupInfoV2 groupInfoV2) throws IOException {
final var groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupInfoV2.getMasterKey());
final var groupOperations = groupsV2Operations.forGroup(groupSecretParams);
final var selfAddress = this.selfAddressProvider.getSelfAddress();
final var profileKeyCredential = profileKeyCredentialProvider.getProfileKeyCredential(selfAddress);
if (profileKeyCredential == null) {
throw new IOException("Cannot join a V2 group as self does not have a versioned profile");
}
final var change = groupOperations.createAcceptInviteChange(profileKeyCredential);
final var uuid = selfAddress.getUuid();
if (uuid.isPresent()) {
change.setSourceUuid(UuidUtil.toByteString(uuid.get()));
}
return commitChange(groupInfoV2, change);
}
public Pair<DecryptedGroup, GroupChange> revokeInvites(
GroupInfoV2 groupInfoV2, Set<DecryptedPendingMember> pendingMembers
) throws IOException {
final var groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupInfoV2.getMasterKey());
final var groupOperations = groupsV2Operations.forGroup(groupSecretParams);
final var uuidCipherTexts = pendingMembers.stream().map(member -> {
try {
return new UuidCiphertext(member.getUuidCipherText().toByteArray());
} catch (InvalidInputException e) {
throw new AssertionError(e);
}
}).collect(Collectors.toSet());
return commitChange(groupInfoV2, groupOperations.createRemoveInvitationChange(uuidCipherTexts));
}
public Pair<DecryptedGroup, GroupChange> ejectMembers(GroupInfoV2 groupInfoV2, Set<UUID> uuids) throws IOException {
final var groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupInfoV2.getMasterKey());
final var groupOperations = groupsV2Operations.forGroup(groupSecretParams);
return commitChange(groupInfoV2, groupOperations.createRemoveMembersChange(uuids));
}
private Pair<DecryptedGroup, GroupChange> commitChange(
GroupInfoV2 groupInfoV2, GroupChange.Actions.Builder change
) throws IOException {
final var groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupInfoV2.getMasterKey());
final var groupOperations = groupsV2Operations.forGroup(groupSecretParams);
final var previousGroupState = groupInfoV2.getGroup();
final var nextRevision = previousGroupState.getRevision() + 1;
final var changeActions = change.setRevision(nextRevision).build();
final DecryptedGroupChange decryptedChange;
final DecryptedGroup decryptedGroupState;
try {
decryptedChange = groupOperations.decryptChange(changeActions,
selfAddressProvider.getSelfAddress().getUuid().get());
decryptedGroupState = DecryptedGroupUtil.apply(previousGroupState, decryptedChange);
} catch (VerificationFailedException | InvalidGroupStateException | NotAbleToApplyGroupV2ChangeException e) {
throw new IOException(e);
}
var signedGroupChange = groupsV2Api.patchGroup(changeActions,
groupAuthorizationProvider.getAuthorizationForToday(groupSecretParams),
Optional.absent());
return new Pair<>(decryptedGroupState, signedGroupChange);
}
private GroupChange commitChange(
GroupSecretParams groupSecretParams,
int currentRevision,
GroupChange.Actions.Builder change,
GroupLinkPassword password
) throws IOException {
final var nextRevision = currentRevision + 1;
final var changeActions = change.setRevision(nextRevision).build();
return groupsV2Api.patchGroup(changeActions,
groupAuthorizationProvider.getAuthorizationForToday(groupSecretParams),
Optional.fromNullable(password).transform(GroupLinkPassword::serialize));
}
public DecryptedGroup getUpdatedDecryptedGroup(
DecryptedGroup group, byte[] signedGroupChange, GroupMasterKey groupMasterKey
) {
try {
final var decryptedGroupChange = getDecryptedGroupChange(signedGroupChange, groupMasterKey);
if (decryptedGroupChange == null) {
return null;
}
return DecryptedGroupUtil.apply(group, decryptedGroupChange);
} catch (NotAbleToApplyGroupV2ChangeException e) {
return null;
}
}
private DecryptedGroupChange getDecryptedGroupChange(byte[] signedGroupChange, GroupMasterKey groupMasterKey) {
if (signedGroupChange != null) {
var groupOperations = groupsV2Operations.forGroup(GroupSecretParams.deriveFromMasterKey(groupMasterKey));
try {
return groupOperations.decryptChange(GroupChange.parseFrom(signedGroupChange), true).orNull();
} catch (VerificationFailedException | InvalidGroupStateException | InvalidProtocolBufferException e) {
return null;
}
}
return null;
}
}

View file

@ -0,0 +1,8 @@
package org.asamk.signal.manager.helper;
import org.whispersystems.signalservice.api.SignalServiceMessagePipe;
public interface MessagePipeProvider {
SignalServiceMessagePipe getMessagePipe(boolean unidentified);
}

View file

@ -0,0 +1,8 @@
package org.asamk.signal.manager.helper;
import org.whispersystems.signalservice.api.SignalServiceMessageReceiver;
public interface MessageReceiverProvider {
SignalServiceMessageReceiver getMessageReceiver();
}

View file

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

View file

@ -0,0 +1,138 @@
package org.asamk.signal.manager.helper;
import org.signal.zkgroup.profiles.ProfileKey;
import org.whispersystems.libsignal.util.guava.Optional;
import org.whispersystems.signalservice.api.crypto.UnidentifiedAccess;
import org.whispersystems.signalservice.api.profiles.ProfileAndCredential;
import org.whispersystems.signalservice.api.profiles.SignalServiceProfile;
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
import org.whispersystems.signalservice.api.push.exceptions.NotFoundException;
import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException;
import org.whispersystems.signalservice.internal.util.concurrent.CascadingFuture;
import org.whispersystems.signalservice.internal.util.concurrent.ListenableFuture;
import java.io.IOException;
import java.util.Arrays;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
public final class ProfileHelper {
private final ProfileKeyProvider profileKeyProvider;
private final UnidentifiedAccessProvider unidentifiedAccessProvider;
private final MessagePipeProvider messagePipeProvider;
private final MessageReceiverProvider messageReceiverProvider;
public ProfileHelper(
final ProfileKeyProvider profileKeyProvider,
final UnidentifiedAccessProvider unidentifiedAccessProvider,
final MessagePipeProvider messagePipeProvider,
final MessageReceiverProvider messageReceiverProvider
) {
this.profileKeyProvider = profileKeyProvider;
this.unidentifiedAccessProvider = unidentifiedAccessProvider;
this.messagePipeProvider = messagePipeProvider;
this.messageReceiverProvider = messageReceiverProvider;
}
public ProfileAndCredential retrieveProfileSync(
SignalServiceAddress recipient, SignalServiceProfile.RequestType requestType
) throws IOException {
try {
return retrieveProfile(recipient, requestType).get(10, TimeUnit.SECONDS);
} catch (ExecutionException e) {
if (e.getCause() instanceof PushNetworkException) {
throw (PushNetworkException) e.getCause();
} else if (e.getCause() instanceof NotFoundException) {
throw (NotFoundException) e.getCause();
} else {
throw new IOException(e);
}
} catch (InterruptedException | TimeoutException e) {
throw new PushNetworkException(e);
}
}
public ListenableFuture<ProfileAndCredential> retrieveProfile(
SignalServiceAddress address, SignalServiceProfile.RequestType requestType
) {
var unidentifiedAccess = getUnidentifiedAccess(address);
var profileKey = Optional.fromNullable(profileKeyProvider.getProfileKey(address));
if (unidentifiedAccess.isPresent()) {
return new CascadingFuture<>(Arrays.asList(() -> getPipeRetrievalFuture(address,
profileKey,
unidentifiedAccess,
requestType),
() -> getSocketRetrievalFuture(address, profileKey, unidentifiedAccess, requestType),
() -> getPipeRetrievalFuture(address, profileKey, Optional.absent(), requestType),
() -> getSocketRetrievalFuture(address, profileKey, Optional.absent(), requestType)),
e -> !(e instanceof NotFoundException));
} else {
return new CascadingFuture<>(Arrays.asList(() -> getPipeRetrievalFuture(address,
profileKey,
Optional.absent(),
requestType), () -> getSocketRetrievalFuture(address, profileKey, Optional.absent(), requestType)),
e -> !(e instanceof NotFoundException));
}
}
private ListenableFuture<ProfileAndCredential> getPipeRetrievalFuture(
SignalServiceAddress address,
Optional<ProfileKey> profileKey,
Optional<UnidentifiedAccess> unidentifiedAccess,
SignalServiceProfile.RequestType requestType
) throws IOException {
var unidentifiedPipe = messagePipeProvider.getMessagePipe(true);
var pipe = unidentifiedPipe != null && unidentifiedAccess.isPresent()
? unidentifiedPipe
: messagePipeProvider.getMessagePipe(false);
if (pipe != null) {
try {
return pipe.getProfile(address, profileKey, unidentifiedAccess, requestType);
} catch (NoClassDefFoundError e) {
// Native zkgroup lib not available for ProfileKey
if (!address.getNumber().isPresent()) {
throw new NotFoundException("Can't request profile without number");
}
var addressWithoutUuid = new SignalServiceAddress(Optional.absent(), address.getNumber());
return pipe.getProfile(addressWithoutUuid, profileKey, unidentifiedAccess, requestType);
}
}
throw new IOException("No pipe available!");
}
private ListenableFuture<ProfileAndCredential> getSocketRetrievalFuture(
SignalServiceAddress address,
Optional<ProfileKey> profileKey,
Optional<UnidentifiedAccess> unidentifiedAccess,
SignalServiceProfile.RequestType requestType
) throws NotFoundException {
var receiver = messageReceiverProvider.getMessageReceiver();
try {
return receiver.retrieveProfile(address, profileKey, unidentifiedAccess, requestType);
} catch (NoClassDefFoundError e) {
// Native zkgroup lib not available for ProfileKey
if (!address.getNumber().isPresent()) {
throw new NotFoundException("Can't request profile without number");
}
var addressWithoutUuid = new SignalServiceAddress(Optional.absent(), address.getNumber());
return receiver.retrieveProfile(addressWithoutUuid, profileKey, unidentifiedAccess, requestType);
}
}
private Optional<UnidentifiedAccess> getUnidentifiedAccess(SignalServiceAddress recipient) {
var unidentifiedAccess = unidentifiedAccessProvider.getAccessFor(recipient);
if (unidentifiedAccess.isPresent()) {
return unidentifiedAccess.get().getTargetUnidentifiedAccess();
}
return Optional.absent();
}
}

View file

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

View file

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

View file

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

View file

@ -0,0 +1,8 @@
package org.asamk.signal.manager.helper;
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
public interface SelfAddressProvider {
SignalServiceAddress getSelfAddress();
}

View file

@ -0,0 +1,8 @@
package org.asamk.signal.manager.helper;
import org.signal.zkgroup.profiles.ProfileKey;
public interface SelfProfileKeyProvider {
ProfileKey getProfileKey();
}

View file

@ -0,0 +1,103 @@
package org.asamk.signal.manager.helper;
import org.signal.libsignal.metadata.certificate.InvalidCertificateException;
import org.whispersystems.libsignal.util.guava.Optional;
import org.whispersystems.signalservice.api.crypto.UnidentifiedAccess;
import org.whispersystems.signalservice.api.crypto.UnidentifiedAccessPair;
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
import java.util.Collection;
import java.util.List;
import java.util.stream.Collectors;
import static org.whispersystems.signalservice.internal.util.Util.getSecretBytes;
public class UnidentifiedAccessHelper {
private final SelfProfileKeyProvider selfProfileKeyProvider;
private final ProfileKeyProvider profileKeyProvider;
private final ProfileProvider profileProvider;
private final UnidentifiedAccessSenderCertificateProvider senderCertificateProvider;
public UnidentifiedAccessHelper(
final SelfProfileKeyProvider selfProfileKeyProvider,
final ProfileKeyProvider profileKeyProvider,
final ProfileProvider profileProvider,
final UnidentifiedAccessSenderCertificateProvider senderCertificateProvider
) {
this.selfProfileKeyProvider = selfProfileKeyProvider;
this.profileKeyProvider = profileKeyProvider;
this.profileProvider = profileProvider;
this.senderCertificateProvider = senderCertificateProvider;
}
private byte[] getSelfUnidentifiedAccessKey() {
return UnidentifiedAccess.deriveAccessKeyFrom(selfProfileKeyProvider.getProfileKey());
}
public byte[] getTargetUnidentifiedAccessKey(SignalServiceAddress recipient) {
var theirProfileKey = profileKeyProvider.getProfileKey(recipient);
if (theirProfileKey == null) {
return null;
}
var targetProfile = profileProvider.getProfile(recipient);
if (targetProfile == null || targetProfile.getUnidentifiedAccess() == null) {
return null;
}
if (targetProfile.isUnrestrictedUnidentifiedAccess()) {
return createUnrestrictedUnidentifiedAccess();
}
return UnidentifiedAccess.deriveAccessKeyFrom(theirProfileKey);
}
public Optional<UnidentifiedAccessPair> getAccessForSync() {
var selfUnidentifiedAccessKey = getSelfUnidentifiedAccessKey();
var selfUnidentifiedAccessCertificate = senderCertificateProvider.getSenderCertificate();
if (selfUnidentifiedAccessKey == null || selfUnidentifiedAccessCertificate == null) {
return Optional.absent();
}
try {
return Optional.of(new UnidentifiedAccessPair(new UnidentifiedAccess(selfUnidentifiedAccessKey,
selfUnidentifiedAccessCertificate),
new UnidentifiedAccess(selfUnidentifiedAccessKey, selfUnidentifiedAccessCertificate)));
} catch (InvalidCertificateException e) {
return Optional.absent();
}
}
public List<Optional<UnidentifiedAccessPair>> getAccessFor(Collection<SignalServiceAddress> recipients) {
return recipients.stream().map(this::getAccessFor).collect(Collectors.toList());
}
public Optional<UnidentifiedAccessPair> getAccessFor(SignalServiceAddress recipient) {
var recipientUnidentifiedAccessKey = getTargetUnidentifiedAccessKey(recipient);
var selfUnidentifiedAccessKey = getSelfUnidentifiedAccessKey();
var selfUnidentifiedAccessCertificate = senderCertificateProvider.getSenderCertificate();
if (recipientUnidentifiedAccessKey == null
|| selfUnidentifiedAccessKey == null
|| selfUnidentifiedAccessCertificate == null) {
return Optional.absent();
}
try {
return Optional.of(new UnidentifiedAccessPair(new UnidentifiedAccess(recipientUnidentifiedAccessKey,
selfUnidentifiedAccessCertificate),
new UnidentifiedAccess(selfUnidentifiedAccessKey, selfUnidentifiedAccessCertificate)));
} catch (InvalidCertificateException e) {
return Optional.absent();
}
}
private static byte[] createUnrestrictedUnidentifiedAccess() {
return getSecretBytes(16);
}
}

View file

@ -0,0 +1,10 @@
package org.asamk.signal.manager.helper;
import org.whispersystems.libsignal.util.guava.Optional;
import org.whispersystems.signalservice.api.crypto.UnidentifiedAccessPair;
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
public interface UnidentifiedAccessProvider {
Optional<UnidentifiedAccessPair> getAccessFor(SignalServiceAddress address);
}

View file

@ -0,0 +1,6 @@
package org.asamk.signal.manager.helper;
public interface UnidentifiedAccessSenderCertificateProvider {
byte[] getSenderCertificate();
}

View file

@ -0,0 +1,596 @@
package org.asamk.signal.manager.storage;
import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.PropertyAccessor;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import org.asamk.signal.manager.groups.GroupId;
import org.asamk.signal.manager.storage.contacts.JsonContactsStore;
import org.asamk.signal.manager.storage.groups.GroupInfoV1;
import org.asamk.signal.manager.storage.groups.JsonGroupStore;
import org.asamk.signal.manager.storage.messageCache.MessageCache;
import org.asamk.signal.manager.storage.profiles.ProfileStore;
import org.asamk.signal.manager.storage.protocol.JsonSignalProtocolStore;
import org.asamk.signal.manager.storage.protocol.RecipientStore;
import org.asamk.signal.manager.storage.protocol.SignalServiceAddressResolver;
import org.asamk.signal.manager.storage.stickers.StickerStore;
import org.asamk.signal.manager.storage.threads.LegacyJsonThreadStore;
import org.asamk.signal.manager.util.IOUtils;
import org.asamk.signal.manager.util.KeyUtils;
import org.asamk.signal.manager.util.Utils;
import org.signal.zkgroup.InvalidInputException;
import org.signal.zkgroup.profiles.ProfileKey;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.libsignal.IdentityKeyPair;
import org.whispersystems.libsignal.state.PreKeyRecord;
import org.whispersystems.libsignal.state.SignedPreKeyRecord;
import org.whispersystems.libsignal.util.Medium;
import org.whispersystems.libsignal.util.Pair;
import org.whispersystems.signalservice.api.crypto.UnidentifiedAccess;
import org.whispersystems.signalservice.api.kbs.MasterKey;
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
import org.whispersystems.signalservice.api.storage.StorageKey;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.Closeable;
import java.io.File;
import java.io.IOException;
import java.io.RandomAccessFile;
import java.nio.channels.Channels;
import java.nio.channels.ClosedChannelException;
import java.nio.channels.FileChannel;
import java.nio.channels.FileLock;
import java.util.Base64;
import java.util.Collection;
import java.util.UUID;
import java.util.stream.Collectors;
public class SignalAccount implements Closeable {
private final static Logger logger = LoggerFactory.getLogger(SignalAccount.class);
private final ObjectMapper jsonProcessor = new ObjectMapper();
private final FileChannel fileChannel;
private final FileLock lock;
private String username;
private UUID uuid;
private int deviceId = SignalServiceAddress.DEFAULT_DEVICE_ID;
private boolean isMultiDevice = false;
private String password;
private String registrationLockPin;
private MasterKey pinMasterKey;
private StorageKey storageKey;
private ProfileKey profileKey;
private int preKeyIdOffset;
private int nextSignedPreKeyId;
private boolean registered = false;
private JsonSignalProtocolStore signalProtocolStore;
private JsonGroupStore groupStore;
private JsonContactsStore contactStore;
private RecipientStore recipientStore;
private ProfileStore profileStore;
private StickerStore stickerStore;
private MessageCache messageCache;
private SignalAccount(final FileChannel fileChannel, final FileLock lock) {
this.fileChannel = fileChannel;
this.lock = lock;
jsonProcessor.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.NONE); // disable autodetect
jsonProcessor.enable(SerializationFeature.INDENT_OUTPUT); // for pretty print
jsonProcessor.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);
jsonProcessor.disable(JsonParser.Feature.AUTO_CLOSE_SOURCE);
jsonProcessor.disable(JsonGenerator.Feature.AUTO_CLOSE_TARGET);
}
public static SignalAccount load(File dataPath, String username) throws IOException {
final var fileName = getFileName(dataPath, username);
final var pair = openFileChannel(fileName);
try {
var account = new SignalAccount(pair.first(), pair.second());
account.load(dataPath);
account.migrateLegacyConfigs();
return account;
} catch (Throwable e) {
pair.second().close();
pair.first().close();
throw e;
}
}
public static SignalAccount create(
File dataPath, String username, IdentityKeyPair identityKey, int registrationId, ProfileKey profileKey
) throws IOException {
IOUtils.createPrivateDirectories(dataPath);
var fileName = getFileName(dataPath, username);
if (!fileName.exists()) {
IOUtils.createPrivateFile(fileName);
}
final var pair = openFileChannel(fileName);
var account = new SignalAccount(pair.first(), pair.second());
account.username = username;
account.profileKey = profileKey;
account.signalProtocolStore = new JsonSignalProtocolStore(identityKey, registrationId);
account.groupStore = new JsonGroupStore(getGroupCachePath(dataPath, username));
account.contactStore = new JsonContactsStore();
account.recipientStore = new RecipientStore();
account.profileStore = new ProfileStore();
account.stickerStore = new StickerStore();
account.messageCache = new MessageCache(getMessageCachePath(dataPath, username));
account.registered = false;
account.migrateLegacyConfigs();
return account;
}
public static SignalAccount createLinkedAccount(
File dataPath,
String username,
UUID uuid,
String password,
int deviceId,
IdentityKeyPair identityKey,
int registrationId,
ProfileKey profileKey
) throws IOException {
IOUtils.createPrivateDirectories(dataPath);
var fileName = getFileName(dataPath, username);
if (!fileName.exists()) {
IOUtils.createPrivateFile(fileName);
}
final var pair = openFileChannel(fileName);
var account = new SignalAccount(pair.first(), pair.second());
account.username = username;
account.uuid = uuid;
account.password = password;
account.profileKey = profileKey;
account.deviceId = deviceId;
account.signalProtocolStore = new JsonSignalProtocolStore(identityKey, registrationId);
account.groupStore = new JsonGroupStore(getGroupCachePath(dataPath, username));
account.contactStore = new JsonContactsStore();
account.recipientStore = new RecipientStore();
account.profileStore = new ProfileStore();
account.stickerStore = new StickerStore();
account.messageCache = new MessageCache(getMessageCachePath(dataPath, username));
account.registered = true;
account.isMultiDevice = true;
account.migrateLegacyConfigs();
return account;
}
public void migrateLegacyConfigs() {
if (getProfileKey() == null && isRegistered()) {
// Old config file, creating new profile key
setProfileKey(KeyUtils.createProfileKey());
save();
}
// Store profile keys only in profile store
for (var contact : getContactStore().getContacts()) {
var profileKeyString = contact.profileKey;
if (profileKeyString == null) {
continue;
}
final ProfileKey profileKey;
try {
profileKey = new ProfileKey(Base64.getDecoder().decode(profileKeyString));
} catch (InvalidInputException ignored) {
continue;
}
contact.profileKey = null;
getProfileStore().storeProfileKey(contact.getAddress(), profileKey);
}
// Ensure our profile key is stored in profile store
getProfileStore().storeProfileKey(getSelfAddress(), getProfileKey());
}
public static File getFileName(File dataPath, String username) {
return new File(dataPath, username);
}
private static File getUserPath(final File dataPath, final String username) {
return new File(dataPath, username + ".d");
}
public static File getMessageCachePath(File dataPath, String username) {
return new File(getUserPath(dataPath, username), "msg-cache");
}
private static File getGroupCachePath(File dataPath, String username) {
return new File(getUserPath(dataPath, username), "group-cache");
}
public static boolean userExists(File dataPath, String username) {
if (username == null) {
return false;
}
var f = getFileName(dataPath, username);
return !(!f.exists() || f.isDirectory());
}
private void load(File dataPath) throws IOException {
JsonNode rootNode;
synchronized (fileChannel) {
fileChannel.position(0);
rootNode = jsonProcessor.readTree(Channels.newInputStream(fileChannel));
}
if (rootNode.hasNonNull("uuid")) {
try {
uuid = UUID.fromString(rootNode.get("uuid").asText());
} catch (IllegalArgumentException e) {
throw new IOException("Config file contains an invalid uuid, needs to be a valid UUID", e);
}
}
if (rootNode.hasNonNull("deviceId")) {
deviceId = rootNode.get("deviceId").asInt();
}
if (rootNode.hasNonNull("isMultiDevice")) {
isMultiDevice = rootNode.get("isMultiDevice").asBoolean();
}
username = Utils.getNotNullNode(rootNode, "username").asText();
password = Utils.getNotNullNode(rootNode, "password").asText();
if (rootNode.hasNonNull("registrationLockPin")) {
registrationLockPin = rootNode.get("registrationLockPin").asText();
}
if (rootNode.hasNonNull("pinMasterKey")) {
pinMasterKey = new MasterKey(Base64.getDecoder().decode(rootNode.get("pinMasterKey").asText()));
}
if (rootNode.hasNonNull("storageKey")) {
storageKey = new StorageKey(Base64.getDecoder().decode(rootNode.get("storageKey").asText()));
}
if (rootNode.hasNonNull("preKeyIdOffset")) {
preKeyIdOffset = rootNode.get("preKeyIdOffset").asInt(0);
} else {
preKeyIdOffset = 0;
}
if (rootNode.hasNonNull("nextSignedPreKeyId")) {
nextSignedPreKeyId = rootNode.get("nextSignedPreKeyId").asInt();
} else {
nextSignedPreKeyId = 0;
}
if (rootNode.hasNonNull("profileKey")) {
try {
profileKey = new ProfileKey(Base64.getDecoder().decode(rootNode.get("profileKey").asText()));
} catch (InvalidInputException e) {
throw new IOException(
"Config file contains an invalid profileKey, needs to be base64 encoded array of 32 bytes",
e);
}
}
signalProtocolStore = jsonProcessor.convertValue(Utils.getNotNullNode(rootNode, "axolotlStore"),
JsonSignalProtocolStore.class);
registered = Utils.getNotNullNode(rootNode, "registered").asBoolean();
var groupStoreNode = rootNode.get("groupStore");
if (groupStoreNode != null) {
groupStore = jsonProcessor.convertValue(groupStoreNode, JsonGroupStore.class);
groupStore.groupCachePath = getGroupCachePath(dataPath, username);
}
if (groupStore == null) {
groupStore = new JsonGroupStore(getGroupCachePath(dataPath, username));
}
var contactStoreNode = rootNode.get("contactStore");
if (contactStoreNode != null) {
contactStore = jsonProcessor.convertValue(contactStoreNode, JsonContactsStore.class);
}
if (contactStore == null) {
contactStore = new JsonContactsStore();
}
var recipientStoreNode = rootNode.get("recipientStore");
if (recipientStoreNode != null) {
recipientStore = jsonProcessor.convertValue(recipientStoreNode, RecipientStore.class);
}
if (recipientStore == null) {
recipientStore = new RecipientStore();
recipientStore.resolveServiceAddress(getSelfAddress());
for (var contact : contactStore.getContacts()) {
recipientStore.resolveServiceAddress(contact.getAddress());
}
for (var group : groupStore.getGroups()) {
if (group instanceof GroupInfoV1) {
var groupInfoV1 = (GroupInfoV1) group;
groupInfoV1.members = groupInfoV1.members.stream()
.map(m -> recipientStore.resolveServiceAddress(m))
.collect(Collectors.toSet());
}
}
for (var session : signalProtocolStore.getSessions()) {
session.address = recipientStore.resolveServiceAddress(session.address);
}
for (var identity : signalProtocolStore.getIdentities()) {
identity.setAddress(recipientStore.resolveServiceAddress(identity.getAddress()));
}
}
var profileStoreNode = rootNode.get("profileStore");
if (profileStoreNode != null) {
profileStore = jsonProcessor.convertValue(profileStoreNode, ProfileStore.class);
}
if (profileStore == null) {
profileStore = new ProfileStore();
}
var stickerStoreNode = rootNode.get("stickerStore");
if (stickerStoreNode != null) {
stickerStore = jsonProcessor.convertValue(stickerStoreNode, StickerStore.class);
}
if (stickerStore == null) {
stickerStore = new StickerStore();
}
messageCache = new MessageCache(getMessageCachePath(dataPath, username));
var threadStoreNode = rootNode.get("threadStore");
if (threadStoreNode != null && !threadStoreNode.isNull()) {
var threadStore = jsonProcessor.convertValue(threadStoreNode, LegacyJsonThreadStore.class);
// Migrate thread info to group and contact store
for (var thread : threadStore.getThreads()) {
if (thread.id == null || thread.id.isEmpty()) {
continue;
}
try {
var contactInfo = contactStore.getContact(new SignalServiceAddress(null, thread.id));
if (contactInfo != null) {
contactInfo.messageExpirationTime = thread.messageExpirationTime;
contactStore.updateContact(contactInfo);
} else {
var groupInfo = groupStore.getGroup(GroupId.fromBase64(thread.id));
if (groupInfo instanceof GroupInfoV1) {
((GroupInfoV1) groupInfo).messageExpirationTime = thread.messageExpirationTime;
groupStore.updateGroup(groupInfo);
}
}
} catch (Exception ignored) {
}
}
}
}
public void save() {
if (fileChannel == null) {
return;
}
var rootNode = jsonProcessor.createObjectNode();
rootNode.put("username", username)
.put("uuid", uuid == null ? null : uuid.toString())
.put("deviceId", deviceId)
.put("isMultiDevice", isMultiDevice)
.put("password", password)
.put("registrationLockPin", registrationLockPin)
.put("pinMasterKey",
pinMasterKey == null ? null : Base64.getEncoder().encodeToString(pinMasterKey.serialize()))
.put("storageKey",
storageKey == null ? null : Base64.getEncoder().encodeToString(storageKey.serialize()))
.put("preKeyIdOffset", preKeyIdOffset)
.put("nextSignedPreKeyId", nextSignedPreKeyId)
.put("profileKey", Base64.getEncoder().encodeToString(profileKey.serialize()))
.put("registered", registered)
.putPOJO("axolotlStore", signalProtocolStore)
.putPOJO("groupStore", groupStore)
.putPOJO("contactStore", contactStore)
.putPOJO("recipientStore", recipientStore)
.putPOJO("profileStore", profileStore)
.putPOJO("stickerStore", stickerStore);
try {
try (var output = new ByteArrayOutputStream()) {
// Write to memory first to prevent corrupting the file in case of serialization errors
jsonProcessor.writeValue(output, rootNode);
var input = new ByteArrayInputStream(output.toByteArray());
synchronized (fileChannel) {
fileChannel.position(0);
input.transferTo(Channels.newOutputStream(fileChannel));
fileChannel.truncate(fileChannel.position());
fileChannel.force(false);
}
}
} catch (Exception e) {
logger.error("Error saving file: {}", e.getMessage());
}
}
private static Pair<FileChannel, FileLock> openFileChannel(File fileName) throws IOException {
var fileChannel = new RandomAccessFile(fileName, "rw").getChannel();
var lock = fileChannel.tryLock();
if (lock == null) {
logger.info("Config file is in use by another instance, waiting…");
lock = fileChannel.lock();
logger.info("Config file lock acquired.");
}
return new Pair<>(fileChannel, lock);
}
public void setResolver(final SignalServiceAddressResolver resolver) {
signalProtocolStore.setResolver(resolver);
}
public void addPreKeys(Collection<PreKeyRecord> records) {
for (var record : records) {
signalProtocolStore.storePreKey(record.getId(), record);
}
preKeyIdOffset = (preKeyIdOffset + records.size()) % Medium.MAX_VALUE;
}
public void addSignedPreKey(SignedPreKeyRecord record) {
signalProtocolStore.storeSignedPreKey(record.getId(), record);
nextSignedPreKeyId = (nextSignedPreKeyId + 1) % Medium.MAX_VALUE;
}
public JsonSignalProtocolStore getSignalProtocolStore() {
return signalProtocolStore;
}
public JsonGroupStore getGroupStore() {
return groupStore;
}
public JsonContactsStore getContactStore() {
return contactStore;
}
public RecipientStore getRecipientStore() {
return recipientStore;
}
public ProfileStore getProfileStore() {
return profileStore;
}
public StickerStore getStickerStore() {
return stickerStore;
}
public MessageCache getMessageCache() {
return messageCache;
}
public String getUsername() {
return username;
}
public UUID getUuid() {
return uuid;
}
public void setUuid(final UUID uuid) {
this.uuid = uuid;
}
public SignalServiceAddress getSelfAddress() {
return new SignalServiceAddress(uuid, username);
}
public int getDeviceId() {
return deviceId;
}
public void setDeviceId(final int deviceId) {
this.deviceId = deviceId;
}
public boolean isMasterDevice() {
return deviceId == SignalServiceAddress.DEFAULT_DEVICE_ID;
}
public String getPassword() {
return password;
}
public void setPassword(final String password) {
this.password = password;
}
public String getRegistrationLockPin() {
return registrationLockPin;
}
public void setRegistrationLockPin(final String registrationLockPin) {
this.registrationLockPin = registrationLockPin;
}
public MasterKey getPinMasterKey() {
return pinMasterKey;
}
public void setPinMasterKey(final MasterKey pinMasterKey) {
this.pinMasterKey = pinMasterKey;
}
public StorageKey getStorageKey() {
if (pinMasterKey != null) {
return pinMasterKey.deriveStorageServiceKey();
}
return storageKey;
}
public void setStorageKey(final StorageKey storageKey) {
this.storageKey = storageKey;
}
public ProfileKey getProfileKey() {
return profileKey;
}
public void setProfileKey(final ProfileKey profileKey) {
this.profileKey = profileKey;
}
public byte[] getSelfUnidentifiedAccessKey() {
return UnidentifiedAccess.deriveAccessKeyFrom(getProfileKey());
}
public int getPreKeyIdOffset() {
return preKeyIdOffset;
}
public int getNextSignedPreKeyId() {
return nextSignedPreKeyId;
}
public boolean isRegistered() {
return registered;
}
public void setRegistered(final boolean registered) {
this.registered = registered;
}
public boolean isMultiDevice() {
return isMultiDevice;
}
public void setMultiDevice(final boolean multiDevice) {
isMultiDevice = multiDevice;
}
public boolean isUnrestrictedUnidentifiedAccess() {
// TODO make configurable
return false;
}
public boolean isDiscoverableByPhoneNumber() {
// TODO make configurable
return true;
}
@Override
public void close() throws IOException {
if (fileChannel.isOpen()) {
save();
}
synchronized (fileChannel) {
try {
lock.close();
} catch (ClosedChannelException ignored) {
}
fileChannel.close();
}
}
}

View file

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

View file

@ -0,0 +1,52 @@
package org.asamk.signal.manager.storage.contacts;
import com.fasterxml.jackson.annotation.JsonProperty;
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
import java.util.ArrayList;
import java.util.List;
public class JsonContactsStore {
@JsonProperty("contacts")
private List<ContactInfo> contacts = new ArrayList<>();
public void updateContact(ContactInfo contact) {
final var contactAddress = contact.getAddress();
for (var i = 0; i < contacts.size(); i++) {
if (contacts.get(i).getAddress().matches(contactAddress)) {
contacts.set(i, contact);
return;
}
}
contacts.add(contact);
}
public ContactInfo getContact(SignalServiceAddress address) {
for (var contact : contacts) {
if (contact.getAddress().matches(address)) {
if (contact.uuid == null) {
contact.uuid = address.getUuid().orNull();
} else if (contact.number == null) {
contact.number = address.getNumber().orNull();
}
return contact;
}
}
return null;
}
public List<ContactInfo> getContacts() {
return new ArrayList<>(contacts);
}
/**
* Remove all contacts from the store
*/
public void clear() {
contacts.clear();
}
}

View file

@ -0,0 +1,77 @@
package org.asamk.signal.manager.storage.groups;
import com.fasterxml.jackson.annotation.JsonIgnore;
import org.asamk.signal.manager.groups.GroupId;
import org.asamk.signal.manager.groups.GroupInviteLinkUrl;
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;
public abstract class GroupInfo {
@JsonIgnore
public abstract GroupId getGroupId();
@JsonIgnore
public abstract String getTitle();
@JsonIgnore
public abstract GroupInviteLinkUrl getGroupInviteLink();
@JsonIgnore
public abstract Set<SignalServiceAddress> getMembers();
@JsonIgnore
public Set<SignalServiceAddress> getPendingMembers() {
return Set.of();
}
@JsonIgnore
public Set<SignalServiceAddress> getRequestingMembers() {
return Set.of();
}
@JsonIgnore
public abstract boolean isBlocked();
@JsonIgnore
public abstract void setBlocked(boolean blocked);
@JsonIgnore
public abstract int getMessageExpirationTime();
@JsonIgnore
public Set<SignalServiceAddress> getMembersWithout(SignalServiceAddress address) {
return getMembers().stream().filter(member -> !member.matches(address)).collect(Collectors.toSet());
}
@JsonIgnore
public Set<SignalServiceAddress> getMembersIncludingPendingWithout(SignalServiceAddress address) {
return Stream.concat(getMembers().stream(), getPendingMembers().stream())
.filter(member -> !member.matches(address))
.collect(Collectors.toSet());
}
@JsonIgnore
public boolean isMember(SignalServiceAddress address) {
for (var member : getMembers()) {
if (member.matches(address)) {
return true;
}
}
return false;
}
@JsonIgnore
public boolean isPendingMember(SignalServiceAddress address) {
for (var member : getPendingMembers()) {
if (member.matches(address)) {
return true;
}
}
return false;
}
}

View file

@ -0,0 +1,212 @@
package org.asamk.signal.manager.storage.groups;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonDeserializer;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializerProvider;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import org.asamk.signal.manager.groups.GroupId;
import org.asamk.signal.manager.groups.GroupIdV1;
import org.asamk.signal.manager.groups.GroupIdV2;
import org.asamk.signal.manager.groups.GroupInviteLinkUrl;
import org.asamk.signal.manager.groups.GroupUtils;
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
import java.io.IOException;
import java.util.Collection;
import java.util.HashSet;
import java.util.Set;
import java.util.UUID;
public class GroupInfoV1 extends GroupInfo {
private static final ObjectMapper jsonProcessor = new ObjectMapper();
private final GroupIdV1 groupId;
private GroupIdV2 expectedV2Id;
@JsonProperty
public String name;
@JsonProperty
@JsonDeserialize(using = MembersDeserializer.class)
@JsonSerialize(using = MembersSerializer.class)
public Set<SignalServiceAddress> members = new HashSet<>();
@JsonProperty
public String color;
@JsonProperty(defaultValue = "0")
public int messageExpirationTime;
@JsonProperty(defaultValue = "false")
public boolean blocked;
@JsonProperty
public Integer inboxPosition;
@JsonProperty(defaultValue = "false")
public boolean archived;
public GroupInfoV1(GroupIdV1 groupId) {
this.groupId = groupId;
}
public GroupInfoV1(
@JsonProperty("groupId") byte[] groupId,
@JsonProperty("expectedV2Id") byte[] expectedV2Id,
@JsonProperty("name") String name,
@JsonProperty("members") Collection<SignalServiceAddress> members,
@JsonProperty("avatarId") long _ignored_avatarId,
@JsonProperty("color") String color,
@JsonProperty("blocked") boolean blocked,
@JsonProperty("inboxPosition") Integer inboxPosition,
@JsonProperty("archived") boolean archived,
@JsonProperty("messageExpirationTime") int messageExpirationTime,
@JsonProperty("active") boolean _ignored_active
) {
this.groupId = GroupId.v1(groupId);
this.expectedV2Id = GroupId.v2(expectedV2Id);
this.name = name;
this.members.addAll(members);
this.color = color;
this.blocked = blocked;
this.inboxPosition = inboxPosition;
this.archived = archived;
this.messageExpirationTime = messageExpirationTime;
}
@Override
@JsonIgnore
public GroupIdV1 getGroupId() {
return groupId;
}
@JsonProperty("groupId")
private byte[] getGroupIdJackson() {
return groupId.serialize();
}
@JsonIgnore
public GroupIdV2 getExpectedV2Id() {
if (expectedV2Id == null) {
expectedV2Id = GroupUtils.getGroupIdV2(groupId);
}
return expectedV2Id;
}
@JsonProperty("expectedV2Id")
private byte[] getExpectedV2IdJackson() {
return getExpectedV2Id().serialize();
}
@Override
public String getTitle() {
return name;
}
@Override
public GroupInviteLinkUrl getGroupInviteLink() {
return null;
}
@JsonIgnore
public Set<SignalServiceAddress> getMembers() {
return members;
}
@Override
public boolean isBlocked() {
return blocked;
}
@Override
public void setBlocked(final boolean blocked) {
this.blocked = blocked;
}
@Override
public int getMessageExpirationTime() {
return messageExpirationTime;
}
public void addMembers(Collection<SignalServiceAddress> addresses) {
for (var address : addresses) {
if (this.members.contains(address)) {
continue;
}
removeMember(address);
this.members.add(address);
}
}
public void removeMember(SignalServiceAddress address) {
this.members.removeIf(member -> member.matches(address));
}
private static final class JsonSignalServiceAddress {
@JsonProperty
private UUID uuid;
@JsonProperty
private String number;
JsonSignalServiceAddress(@JsonProperty("uuid") final UUID uuid, @JsonProperty("number") final String number) {
this.uuid = uuid;
this.number = number;
}
JsonSignalServiceAddress(SignalServiceAddress address) {
this.uuid = address.getUuid().orNull();
this.number = address.getNumber().orNull();
}
SignalServiceAddress toSignalServiceAddress() {
return new SignalServiceAddress(uuid, number);
}
}
private static class MembersSerializer extends JsonSerializer<Set<SignalServiceAddress>> {
@Override
public void serialize(
final Set<SignalServiceAddress> value, final JsonGenerator jgen, final SerializerProvider provider
) throws IOException {
jgen.writeStartArray(value.size());
for (var address : value) {
if (address.getUuid().isPresent()) {
jgen.writeObject(new JsonSignalServiceAddress(address));
} else {
jgen.writeString(address.getNumber().get());
}
}
jgen.writeEndArray();
}
}
private static class MembersDeserializer extends JsonDeserializer<Set<SignalServiceAddress>> {
@Override
public Set<SignalServiceAddress> deserialize(
JsonParser jsonParser, DeserializationContext deserializationContext
) throws IOException {
var addresses = new HashSet<SignalServiceAddress>();
JsonNode node = jsonParser.getCodec().readTree(jsonParser);
for (var n : node) {
if (n.isTextual()) {
addresses.add(new SignalServiceAddress(null, n.textValue()));
} else {
var address = jsonProcessor.treeToValue(n, JsonSignalServiceAddress.class);
addresses.add(address.toSignalServiceAddress());
}
}
return addresses;
}
}
}

View file

@ -0,0 +1,114 @@
package org.asamk.signal.manager.storage.groups;
import org.asamk.signal.manager.groups.GroupIdV2;
import org.asamk.signal.manager.groups.GroupInviteLinkUrl;
import org.signal.storageservice.protos.groups.AccessControl;
import org.signal.storageservice.protos.groups.local.DecryptedGroup;
import org.signal.zkgroup.groups.GroupMasterKey;
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
import org.whispersystems.signalservice.api.util.UuidUtil;
import java.util.Set;
import java.util.stream.Collectors;
public class GroupInfoV2 extends GroupInfo {
private final GroupIdV2 groupId;
private final GroupMasterKey masterKey;
private boolean blocked;
private DecryptedGroup group; // stored as a file with hexadecimal groupId as name
public GroupInfoV2(final GroupIdV2 groupId, final GroupMasterKey masterKey) {
this.groupId = groupId;
this.masterKey = masterKey;
}
@Override
public GroupIdV2 getGroupId() {
return groupId;
}
public GroupMasterKey getMasterKey() {
return masterKey;
}
public void setGroup(final DecryptedGroup group) {
this.group = group;
}
public DecryptedGroup getGroup() {
return group;
}
@Override
public String getTitle() {
if (this.group == null) {
return null;
}
return this.group.getTitle();
}
@Override
public GroupInviteLinkUrl getGroupInviteLink() {
if (this.group == null || this.group.getInviteLinkPassword() == null || (
this.group.getAccessControl().getAddFromInviteLink() != AccessControl.AccessRequired.ANY
&& this.group.getAccessControl().getAddFromInviteLink()
!= AccessControl.AccessRequired.ADMINISTRATOR
)) {
return null;
}
return GroupInviteLinkUrl.forGroup(masterKey, group);
}
@Override
public Set<SignalServiceAddress> getMembers() {
if (this.group == null) {
return Set.of();
}
return group.getMembersList()
.stream()
.map(m -> new SignalServiceAddress(UuidUtil.parseOrThrow(m.getUuid().toByteArray()), null))
.collect(Collectors.toSet());
}
@Override
public Set<SignalServiceAddress> getPendingMembers() {
if (this.group == null) {
return Set.of();
}
return group.getPendingMembersList()
.stream()
.map(m -> new SignalServiceAddress(UuidUtil.parseOrThrow(m.getUuid().toByteArray()), null))
.collect(Collectors.toSet());
}
@Override
public Set<SignalServiceAddress> getRequestingMembers() {
if (this.group == null) {
return Set.of();
}
return group.getRequestingMembersList()
.stream()
.map(m -> new SignalServiceAddress(UuidUtil.parseOrThrow(m.getUuid().toByteArray()), null))
.collect(Collectors.toSet());
}
@Override
public boolean isBlocked() {
return blocked;
}
@Override
public void setBlocked(final boolean blocked) {
this.blocked = blocked;
}
@Override
public int getMessageExpirationTime() {
return this.group != null && this.group.hasDisappearingMessagesTimer()
? this.group.getDisappearingMessagesTimer().getDuration()
: 0;
}
}

View file

@ -0,0 +1,204 @@
package org.asamk.signal.manager.storage.groups;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonDeserializer;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializerProvider;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import org.asamk.signal.manager.groups.GroupId;
import org.asamk.signal.manager.groups.GroupIdV1;
import org.asamk.signal.manager.groups.GroupIdV2;
import org.asamk.signal.manager.groups.GroupUtils;
import org.asamk.signal.manager.util.IOUtils;
import org.signal.storageservice.protos.groups.local.DecryptedGroup;
import org.signal.zkgroup.InvalidInputException;
import org.signal.zkgroup.groups.GroupMasterKey;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.libsignal.util.Hex;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Base64;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
public class JsonGroupStore {
private final static Logger logger = LoggerFactory.getLogger(JsonGroupStore.class);
private static final ObjectMapper jsonProcessor = new ObjectMapper();
public File groupCachePath;
@JsonProperty("groups")
@JsonSerialize(using = GroupsSerializer.class)
@JsonDeserialize(using = GroupsDeserializer.class)
private final Map<GroupId, GroupInfo> groups = new HashMap<>();
private JsonGroupStore() {
}
public JsonGroupStore(final File groupCachePath) {
this.groupCachePath = groupCachePath;
}
public void updateGroup(GroupInfo group) {
groups.put(group.getGroupId(), group);
if (group instanceof GroupInfoV2 && ((GroupInfoV2) group).getGroup() != null) {
try {
IOUtils.createPrivateDirectories(groupCachePath);
try (var stream = new FileOutputStream(getGroupFile(group.getGroupId()))) {
((GroupInfoV2) group).getGroup().writeTo(stream);
}
final var groupFileLegacy = getGroupFileLegacy(group.getGroupId());
if (groupFileLegacy.exists()) {
groupFileLegacy.delete();
}
} catch (IOException e) {
logger.warn("Failed to cache group, ignoring: {}", e.getMessage());
}
}
}
public void deleteGroup(GroupId groupId) {
groups.remove(groupId);
}
public GroupInfo getGroup(GroupId groupId) {
var group = groups.get(groupId);
if (group == null) {
if (groupId instanceof GroupIdV1) {
group = groups.get(GroupUtils.getGroupIdV2((GroupIdV1) groupId));
} else if (groupId instanceof GroupIdV2) {
group = getGroupV1ByV2Id((GroupIdV2) groupId);
}
}
loadDecryptedGroup(group);
return group;
}
private GroupInfoV1 getGroupV1ByV2Id(GroupIdV2 groupIdV2) {
for (var g : groups.values()) {
if (g instanceof GroupInfoV1) {
final var gv1 = (GroupInfoV1) g;
if (groupIdV2.equals(gv1.getExpectedV2Id())) {
return gv1;
}
}
}
return null;
}
private void loadDecryptedGroup(final GroupInfo group) {
if (group instanceof GroupInfoV2 && ((GroupInfoV2) group).getGroup() == null) {
var groupFile = getGroupFile(group.getGroupId());
if (!groupFile.exists()) {
groupFile = getGroupFileLegacy(group.getGroupId());
}
if (!groupFile.exists()) {
return;
}
try (var stream = new FileInputStream(groupFile)) {
((GroupInfoV2) group).setGroup(DecryptedGroup.parseFrom(stream));
} catch (IOException ignored) {
}
}
}
private File getGroupFileLegacy(final GroupId groupId) {
return new File(groupCachePath, Hex.toStringCondensed(groupId.serialize()));
}
private File getGroupFile(final GroupId groupId) {
return new File(groupCachePath, groupId.toBase64().replace("/", "_"));
}
public GroupInfoV1 getOrCreateGroupV1(GroupIdV1 groupId) {
var group = getGroup(groupId);
if (group instanceof GroupInfoV1) {
return (GroupInfoV1) group;
}
if (group == null) {
return new GroupInfoV1(groupId);
}
return null;
}
public List<GroupInfo> getGroups() {
final var groups = this.groups.values();
for (var group : groups) {
loadDecryptedGroup(group);
}
return new ArrayList<>(groups);
}
private static class GroupsSerializer extends JsonSerializer<Map<String, GroupInfo>> {
@Override
public void serialize(
final Map<String, GroupInfo> value, final JsonGenerator jgen, final SerializerProvider provider
) throws IOException {
final var groups = value.values();
jgen.writeStartArray(groups.size());
for (var group : groups) {
if (group instanceof GroupInfoV1) {
jgen.writeObject(group);
} else if (group instanceof GroupInfoV2) {
final var groupV2 = (GroupInfoV2) group;
jgen.writeStartObject();
jgen.writeStringField("groupId", groupV2.getGroupId().toBase64());
jgen.writeStringField("masterKey",
Base64.getEncoder().encodeToString(groupV2.getMasterKey().serialize()));
jgen.writeBooleanField("blocked", groupV2.isBlocked());
jgen.writeEndObject();
} else {
throw new AssertionError("Unknown group version");
}
}
jgen.writeEndArray();
}
}
private static class GroupsDeserializer extends JsonDeserializer<Map<GroupId, GroupInfo>> {
@Override
public Map<GroupId, GroupInfo> deserialize(
JsonParser jsonParser, DeserializationContext deserializationContext
) throws IOException {
var groups = new HashMap<GroupId, GroupInfo>();
JsonNode node = jsonParser.getCodec().readTree(jsonParser);
for (var n : node) {
GroupInfo g;
if (n.hasNonNull("masterKey")) {
// a v2 group
var groupId = GroupIdV2.fromBase64(n.get("groupId").asText());
try {
var masterKey = new GroupMasterKey(Base64.getDecoder().decode(n.get("masterKey").asText()));
g = new GroupInfoV2(groupId, masterKey);
} catch (InvalidInputException | IllegalArgumentException e) {
throw new AssertionError("Invalid master key for group " + groupId.toBase64());
}
g.setBlocked(n.get("blocked").asBoolean(false));
} else {
g = jsonProcessor.treeToValue(n, GroupInfoV1.class);
}
groups.put(g.getGroupId(), g);
}
return groups;
}
}
}

View file

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

View file

@ -0,0 +1,79 @@
package org.asamk.signal.manager.storage.messageCache;
import org.asamk.signal.manager.util.IOUtils;
import org.asamk.signal.manager.util.MessageCacheUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.signalservice.api.messages.SignalServiceEnvelope;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.util.Arrays;
import java.util.Collections;
import java.util.Date;
import java.util.Objects;
import java.util.stream.Collectors;
import java.util.stream.Stream;
public class MessageCache {
private final static Logger logger = LoggerFactory.getLogger(MessageCache.class);
private final File messageCachePath;
public MessageCache(final File messageCachePath) {
this.messageCachePath = messageCachePath;
}
public Iterable<CachedMessage> getCachedMessages() {
if (!messageCachePath.exists()) {
return Collections.emptyList();
}
return Arrays.stream(Objects.requireNonNull(messageCachePath.listFiles())).flatMap(dir -> {
if (dir.isFile()) {
return Stream.of(dir);
}
final var files = Objects.requireNonNull(dir.listFiles());
if (files.length == 0) {
try {
Files.delete(dir.toPath());
} catch (IOException e) {
logger.warn("Failed to delete cache dir “{}”, ignoring: {}", dir, e.getMessage());
}
return Stream.empty();
}
return Arrays.stream(files).filter(File::isFile);
}).map(CachedMessage::new).collect(Collectors.toList());
}
public CachedMessage cacheMessage(SignalServiceEnvelope envelope) {
final var now = new Date().getTime();
final var source = envelope.hasSource() ? envelope.getSourceAddress().getLegacyIdentifier() : "";
try {
var cacheFile = getMessageCacheFile(source, now, envelope.getTimestamp());
MessageCacheUtils.storeEnvelope(envelope, cacheFile);
return new CachedMessage(cacheFile);
} catch (IOException e) {
logger.warn("Failed to store encrypted message in disk cache, ignoring: {}", e.getMessage());
return null;
}
}
private File getMessageCachePath(String sender) {
if (sender == null || sender.isEmpty()) {
return messageCachePath;
}
return new File(messageCachePath, sender.replace("/", "_"));
}
private File getMessageCacheFile(String sender, long now, long timestamp) throws IOException {
var cachePath = getMessageCachePath(sender);
IOUtils.createPrivateDirectories(cachePath);
return new File(cachePath, now + "_" + timestamp);
}
}

View file

@ -0,0 +1,156 @@
package org.asamk.signal.manager.storage.profiles;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonDeserializer;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializerProvider;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import org.signal.zkgroup.InvalidInputException;
import org.signal.zkgroup.profiles.ProfileKey;
import org.signal.zkgroup.profiles.ProfileKeyCredential;
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
import org.whispersystems.signalservice.api.util.UuidUtil;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
public class ProfileStore {
private static final ObjectMapper jsonProcessor = new ObjectMapper();
@JsonProperty("profiles")
@JsonDeserialize(using = ProfileStoreDeserializer.class)
@JsonSerialize(using = ProfileStoreSerializer.class)
private final List<SignalProfileEntry> profiles = new ArrayList<>();
public SignalProfileEntry getProfileEntry(SignalServiceAddress serviceAddress) {
for (var entry : profiles) {
if (entry.getServiceAddress().matches(serviceAddress)) {
return entry;
}
}
return null;
}
public ProfileKey getProfileKey(SignalServiceAddress serviceAddress) {
for (var entry : profiles) {
if (entry.getServiceAddress().matches(serviceAddress)) {
return entry.getProfileKey();
}
}
return null;
}
public void updateProfile(
SignalServiceAddress serviceAddress,
ProfileKey profileKey,
long now,
SignalProfile profile,
ProfileKeyCredential profileKeyCredential
) {
var newEntry = new SignalProfileEntry(serviceAddress, profileKey, now, profile, profileKeyCredential);
for (var i = 0; i < profiles.size(); i++) {
if (profiles.get(i).getServiceAddress().matches(serviceAddress)) {
profiles.set(i, newEntry);
return;
}
}
profiles.add(newEntry);
}
public void storeProfileKey(SignalServiceAddress serviceAddress, ProfileKey profileKey) {
var newEntry = new SignalProfileEntry(serviceAddress, profileKey, 0, null, null);
for (var i = 0; i < profiles.size(); i++) {
if (profiles.get(i).getServiceAddress().matches(serviceAddress)) {
if (!profiles.get(i).getProfileKey().equals(profileKey)) {
profiles.set(i, newEntry);
}
return;
}
}
profiles.add(newEntry);
}
public static class ProfileStoreDeserializer extends JsonDeserializer<List<SignalProfileEntry>> {
@Override
public List<SignalProfileEntry> deserialize(
JsonParser jsonParser, DeserializationContext deserializationContext
) throws IOException {
JsonNode node = jsonParser.getCodec().readTree(jsonParser);
var addresses = new ArrayList<SignalProfileEntry>();
if (node.isArray()) {
for (var entry : node) {
var name = entry.hasNonNull("name") ? entry.get("name").asText() : null;
var uuid = entry.hasNonNull("uuid") ? UuidUtil.parseOrNull(entry.get("uuid").asText()) : null;
final var serviceAddress = new SignalServiceAddress(uuid, name);
ProfileKey profileKey = null;
try {
profileKey = new ProfileKey(Base64.getDecoder().decode(entry.get("profileKey").asText()));
} catch (InvalidInputException ignored) {
}
ProfileKeyCredential profileKeyCredential = null;
if (entry.hasNonNull("profileKeyCredential")) {
try {
profileKeyCredential = new ProfileKeyCredential(Base64.getDecoder()
.decode(entry.get("profileKeyCredential").asText()));
} catch (Throwable ignored) {
}
}
var lastUpdateTimestamp = entry.get("lastUpdateTimestamp").asLong();
var profile = jsonProcessor.treeToValue(entry.get("profile"), SignalProfile.class);
addresses.add(new SignalProfileEntry(serviceAddress,
profileKey,
lastUpdateTimestamp,
profile,
profileKeyCredential));
}
}
return addresses;
}
}
public static class ProfileStoreSerializer extends JsonSerializer<List<SignalProfileEntry>> {
@Override
public void serialize(
List<SignalProfileEntry> profiles, JsonGenerator json, SerializerProvider serializerProvider
) throws IOException {
json.writeStartArray();
for (var profileEntry : profiles) {
final var address = profileEntry.getServiceAddress();
json.writeStartObject();
if (address.getNumber().isPresent()) {
json.writeStringField("name", address.getNumber().get());
}
if (address.getUuid().isPresent()) {
json.writeStringField("uuid", address.getUuid().get().toString());
}
json.writeStringField("profileKey",
Base64.getEncoder().encodeToString(profileEntry.getProfileKey().serialize()));
json.writeNumberField("lastUpdateTimestamp", profileEntry.getLastUpdateTimestamp());
json.writeObjectField("profile", profileEntry.getProfile());
if (profileEntry.getProfileKeyCredential() != null) {
json.writeStringField("profileKeyCredential",
Base64.getEncoder().encodeToString(profileEntry.getProfileKeyCredential().serialize()));
}
json.writeEndObject();
}
json.writeEndArray();
}
}
}

View file

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

View file

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

View file

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

View file

@ -0,0 +1,277 @@
package org.asamk.signal.manager.storage.protocol;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonDeserializer;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.SerializerProvider;
import org.asamk.signal.manager.TrustLevel;
import org.asamk.signal.manager.util.Utils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.libsignal.IdentityKey;
import org.whispersystems.libsignal.IdentityKeyPair;
import org.whispersystems.libsignal.InvalidKeyException;
import org.whispersystems.libsignal.SignalProtocolAddress;
import org.whispersystems.libsignal.state.IdentityKeyStore;
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
import org.whispersystems.signalservice.api.util.UuidUtil;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Base64;
import java.util.Date;
import java.util.List;
public class JsonIdentityKeyStore implements IdentityKeyStore {
private final static Logger logger = LoggerFactory.getLogger(JsonIdentityKeyStore.class);
private final List<IdentityInfo> identities = new ArrayList<>();
private final IdentityKeyPair identityKeyPair;
private final int localRegistrationId;
private SignalServiceAddressResolver resolver;
public JsonIdentityKeyStore(IdentityKeyPair identityKeyPair, int localRegistrationId) {
this.identityKeyPair = identityKeyPair;
this.localRegistrationId = localRegistrationId;
}
public void setResolver(final SignalServiceAddressResolver resolver) {
this.resolver = resolver;
}
private SignalServiceAddress resolveSignalServiceAddress(String identifier) {
if (resolver != null) {
return resolver.resolveSignalServiceAddress(identifier);
} else {
return Utils.getSignalServiceAddressFromIdentifier(identifier);
}
}
@Override
public IdentityKeyPair getIdentityKeyPair() {
return identityKeyPair;
}
@Override
public int getLocalRegistrationId() {
return localRegistrationId;
}
@Override
public boolean saveIdentity(SignalProtocolAddress address, IdentityKey identityKey) {
return saveIdentity(resolveSignalServiceAddress(address.getName()),
identityKey,
TrustLevel.TRUSTED_UNVERIFIED,
null);
}
/**
* Adds the given identityKey for the user name and sets the trustLevel and added timestamp.
* If the identityKey already exists, the trustLevel and added timestamp are NOT updated.
*
* @param serviceAddress User address, i.e. phone number and/or uuid
* @param identityKey The user's public key
* @param trustLevel Level of trust: untrusted, trusted, trusted and verified
* @param added Added timestamp, if null and the key is newly added, the current time is used.
*/
public boolean saveIdentity(
SignalServiceAddress serviceAddress, IdentityKey identityKey, TrustLevel trustLevel, Date added
) {
for (var id : identities) {
if (!id.address.matches(serviceAddress) || !id.identityKey.equals(identityKey)) {
continue;
}
if (!id.address.getUuid().isPresent() || !id.address.getNumber().isPresent()) {
id.address = serviceAddress;
}
// Identity already exists, not updating the trust level
return true;
}
identities.add(new IdentityInfo(serviceAddress, identityKey, trustLevel, added != null ? added : new Date()));
return false;
}
/**
* Update trustLevel for the given identityKey for the user name.
*
* @param serviceAddress User address, i.e. phone number and/or uuid
* @param identityKey The user's public key
* @param trustLevel Level of trust: untrusted, trusted, trusted and verified
*/
public void setIdentityTrustLevel(
SignalServiceAddress serviceAddress, IdentityKey identityKey, TrustLevel trustLevel
) {
for (var id : identities) {
if (!id.address.matches(serviceAddress) || !id.identityKey.equals(identityKey)) {
continue;
}
if (!id.address.getUuid().isPresent() || !id.address.getNumber().isPresent()) {
id.address = serviceAddress;
}
id.trustLevel = trustLevel;
return;
}
identities.add(new IdentityInfo(serviceAddress, identityKey, trustLevel, new Date()));
}
public void removeIdentity(SignalServiceAddress serviceAddress, IdentityKey identityKey) {
identities.removeIf(id -> id.address.matches(serviceAddress) && id.identityKey.equals(identityKey));
}
@Override
public boolean isTrustedIdentity(SignalProtocolAddress address, IdentityKey identityKey, Direction direction) {
// TODO implement possibility for different handling of incoming/outgoing trust decisions
var serviceAddress = resolveSignalServiceAddress(address.getName());
var trustOnFirstUse = true;
for (var id : identities) {
if (!id.address.matches(serviceAddress)) {
continue;
}
if (id.identityKey.equals(identityKey)) {
return id.isTrusted();
} else {
trustOnFirstUse = false;
}
}
if (!trustOnFirstUse) {
saveIdentity(resolveSignalServiceAddress(address.getName()), identityKey, TrustLevel.UNTRUSTED, null);
}
return trustOnFirstUse;
}
@Override
public IdentityKey getIdentity(SignalProtocolAddress address) {
var serviceAddress = resolveSignalServiceAddress(address.getName());
var identity = getIdentity(serviceAddress);
return identity == null ? null : identity.getIdentityKey();
}
public IdentityInfo getIdentity(SignalServiceAddress serviceAddress) {
long maxDate = 0;
IdentityInfo maxIdentity = null;
for (var id : this.identities) {
if (!id.address.matches(serviceAddress)) {
continue;
}
final var time = id.getDateAdded().getTime();
if (maxIdentity == null || maxDate <= time) {
maxDate = time;
maxIdentity = id;
}
}
return maxIdentity;
}
public List<IdentityInfo> getIdentities() {
// TODO deep copy
return identities;
}
public List<IdentityInfo> getIdentities(SignalServiceAddress serviceAddress) {
var identities = new ArrayList<IdentityInfo>();
for (var identity : this.identities) {
if (identity.address.matches(serviceAddress)) {
identities.add(identity);
}
}
return identities;
}
public static class JsonIdentityKeyStoreDeserializer extends JsonDeserializer<JsonIdentityKeyStore> {
@Override
public JsonIdentityKeyStore deserialize(
JsonParser jsonParser, DeserializationContext deserializationContext
) throws IOException {
JsonNode node = jsonParser.getCodec().readTree(jsonParser);
var localRegistrationId = node.get("registrationId").asInt();
var identityKeyPair = new IdentityKeyPair(Base64.getDecoder().decode(node.get("identityKey").asText()));
var keyStore = new JsonIdentityKeyStore(identityKeyPair, localRegistrationId);
var trustedKeysNode = node.get("trustedKeys");
if (trustedKeysNode.isArray()) {
for (var trustedKey : trustedKeysNode) {
var trustedKeyName = trustedKey.hasNonNull("name") ? trustedKey.get("name").asText() : null;
if (UuidUtil.isUuid(trustedKeyName)) {
// Ignore identities that were incorrectly created with UUIDs as name
continue;
}
var uuid = trustedKey.hasNonNull("uuid")
? UuidUtil.parseOrNull(trustedKey.get("uuid").asText())
: null;
final var serviceAddress = uuid == null
? Utils.getSignalServiceAddressFromIdentifier(trustedKeyName)
: new SignalServiceAddress(uuid, trustedKeyName);
try {
var id = new IdentityKey(Base64.getDecoder().decode(trustedKey.get("identityKey").asText()), 0);
var trustLevel = trustedKey.hasNonNull("trustLevel") ? TrustLevel.fromInt(trustedKey.get(
"trustLevel").asInt()) : TrustLevel.TRUSTED_UNVERIFIED;
var added = trustedKey.hasNonNull("addedTimestamp") ? new Date(trustedKey.get("addedTimestamp")
.asLong()) : new Date();
keyStore.saveIdentity(serviceAddress, id, trustLevel, added);
} catch (InvalidKeyException e) {
logger.warn("Error while decoding key for {}: {}", trustedKeyName, e.getMessage());
}
}
}
return keyStore;
}
}
public static class JsonIdentityKeyStoreSerializer extends JsonSerializer<JsonIdentityKeyStore> {
@Override
public void serialize(
JsonIdentityKeyStore jsonIdentityKeyStore, JsonGenerator json, SerializerProvider serializerProvider
) throws IOException {
json.writeStartObject();
json.writeNumberField("registrationId", jsonIdentityKeyStore.getLocalRegistrationId());
json.writeStringField("identityKey",
Base64.getEncoder().encodeToString(jsonIdentityKeyStore.getIdentityKeyPair().serialize()));
json.writeStringField("identityPrivateKey",
Base64.getEncoder()
.encodeToString(jsonIdentityKeyStore.getIdentityKeyPair().getPrivateKey().serialize()));
json.writeStringField("identityPublicKey",
Base64.getEncoder()
.encodeToString(jsonIdentityKeyStore.getIdentityKeyPair().getPublicKey().serialize()));
json.writeArrayFieldStart("trustedKeys");
for (var trustedKey : jsonIdentityKeyStore.identities) {
json.writeStartObject();
if (trustedKey.getAddress().getNumber().isPresent()) {
json.writeStringField("name", trustedKey.getAddress().getNumber().get());
}
if (trustedKey.getAddress().getUuid().isPresent()) {
json.writeStringField("uuid", trustedKey.getAddress().getUuid().get().toString());
}
json.writeStringField("identityKey",
Base64.getEncoder().encodeToString(trustedKey.identityKey.serialize()));
json.writeNumberField("trustLevel", trustedKey.trustLevel.ordinal());
json.writeNumberField("addedTimestamp", trustedKey.added.getTime());
json.writeEndObject();
}
json.writeEndArray();
json.writeEndObject();
}
}
}

View file

@ -0,0 +1,104 @@
package org.asamk.signal.manager.storage.protocol;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonDeserializer;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.SerializerProvider;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.libsignal.InvalidKeyIdException;
import org.whispersystems.libsignal.state.PreKeyRecord;
import org.whispersystems.libsignal.state.PreKeyStore;
import java.io.IOException;
import java.util.Base64;
import java.util.HashMap;
import java.util.Map;
class JsonPreKeyStore implements PreKeyStore {
private final static Logger logger = LoggerFactory.getLogger(JsonPreKeyStore.class);
private final Map<Integer, byte[]> store = new HashMap<>();
public JsonPreKeyStore() {
}
private void addPreKeys(Map<Integer, byte[]> preKeys) {
store.putAll(preKeys);
}
@Override
public PreKeyRecord loadPreKey(int preKeyId) throws InvalidKeyIdException {
try {
if (!store.containsKey(preKeyId)) {
throw new InvalidKeyIdException("No such prekeyrecord!");
}
return new PreKeyRecord(store.get(preKeyId));
} catch (IOException e) {
throw new AssertionError(e);
}
}
@Override
public void storePreKey(int preKeyId, PreKeyRecord record) {
store.put(preKeyId, record.serialize());
}
@Override
public boolean containsPreKey(int preKeyId) {
return store.containsKey(preKeyId);
}
@Override
public void removePreKey(int preKeyId) {
store.remove(preKeyId);
}
public static class JsonPreKeyStoreDeserializer extends JsonDeserializer<JsonPreKeyStore> {
@Override
public JsonPreKeyStore deserialize(
JsonParser jsonParser, DeserializationContext deserializationContext
) throws IOException {
JsonNode node = jsonParser.getCodec().readTree(jsonParser);
var preKeyMap = new HashMap<Integer, byte[]>();
if (node.isArray()) {
for (var preKey : node) {
final var preKeyId = preKey.get("id").asInt();
final var preKeyRecord = Base64.getDecoder().decode(preKey.get("record").asText());
preKeyMap.put(preKeyId, preKeyRecord);
}
}
var keyStore = new JsonPreKeyStore();
keyStore.addPreKeys(preKeyMap);
return keyStore;
}
}
public static class JsonPreKeyStoreSerializer extends JsonSerializer<JsonPreKeyStore> {
@Override
public void serialize(
JsonPreKeyStore jsonPreKeyStore, JsonGenerator json, SerializerProvider serializerProvider
) throws IOException {
json.writeStartArray();
for (var preKey : jsonPreKeyStore.store.entrySet()) {
json.writeStartObject();
json.writeNumberField("id", preKey.getKey());
json.writeStringField("record", Base64.getEncoder().encodeToString(preKey.getValue()));
json.writeEndObject();
}
json.writeEndArray();
}
}
}

View file

@ -0,0 +1,214 @@
package org.asamk.signal.manager.storage.protocol;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonDeserializer;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.SerializerProvider;
import org.asamk.signal.manager.util.Utils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.libsignal.SignalProtocolAddress;
import org.whispersystems.libsignal.protocol.CiphertextMessage;
import org.whispersystems.libsignal.state.SessionRecord;
import org.whispersystems.signalservice.api.SignalServiceSessionStore;
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
import org.whispersystems.signalservice.api.util.UuidUtil;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Base64;
import java.util.LinkedList;
import java.util.List;
class JsonSessionStore implements SignalServiceSessionStore {
private final static Logger logger = LoggerFactory.getLogger(JsonSessionStore.class);
private final List<SessionInfo> sessions = new ArrayList<>();
private SignalServiceAddressResolver resolver;
public JsonSessionStore() {
}
public void setResolver(final SignalServiceAddressResolver resolver) {
this.resolver = resolver;
}
private SignalServiceAddress resolveSignalServiceAddress(String identifier) {
if (resolver != null) {
return resolver.resolveSignalServiceAddress(identifier);
} else {
return Utils.getSignalServiceAddressFromIdentifier(identifier);
}
}
@Override
public synchronized SessionRecord loadSession(SignalProtocolAddress address) {
var serviceAddress = resolveSignalServiceAddress(address.getName());
for (var info : sessions) {
if (info.address.matches(serviceAddress) && info.deviceId == address.getDeviceId()) {
try {
return new SessionRecord(info.sessionRecord);
} catch (IOException e) {
logger.warn("Failed to load session, resetting session: {}", e.getMessage());
return new SessionRecord();
}
}
}
return new SessionRecord();
}
public synchronized List<SessionInfo> getSessions() {
return sessions;
}
@Override
public synchronized List<Integer> getSubDeviceSessions(String name) {
var serviceAddress = resolveSignalServiceAddress(name);
var deviceIds = new LinkedList<Integer>();
for (var info : sessions) {
if (info.address.matches(serviceAddress) && info.deviceId != 1) {
deviceIds.add(info.deviceId);
}
}
return deviceIds;
}
@Override
public synchronized void storeSession(SignalProtocolAddress address, SessionRecord record) {
var serviceAddress = resolveSignalServiceAddress(address.getName());
for (var info : sessions) {
if (info.address.matches(serviceAddress) && info.deviceId == address.getDeviceId()) {
if (!info.address.getUuid().isPresent() || !info.address.getNumber().isPresent()) {
info.address = serviceAddress;
}
info.sessionRecord = record.serialize();
return;
}
}
sessions.add(new SessionInfo(serviceAddress, address.getDeviceId(), record.serialize()));
}
@Override
public synchronized boolean containsSession(SignalProtocolAddress address) {
var serviceAddress = resolveSignalServiceAddress(address.getName());
for (var info : sessions) {
if (info.address.matches(serviceAddress) && info.deviceId == address.getDeviceId()) {
final SessionRecord sessionRecord;
try {
sessionRecord = new SessionRecord(info.sessionRecord);
} catch (IOException e) {
logger.warn("Failed to check session: {}", e.getMessage());
return false;
}
return sessionRecord.hasSenderChain()
&& sessionRecord.getSessionVersion() == CiphertextMessage.CURRENT_VERSION;
}
}
return false;
}
@Override
public synchronized void deleteSession(SignalProtocolAddress address) {
var serviceAddress = resolveSignalServiceAddress(address.getName());
sessions.removeIf(info -> info.address.matches(serviceAddress) && info.deviceId == address.getDeviceId());
}
@Override
public synchronized void deleteAllSessions(String name) {
var serviceAddress = resolveSignalServiceAddress(name);
deleteAllSessions(serviceAddress);
}
public synchronized void deleteAllSessions(SignalServiceAddress serviceAddress) {
sessions.removeIf(info -> info.address.matches(serviceAddress));
}
@Override
public void archiveSession(final SignalProtocolAddress address) {
final var sessionRecord = loadSession(address);
if (sessionRecord == null) {
return;
}
sessionRecord.archiveCurrentState();
storeSession(address, sessionRecord);
}
public void archiveAllSessions() {
for (var info : sessions) {
try {
final var sessionRecord = new SessionRecord(info.sessionRecord);
sessionRecord.archiveCurrentState();
info.sessionRecord = sessionRecord.serialize();
} catch (IOException ignored) {
}
}
}
public static class JsonSessionStoreDeserializer extends JsonDeserializer<JsonSessionStore> {
@Override
public JsonSessionStore deserialize(
JsonParser jsonParser, DeserializationContext deserializationContext
) throws IOException {
JsonNode node = jsonParser.getCodec().readTree(jsonParser);
var sessionStore = new JsonSessionStore();
if (node.isArray()) {
for (var session : node) {
var sessionName = session.hasNonNull("name") ? session.get("name").asText() : null;
if (UuidUtil.isUuid(sessionName)) {
// Ignore sessions that were incorrectly created with UUIDs as name
continue;
}
var uuid = session.hasNonNull("uuid") ? UuidUtil.parseOrNull(session.get("uuid").asText()) : null;
final var serviceAddress = uuid == null
? Utils.getSignalServiceAddressFromIdentifier(sessionName)
: new SignalServiceAddress(uuid, sessionName);
final var deviceId = session.get("deviceId").asInt();
final var record = Base64.getDecoder().decode(session.get("record").asText());
var sessionInfo = new SessionInfo(serviceAddress, deviceId, record);
sessionStore.sessions.add(sessionInfo);
}
}
return sessionStore;
}
}
public static class JsonSessionStoreSerializer extends JsonSerializer<JsonSessionStore> {
@Override
public void serialize(
JsonSessionStore jsonSessionStore, JsonGenerator json, SerializerProvider serializerProvider
) throws IOException {
json.writeStartArray();
for (var sessionInfo : jsonSessionStore.sessions) {
json.writeStartObject();
if (sessionInfo.address.getNumber().isPresent()) {
json.writeStringField("name", sessionInfo.address.getNumber().get());
}
if (sessionInfo.address.getUuid().isPresent()) {
json.writeStringField("uuid", sessionInfo.address.getUuid().get().toString());
}
json.writeNumberField("deviceId", sessionInfo.deviceId);
json.writeStringField("record", Base64.getEncoder().encodeToString(sessionInfo.sessionRecord));
json.writeEndObject();
}
json.writeEndArray();
}
}
}

View file

@ -0,0 +1,211 @@
package org.asamk.signal.manager.storage.protocol;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import org.asamk.signal.manager.TrustLevel;
import org.whispersystems.libsignal.IdentityKey;
import org.whispersystems.libsignal.IdentityKeyPair;
import org.whispersystems.libsignal.InvalidKeyIdException;
import org.whispersystems.libsignal.SignalProtocolAddress;
import org.whispersystems.libsignal.state.PreKeyRecord;
import org.whispersystems.libsignal.state.SessionRecord;
import org.whispersystems.libsignal.state.SignedPreKeyRecord;
import org.whispersystems.signalservice.api.SignalServiceProtocolStore;
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
import java.util.List;
public class JsonSignalProtocolStore implements SignalServiceProtocolStore {
@JsonProperty("preKeys")
@JsonDeserialize(using = JsonPreKeyStore.JsonPreKeyStoreDeserializer.class)
@JsonSerialize(using = JsonPreKeyStore.JsonPreKeyStoreSerializer.class)
private JsonPreKeyStore preKeyStore;
@JsonProperty("sessionStore")
@JsonDeserialize(using = JsonSessionStore.JsonSessionStoreDeserializer.class)
@JsonSerialize(using = JsonSessionStore.JsonSessionStoreSerializer.class)
private JsonSessionStore sessionStore;
@JsonProperty("signedPreKeyStore")
@JsonDeserialize(using = JsonSignedPreKeyStore.JsonSignedPreKeyStoreDeserializer.class)
@JsonSerialize(using = JsonSignedPreKeyStore.JsonSignedPreKeyStoreSerializer.class)
private JsonSignedPreKeyStore signedPreKeyStore;
@JsonProperty("identityKeyStore")
@JsonDeserialize(using = JsonIdentityKeyStore.JsonIdentityKeyStoreDeserializer.class)
@JsonSerialize(using = JsonIdentityKeyStore.JsonIdentityKeyStoreSerializer.class)
private JsonIdentityKeyStore identityKeyStore;
public JsonSignalProtocolStore() {
}
public JsonSignalProtocolStore(
JsonPreKeyStore preKeyStore,
JsonSessionStore sessionStore,
JsonSignedPreKeyStore signedPreKeyStore,
JsonIdentityKeyStore identityKeyStore
) {
this.preKeyStore = preKeyStore;
this.sessionStore = sessionStore;
this.signedPreKeyStore = signedPreKeyStore;
this.identityKeyStore = identityKeyStore;
}
public JsonSignalProtocolStore(IdentityKeyPair identityKeyPair, int registrationId) {
preKeyStore = new JsonPreKeyStore();
sessionStore = new JsonSessionStore();
signedPreKeyStore = new JsonSignedPreKeyStore();
this.identityKeyStore = new JsonIdentityKeyStore(identityKeyPair, registrationId);
}
public void setResolver(final SignalServiceAddressResolver resolver) {
sessionStore.setResolver(resolver);
identityKeyStore.setResolver(resolver);
}
@Override
public IdentityKeyPair getIdentityKeyPair() {
return identityKeyStore.getIdentityKeyPair();
}
@Override
public int getLocalRegistrationId() {
return identityKeyStore.getLocalRegistrationId();
}
@Override
public boolean saveIdentity(SignalProtocolAddress address, IdentityKey identityKey) {
return identityKeyStore.saveIdentity(address, identityKey);
}
public void saveIdentity(SignalServiceAddress serviceAddress, IdentityKey identityKey, TrustLevel trustLevel) {
identityKeyStore.saveIdentity(serviceAddress, identityKey, trustLevel, null);
}
public void setIdentityTrustLevel(
SignalServiceAddress serviceAddress, IdentityKey identityKey, TrustLevel trustLevel
) {
identityKeyStore.setIdentityTrustLevel(serviceAddress, identityKey, trustLevel);
}
public void removeIdentity(SignalServiceAddress serviceAddress, IdentityKey identityKey) {
identityKeyStore.removeIdentity(serviceAddress, identityKey);
}
public List<IdentityInfo> getIdentities() {
return identityKeyStore.getIdentities();
}
public List<IdentityInfo> getIdentities(SignalServiceAddress serviceAddress) {
return identityKeyStore.getIdentities(serviceAddress);
}
@Override
public boolean isTrustedIdentity(SignalProtocolAddress address, IdentityKey identityKey, Direction direction) {
return identityKeyStore.isTrustedIdentity(address, identityKey, direction);
}
@Override
public IdentityKey getIdentity(SignalProtocolAddress address) {
return identityKeyStore.getIdentity(address);
}
public IdentityInfo getIdentity(SignalServiceAddress serviceAddress) {
return identityKeyStore.getIdentity(serviceAddress);
}
@Override
public PreKeyRecord loadPreKey(int preKeyId) throws InvalidKeyIdException {
return preKeyStore.loadPreKey(preKeyId);
}
@Override
public void storePreKey(int preKeyId, PreKeyRecord record) {
preKeyStore.storePreKey(preKeyId, record);
}
@Override
public boolean containsPreKey(int preKeyId) {
return preKeyStore.containsPreKey(preKeyId);
}
@Override
public void removePreKey(int preKeyId) {
preKeyStore.removePreKey(preKeyId);
}
@Override
public SessionRecord loadSession(SignalProtocolAddress address) {
return sessionStore.loadSession(address);
}
public List<SessionInfo> getSessions() {
return sessionStore.getSessions();
}
@Override
public List<Integer> getSubDeviceSessions(String name) {
return sessionStore.getSubDeviceSessions(name);
}
@Override
public void storeSession(SignalProtocolAddress address, SessionRecord record) {
sessionStore.storeSession(address, record);
}
@Override
public boolean containsSession(SignalProtocolAddress address) {
return sessionStore.containsSession(address);
}
@Override
public void deleteSession(SignalProtocolAddress address) {
sessionStore.deleteSession(address);
}
@Override
public void deleteAllSessions(String name) {
sessionStore.deleteAllSessions(name);
}
public void deleteAllSessions(SignalServiceAddress serviceAddress) {
sessionStore.deleteAllSessions(serviceAddress);
}
@Override
public void archiveSession(final SignalProtocolAddress address) {
sessionStore.archiveSession(address);
}
public void archiveAllSessions() {
sessionStore.archiveAllSessions();
}
@Override
public SignedPreKeyRecord loadSignedPreKey(int signedPreKeyId) throws InvalidKeyIdException {
return signedPreKeyStore.loadSignedPreKey(signedPreKeyId);
}
@Override
public List<SignedPreKeyRecord> loadSignedPreKeys() {
return signedPreKeyStore.loadSignedPreKeys();
}
@Override
public void storeSignedPreKey(int signedPreKeyId, SignedPreKeyRecord record) {
signedPreKeyStore.storeSignedPreKey(signedPreKeyId, record);
}
@Override
public boolean containsSignedPreKey(int signedPreKeyId) {
return signedPreKeyStore.containsSignedPreKey(signedPreKeyId);
}
@Override
public void removeSignedPreKey(int signedPreKeyId) {
signedPreKeyStore.removeSignedPreKey(signedPreKeyId);
}
}

View file

@ -0,0 +1,121 @@
package org.asamk.signal.manager.storage.protocol;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonDeserializer;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.SerializerProvider;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.libsignal.InvalidKeyIdException;
import org.whispersystems.libsignal.state.SignedPreKeyRecord;
import org.whispersystems.libsignal.state.SignedPreKeyStore;
import java.io.IOException;
import java.util.Base64;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
class JsonSignedPreKeyStore implements SignedPreKeyStore {
private final static Logger logger = LoggerFactory.getLogger(JsonSignedPreKeyStore.class);
private final Map<Integer, byte[]> store = new HashMap<>();
public JsonSignedPreKeyStore() {
}
private void addSignedPreKeys(Map<Integer, byte[]> preKeys) {
store.putAll(preKeys);
}
@Override
public SignedPreKeyRecord loadSignedPreKey(int signedPreKeyId) throws InvalidKeyIdException {
try {
if (!store.containsKey(signedPreKeyId)) {
throw new InvalidKeyIdException("No such signedprekeyrecord! " + signedPreKeyId);
}
return new SignedPreKeyRecord(store.get(signedPreKeyId));
} catch (IOException e) {
throw new AssertionError(e);
}
}
@Override
public List<SignedPreKeyRecord> loadSignedPreKeys() {
try {
var results = new LinkedList<SignedPreKeyRecord>();
for (var serialized : store.values()) {
results.add(new SignedPreKeyRecord(serialized));
}
return results;
} catch (IOException e) {
throw new AssertionError(e);
}
}
@Override
public void storeSignedPreKey(int signedPreKeyId, SignedPreKeyRecord record) {
store.put(signedPreKeyId, record.serialize());
}
@Override
public boolean containsSignedPreKey(int signedPreKeyId) {
return store.containsKey(signedPreKeyId);
}
@Override
public void removeSignedPreKey(int signedPreKeyId) {
store.remove(signedPreKeyId);
}
public static class JsonSignedPreKeyStoreDeserializer extends JsonDeserializer<JsonSignedPreKeyStore> {
@Override
public JsonSignedPreKeyStore deserialize(
JsonParser jsonParser, DeserializationContext deserializationContext
) throws IOException {
JsonNode node = jsonParser.getCodec().readTree(jsonParser);
var preKeyMap = new HashMap<Integer, byte[]>();
if (node.isArray()) {
for (var preKey : node) {
final var preKeyId = preKey.get("id").asInt();
final var preKeyRecord = Base64.getDecoder().decode(preKey.get("record").asText());
preKeyMap.put(preKeyId, preKeyRecord);
}
}
var keyStore = new JsonSignedPreKeyStore();
keyStore.addSignedPreKeys(preKeyMap);
return keyStore;
}
}
public static class JsonSignedPreKeyStoreSerializer extends JsonSerializer<JsonSignedPreKeyStore> {
@Override
public void serialize(
JsonSignedPreKeyStore jsonPreKeyStore, JsonGenerator json, SerializerProvider serializerProvider
) throws IOException {
json.writeStartArray();
for (var signedPreKey : jsonPreKeyStore.store.entrySet()) {
json.writeStartObject();
json.writeNumberField("id", signedPreKey.getKey());
json.writeStringField("record", Base64.getEncoder().encodeToString(signedPreKey.getValue()));
json.writeEndObject();
}
json.writeEndArray();
}
}
}

View file

@ -0,0 +1,87 @@
package org.asamk.signal.manager.storage.protocol;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonDeserializer;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.SerializerProvider;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
import org.whispersystems.signalservice.api.util.UuidUtil;
import java.io.IOException;
import java.util.HashSet;
import java.util.Set;
public class RecipientStore {
@JsonProperty("recipientStore")
@JsonDeserialize(using = RecipientStoreDeserializer.class)
@JsonSerialize(using = RecipientStoreSerializer.class)
private final Set<SignalServiceAddress> addresses = new HashSet<>();
public SignalServiceAddress resolveServiceAddress(SignalServiceAddress serviceAddress) {
if (addresses.contains(serviceAddress)) {
// If the Set already contains the exact address with UUID and Number,
// we can just return it here.
return serviceAddress;
}
for (var address : addresses) {
if (address.matches(serviceAddress)) {
return address;
}
}
if (serviceAddress.getNumber().isPresent() && serviceAddress.getUuid().isPresent()) {
addresses.add(serviceAddress);
}
return serviceAddress;
}
public static class RecipientStoreDeserializer extends JsonDeserializer<Set<SignalServiceAddress>> {
@Override
public Set<SignalServiceAddress> deserialize(
JsonParser jsonParser, DeserializationContext deserializationContext
) throws IOException {
JsonNode node = jsonParser.getCodec().readTree(jsonParser);
var addresses = new HashSet<SignalServiceAddress>();
if (node.isArray()) {
for (var recipient : node) {
var recipientName = recipient.get("name").asText();
var uuid = UuidUtil.parseOrThrow(recipient.get("uuid").asText());
final var serviceAddress = new SignalServiceAddress(uuid, recipientName);
addresses.add(serviceAddress);
}
}
return addresses;
}
}
public static class RecipientStoreSerializer extends JsonSerializer<Set<SignalServiceAddress>> {
@Override
public void serialize(
Set<SignalServiceAddress> addresses, JsonGenerator json, SerializerProvider serializerProvider
) throws IOException {
json.writeStartArray();
for (var address : addresses) {
json.writeStartObject();
json.writeStringField("name", address.getNumber().get());
json.writeStringField("uuid", address.getUuid().get().toString());
json.writeEndObject();
}
json.writeEndArray();
}
}
}

View file

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

View file

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

View file

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

View file

@ -0,0 +1,69 @@
package org.asamk.signal.manager.storage.stickers;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonDeserializer;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.SerializerProvider;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import java.io.IOException;
import java.util.Base64;
import java.util.HashMap;
import java.util.Map;
public class StickerStore {
@JsonSerialize(using = StickersSerializer.class)
@JsonDeserialize(using = StickersDeserializer.class)
private final Map<byte[], Sticker> stickers = new HashMap<>();
public Sticker getSticker(byte[] packId) {
return stickers.get(packId);
}
public void updateSticker(Sticker sticker) {
stickers.put(sticker.getPackId(), sticker);
}
private static class StickersSerializer extends JsonSerializer<Map<byte[], Sticker>> {
@Override
public void serialize(
final Map<byte[], Sticker> value, final JsonGenerator jgen, final SerializerProvider provider
) throws IOException {
final var stickers = value.values();
jgen.writeStartArray(stickers.size());
for (var sticker : stickers) {
jgen.writeStartObject();
jgen.writeStringField("packId", Base64.getEncoder().encodeToString(sticker.getPackId()));
jgen.writeStringField("packKey", Base64.getEncoder().encodeToString(sticker.getPackKey()));
jgen.writeBooleanField("installed", sticker.isInstalled());
jgen.writeEndObject();
}
jgen.writeEndArray();
}
}
private static class StickersDeserializer extends JsonDeserializer<Map<byte[], Sticker>> {
@Override
public Map<byte[], Sticker> deserialize(
JsonParser jsonParser, DeserializationContext deserializationContext
) throws IOException {
var stickers = new HashMap<byte[], Sticker>();
JsonNode node = jsonParser.getCodec().readTree(jsonParser);
for (var n : node) {
var packId = Base64.getDecoder().decode(n.get("packId").asText());
var packKey = Base64.getDecoder().decode(n.get("packKey").asText());
var installed = n.get("installed").asBoolean(false);
stickers.put(packId, new Sticker(packId, packKey, installed));
}
return stickers;
}
}
}

View file

@ -0,0 +1,60 @@
package org.asamk.signal.manager.storage.threads;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonDeserializer;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializerProvider;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import java.io.IOException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
public class LegacyJsonThreadStore {
private static final ObjectMapper jsonProcessor = new ObjectMapper();
@JsonProperty("threads")
@JsonSerialize(using = MapToListSerializer.class)
@JsonDeserialize(using = ThreadsDeserializer.class)
private Map<String, ThreadInfo> threads = new HashMap<>();
public List<ThreadInfo> getThreads() {
return new ArrayList<>(threads.values());
}
private static class MapToListSerializer extends JsonSerializer<Map<?, ?>> {
@Override
public void serialize(
final Map<?, ?> value, final JsonGenerator jgen, final SerializerProvider provider
) throws IOException {
jgen.writeObject(value.values());
}
}
private static class ThreadsDeserializer extends JsonDeserializer<Map<String, ThreadInfo>> {
@Override
public Map<String, ThreadInfo> deserialize(
JsonParser jsonParser, DeserializationContext deserializationContext
) throws IOException {
var threads = new HashMap<String, ThreadInfo>();
JsonNode node = jsonParser.getCodec().readTree(jsonParser);
for (var n : node) {
var t = jsonProcessor.treeToValue(n, ThreadInfo.class);
threads.put(t.id, t);
}
return threads;
}
}
}

View file

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

View file

@ -0,0 +1,62 @@
package org.asamk.signal.manager.util;
import org.asamk.signal.manager.AttachmentInvalidException;
import org.whispersystems.libsignal.util.guava.Optional;
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment;
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentStream;
import org.whispersystems.signalservice.api.util.StreamDetails;
import org.whispersystems.signalservice.internal.push.http.ResumableUploadSpec;
import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
public class AttachmentUtils {
public static List<SignalServiceAttachment> getSignalServiceAttachments(List<String> attachments) throws AttachmentInvalidException {
List<SignalServiceAttachment> signalServiceAttachments = null;
if (attachments != null) {
signalServiceAttachments = new ArrayList<>(attachments.size());
for (var attachment : attachments) {
try {
signalServiceAttachments.add(createAttachment(new File(attachment)));
} catch (IOException e) {
throw new AttachmentInvalidException(attachment, e);
}
}
}
return signalServiceAttachments;
}
public static SignalServiceAttachmentStream createAttachment(File attachmentFile) throws IOException {
final var streamDetails = Utils.createStreamDetailsFromFile(attachmentFile);
return createAttachment(streamDetails, Optional.of(attachmentFile.getName()));
}
public static SignalServiceAttachmentStream createAttachment(
StreamDetails streamDetails, Optional<String> name
) {
// TODO mabybe add a parameter to set the voiceNote, borderless, preview, width, height and caption option
final var uploadTimestamp = System.currentTimeMillis();
Optional<byte[]> preview = Optional.absent();
Optional<String> caption = Optional.absent();
Optional<String> blurHash = Optional.absent();
final Optional<ResumableUploadSpec> resumableUploadSpec = Optional.absent();
return new SignalServiceAttachmentStream(streamDetails.getStream(),
streamDetails.getContentType(),
streamDetails.getLength(),
name,
false,
false,
preview,
0,
0,
uploadTimestamp,
caption,
blurHash,
null,
null,
resumableUploadSpec);
}
}

View file

@ -0,0 +1,75 @@
package org.asamk.signal.manager.util;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.attribute.PosixFilePermission;
import java.nio.file.attribute.PosixFilePermissions;
import java.util.EnumSet;
import java.util.Set;
import static java.nio.file.attribute.PosixFilePermission.OWNER_EXECUTE;
import static java.nio.file.attribute.PosixFilePermission.OWNER_READ;
import static java.nio.file.attribute.PosixFilePermission.OWNER_WRITE;
public class IOUtils {
public static File createTempFile() throws IOException {
final var tempFile = File.createTempFile("signal-cli_tmp_", ".tmp");
tempFile.deleteOnExit();
return tempFile;
}
public static byte[] readFully(InputStream in) throws IOException {
var baos = new ByteArrayOutputStream();
IOUtils.copyStream(in, baos);
return baos.toByteArray();
}
public static void createPrivateDirectories(File file) throws IOException {
if (file.exists()) {
return;
}
final var path = file.toPath();
try {
Set<PosixFilePermission> perms = EnumSet.of(OWNER_READ, OWNER_WRITE, OWNER_EXECUTE);
Files.createDirectories(path, PosixFilePermissions.asFileAttribute(perms));
} catch (UnsupportedOperationException e) {
Files.createDirectories(path);
}
}
public static void createPrivateFile(File path) throws IOException {
final var file = path.toPath();
try {
Set<PosixFilePermission> perms = EnumSet.of(OWNER_READ, OWNER_WRITE);
Files.createFile(file, PosixFilePermissions.asFileAttribute(perms));
} catch (UnsupportedOperationException e) {
Files.createFile(file);
}
}
public static void copyFileToStream(File inputFile, OutputStream output) throws IOException {
try (InputStream inputStream = new FileInputStream(inputFile)) {
copyStream(inputStream, output);
}
}
public static void copyStream(InputStream input, OutputStream output) throws IOException {
copyStream(input, output, 4096);
}
public static void copyStream(InputStream input, OutputStream output, int bufferSize) throws IOException {
var buffer = new byte[bufferSize];
int read;
while ((read = input.read(buffer)) != -1) {
output.write(buffer, 0, read);
}
}
}

View file

@ -0,0 +1,93 @@
package org.asamk.signal.manager.util;
import org.signal.zkgroup.InvalidInputException;
import org.signal.zkgroup.profiles.ProfileKey;
import org.whispersystems.libsignal.IdentityKey;
import org.whispersystems.libsignal.IdentityKeyPair;
import org.whispersystems.libsignal.InvalidKeyException;
import org.whispersystems.libsignal.ecc.Curve;
import org.whispersystems.libsignal.state.PreKeyRecord;
import org.whispersystems.libsignal.state.SignedPreKeyRecord;
import org.whispersystems.libsignal.util.Medium;
import org.whispersystems.signalservice.api.kbs.MasterKey;
import java.security.SecureRandom;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
public class KeyUtils {
private static final SecureRandom secureRandom = new SecureRandom();
private KeyUtils() {
}
public static IdentityKeyPair generateIdentityKeyPair() {
var djbKeyPair = Curve.generateKeyPair();
var djbIdentityKey = new IdentityKey(djbKeyPair.getPublicKey());
var djbPrivateKey = djbKeyPair.getPrivateKey();
return new IdentityKeyPair(djbIdentityKey, djbPrivateKey);
}
public static List<PreKeyRecord> generatePreKeyRecords(final int offset, final int batchSize) {
var records = new ArrayList<PreKeyRecord>(batchSize);
for (var i = 0; i < batchSize; i++) {
var preKeyId = (offset + i) % Medium.MAX_VALUE;
var keyPair = Curve.generateKeyPair();
var record = new PreKeyRecord(preKeyId, keyPair);
records.add(record);
}
return records;
}
public static SignedPreKeyRecord generateSignedPreKeyRecord(
final IdentityKeyPair identityKeyPair, final int signedPreKeyId
) {
var keyPair = Curve.generateKeyPair();
byte[] signature;
try {
signature = Curve.calculateSignature(identityKeyPair.getPrivateKey(), keyPair.getPublicKey().serialize());
} catch (InvalidKeyException e) {
throw new AssertionError(e);
}
return new SignedPreKeyRecord(signedPreKeyId, System.currentTimeMillis(), keyPair, signature);
}
public static String createSignalingKey() {
return getSecret(52);
}
public static ProfileKey createProfileKey() {
try {
return new ProfileKey(getSecretBytes(32));
} catch (InvalidInputException e) {
throw new AssertionError("Profile key is guaranteed to be 32 bytes here");
}
}
public static String createPassword() {
return getSecret(18);
}
public static byte[] createStickerUploadKey() {
return getSecretBytes(32);
}
public static MasterKey createMasterKey() {
return MasterKey.createNew(secureRandom);
}
private static String getSecret(int size) {
var secret = getSecretBytes(size);
return Base64.getEncoder().encodeToString(secret);
}
public static byte[] getSecretBytes(int size) {
var secret = new byte[size];
secureRandom.nextBytes(secret);
return secret;
}
}

View file

@ -0,0 +1,105 @@
package org.asamk.signal.manager.util;
import org.whispersystems.libsignal.util.guava.Optional;
import org.whispersystems.signalservice.api.messages.SignalServiceEnvelope;
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
import org.whispersystems.signalservice.api.util.UuidUtil;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.util.UUID;
public class MessageCacheUtils {
public static SignalServiceEnvelope loadEnvelope(File file) throws IOException {
try (var f = new FileInputStream(file)) {
var in = new DataInputStream(f);
var version = in.readInt();
if (version > 4) {
return null;
}
var type = in.readInt();
var source = in.readUTF();
UUID sourceUuid = null;
if (version >= 3) {
sourceUuid = UuidUtil.parseOrNull(in.readUTF());
}
var sourceDevice = in.readInt();
if (version == 1) {
// read legacy relay field
in.readUTF();
}
var timestamp = in.readLong();
byte[] content = null;
var contentLen = in.readInt();
if (contentLen > 0) {
content = new byte[contentLen];
in.readFully(content);
}
byte[] legacyMessage = null;
var legacyMessageLen = in.readInt();
if (legacyMessageLen > 0) {
legacyMessage = new byte[legacyMessageLen];
in.readFully(legacyMessage);
}
long serverReceivedTimestamp = 0;
String uuid = null;
if (version >= 2) {
serverReceivedTimestamp = in.readLong();
uuid = in.readUTF();
if ("".equals(uuid)) {
uuid = null;
}
}
long serverDeliveredTimestamp = 0;
if (version >= 4) {
serverDeliveredTimestamp = in.readLong();
}
Optional<SignalServiceAddress> addressOptional = sourceUuid == null && source.isEmpty()
? Optional.absent()
: Optional.of(new SignalServiceAddress(sourceUuid, source));
return new SignalServiceEnvelope(type,
addressOptional,
sourceDevice,
timestamp,
legacyMessage,
content,
serverReceivedTimestamp,
serverDeliveredTimestamp,
uuid);
}
}
public static void storeEnvelope(SignalServiceEnvelope envelope, File file) throws IOException {
try (var f = new FileOutputStream(file)) {
try (var out = new DataOutputStream(f)) {
out.writeInt(4); // version
out.writeInt(envelope.getType());
out.writeUTF(envelope.getSourceE164().isPresent() ? envelope.getSourceE164().get() : "");
out.writeUTF(envelope.getSourceUuid().isPresent() ? envelope.getSourceUuid().get() : "");
out.writeInt(envelope.getSourceDevice());
out.writeLong(envelope.getTimestamp());
if (envelope.hasContent()) {
out.writeInt(envelope.getContent().length);
out.write(envelope.getContent());
} else {
out.writeInt(0);
}
if (envelope.hasLegacyMessage()) {
out.writeInt(envelope.getLegacyMessage().length);
out.write(envelope.getLegacyMessage());
} else {
out.writeInt(0);
}
out.writeLong(envelope.getServerReceivedTimestamp());
var uuid = envelope.getUuid();
out.writeUTF(uuid == null ? "" : uuid);
out.writeLong(envelope.getServerDeliveredTimestamp());
}
}
}
}

View file

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

View file

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

View file

@ -0,0 +1,110 @@
package org.asamk.signal.manager.util;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.asamk.signal.manager.JsonStickerPack;
import org.asamk.signal.manager.StickerPackInvalidException;
import org.whispersystems.libsignal.util.Pair;
import org.whispersystems.libsignal.util.guava.Optional;
import org.whispersystems.signalservice.api.messages.SignalServiceStickerManifestUpload;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.zip.ZipFile;
public class StickerUtils {
public static SignalServiceStickerManifestUpload getSignalServiceStickerManifestUpload(
final File file
) throws IOException, StickerPackInvalidException {
ZipFile zip = null;
String rootPath = null;
if (file.getName().endsWith(".zip")) {
zip = new ZipFile(file);
} else if (file.getName().equals("manifest.json")) {
rootPath = file.getParent();
} else {
throw new StickerPackInvalidException("Could not find manifest.json");
}
var pack = parseStickerPack(rootPath, zip);
if (pack.stickers == null) {
throw new StickerPackInvalidException("Must set a 'stickers' field.");
}
if (pack.stickers.isEmpty()) {
throw new StickerPackInvalidException("Must include stickers.");
}
var stickers = new ArrayList<SignalServiceStickerManifestUpload.StickerInfo>(pack.stickers.size());
for (var sticker : pack.stickers) {
if (sticker.file == null) {
throw new StickerPackInvalidException("Must set a 'file' field on each sticker.");
}
Pair<InputStream, Long> data;
try {
data = getInputStreamAndLength(rootPath, zip, sticker.file);
} catch (IOException ignored) {
throw new StickerPackInvalidException("Could not find find " + sticker.file);
}
var contentType = Utils.getFileMimeType(new File(sticker.file), null);
var stickerInfo = new SignalServiceStickerManifestUpload.StickerInfo(data.first(),
data.second(),
Optional.fromNullable(sticker.emoji).or(""),
contentType);
stickers.add(stickerInfo);
}
SignalServiceStickerManifestUpload.StickerInfo cover = null;
if (pack.cover != null) {
if (pack.cover.file == null) {
throw new StickerPackInvalidException("Must set a 'file' field on the cover.");
}
Pair<InputStream, Long> data;
try {
data = getInputStreamAndLength(rootPath, zip, pack.cover.file);
} catch (IOException ignored) {
throw new StickerPackInvalidException("Could not find find " + pack.cover.file);
}
var contentType = Utils.getFileMimeType(new File(pack.cover.file), null);
cover = new SignalServiceStickerManifestUpload.StickerInfo(data.first(),
data.second(),
Optional.fromNullable(pack.cover.emoji).or(""),
contentType);
}
return new SignalServiceStickerManifestUpload(pack.title, pack.author, cover, stickers);
}
private static JsonStickerPack parseStickerPack(String rootPath, ZipFile zip) throws IOException {
InputStream inputStream;
if (zip != null) {
inputStream = zip.getInputStream(zip.getEntry("manifest.json"));
} else {
inputStream = new FileInputStream((new File(rootPath, "manifest.json")));
}
return new ObjectMapper().readValue(inputStream, JsonStickerPack.class);
}
private static Pair<InputStream, Long> getInputStreamAndLength(
final String rootPath, final ZipFile zip, final String subfile
) throws IOException {
if (zip != null) {
final var entry = zip.getEntry(subfile);
return new Pair<>(zip.getInputStream(entry), entry.getSize());
} else {
final var file = new File(rootPath, subfile);
return new Pair<>(new FileInputStream(file), file.length());
}
}
}

View file

@ -0,0 +1,93 @@
package org.asamk.signal.manager.util;
import com.fasterxml.jackson.databind.JsonNode;
import org.whispersystems.libsignal.IdentityKey;
import org.whispersystems.libsignal.fingerprint.NumericFingerprintGenerator;
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
import org.whispersystems.signalservice.api.util.StreamDetails;
import org.whispersystems.signalservice.api.util.UuidUtil;
import java.io.BufferedInputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InvalidObjectException;
import java.net.URLConnection;
import java.nio.file.Files;
public class Utils {
public static String getFileMimeType(File file, String defaultMimeType) throws IOException {
var mime = Files.probeContentType(file.toPath());
if (mime == null) {
try (InputStream bufferedStream = new BufferedInputStream(new FileInputStream(file))) {
mime = URLConnection.guessContentTypeFromStream(bufferedStream);
}
}
if (mime == null) {
return defaultMimeType;
}
return mime;
}
public static StreamDetails createStreamDetailsFromFile(File file) throws IOException {
InputStream stream = new FileInputStream(file);
final var size = file.length();
final var mime = getFileMimeType(file, "application/octet-stream");
return new StreamDetails(stream, mime, size);
}
public static String computeSafetyNumber(
boolean isUuidCapable,
SignalServiceAddress ownAddress,
IdentityKey ownIdentityKey,
SignalServiceAddress theirAddress,
IdentityKey theirIdentityKey
) {
int version;
byte[] ownId;
byte[] theirId;
if (isUuidCapable && ownAddress.getUuid().isPresent() && theirAddress.getUuid().isPresent()) {
// Version 2: UUID user
version = 2;
ownId = UuidUtil.toByteArray(ownAddress.getUuid().get());
theirId = UuidUtil.toByteArray(theirAddress.getUuid().get());
} else {
// Version 1: E164 user
version = 1;
if (!ownAddress.getNumber().isPresent() || !theirAddress.getNumber().isPresent()) {
return "INVALID ID";
}
ownId = ownAddress.getNumber().get().getBytes();
theirId = theirAddress.getNumber().get().getBytes();
}
var fingerprint = new NumericFingerprintGenerator(5200).createFor(version,
ownId,
ownIdentityKey,
theirId,
theirIdentityKey);
return fingerprint.getDisplayableFingerprint().getDisplayText();
}
public static SignalServiceAddress getSignalServiceAddressFromIdentifier(final String identifier) {
if (UuidUtil.isUuid(identifier)) {
return new SignalServiceAddress(UuidUtil.parseOrNull(identifier), null);
} else {
return new SignalServiceAddress(null, identifier);
}
}
public static JsonNode getNotNullNode(JsonNode parent, String name) throws InvalidObjectException {
var node = parent.get(name);
if (node == null || node.isNull()) {
throw new InvalidObjectException(String.format("Incorrect file format: expected parameter %s not found ",
name));
}
return node;
}
}

259
man/signal-cli-dbus.5.adoc Executable file
View file

@ -0,0 +1,259 @@
/////
vim:set ts=4 sw=4 tw=82 noet:
/////
:quotes.~:
= signal-cli-dbus (5)
== Name
DBus API for signal-cli - A commandline and dbus interface for the Signal messenger
== Synopsis
*signal-cli* [--verbose] [--config CONFIG] [-u USERNAME] [-o {plain-text,json}] daemon [--system]
*dbus-send* [--system | --session] [--print-reply] --type=method_call --dest="org.asamk.Signal" /org/asamk/Signal[/_<phonenum>] org.asamk.Signal.<method> [string:<string argument>] [array:<type>:<array argument>]
Note: when daemon was started without explicit `-u USERNAME`, the `dbus-send` command requires adding the phone number in `/org/asamk/Signal/_<phonenum>`.
== Description
See signal-cli (1) for details on the application.
This documentation handles the supported methods when running signal-cli as a DBus daemon.
The method are described as follows:
method(arg1<type>, arg2<type>, ...) -> return<type>
Where <type> is according to DBus specification:
* <s> : String
* <ay> : Byte Array
* <aay> : Array of Byte Arrays
* <as> : String Array
* <b> : Boolean (0|1)
* <x> : Signed 64 bit integer
* <> : no return value
Exceptions are the names of the Java Exceptions returned in the body field. They typically contain an additional message with details. All Exceptions begin with "org.asamk.Signal.Error." which is omitted here for better readability.
Phone numbers always have the format +<countrycode><regional number>
== Methods
updateGroup(groupId<ay>, newName<s>, members<as>, avatar<s>) -> groupId<ay>::
* groupId : Byte array representing the internal group identifier
* newName : New name of group (empty if unchanged)
* members : String array of new members to be invited to group
* avatar : Filename of avatar picture to be set for group (empty if none)
Exceptions: AttachmentInvalid, Failure, InvalidNumber, GroupNotFound
updateProfile(newName<s>, about <s>, aboutEmoji <s>, avatar<s>, remove<b>) -> <>::
* newName : New name for your own profile (empty if unchanged)
* about : About message for profile (empty if unchanged)
* aboutEmoji : Emoji for profile (empty if unchanged)
* avatar : Filename of avatar picture for profile (empty if unchanged)
* remove : Set to 1 if the existing avatar picture should be removed
Exceptions: Failure
setContactBlocked(number<s>, block<b>) -> <>::
* number : Phone number affected by method
* block : 0=remove block , 1=blocked
Messages from blocked numbers will no longer be forwarded via DBus.
Exceptions: InvalidNumber
setGroupBlocked(groupId<ay>, block<b>) -> <>::
* groupId : Byte array representing the internal group identifier
* block : 0=remove block , 1=blocked
Messages from blocked groups will no longer be forwarded via DBus.
Exceptions: GroupNotFound
joinGroup(inviteURI<s>) -> <>::
* inviteURI : String starting with https://signal.group which is generated when you share a group link via Signal App
Exceptions: Failure
quitGroup(groupId<ay>) -> <>::
* groupId : Byte array representing the internal group identifier
Note that quitting a group will not remove the group from the getGroupIds command, but set it inactive which can be tested with isMember()
Exceptions: GroupNotFound, Failure
isMember(groupId<ay>) -> active<b>::
* groupId : Byte array representing the internal group identifier
Note that this method does not raise an Exception for a non-existing/unknown group but will simply return 0 (false)
sendEndSessionMessage(recipients<as>) -> <>::
* recipients : Array of phone numbers
Exceptions: Failure, InvalidNumber, UntrustedIdentity
sendGroupMessage(message<s>, attachments<as>, groupId<ay>) -> timestamp<x>::
* message : Text to send (can be UTF8)
* attachments : String array of filenames to send as attachments (passed as filename, so need to be readable by the user signal-cli is running under)
* groupId : Byte array representing the internal group identifier
* timestamp : Can be used to identify the corresponding signal reply
Exceptions: GroupNotFound, Failure, AttachmentInvalid
sendNoteToSelfMessage(message<s>, attachments<as>) -> timestamp<x>::
* message : Text to send (can be UTF8)
* attachments : String array of filenames to send as attachments (passed as filename, so need to be readable by the user signal-cli is running under)
* timestamp : Can be used to identify the corresponding signal reply
Exceptions: Failure, AttachmentInvalid
sendMessage(message<s>, attachments<as>, recipient<s>) -> timestamp<x>::
sendMessage(message<s>, attachments<as>, recipients<as>) -> timestamp<x>::
* message : Text to send (can be UTF8)
* attachments : String array of filenames to send as attachments (passed as filename, so need to be readable by the user signal-cli is running under)
* recipient : Phone number of a single recipient
* recipients : Array of phone numbers
* timestamp : Can be used to identify the corresponding signal reply
Depending on the type of the recipient field this sends a message to one or multiple recipients.
Exceptions: AttachmentInvalid, Failure, InvalidNumber, UntrustedIdentity
sendGroupMessageReaction(emoji<s>, remove<b>, targetAuthor<s>, targetSentTimestamp<x>, groupId<ay>) -> timestamp<x>::
* emoji : Unicode grapheme cluster of the emoji
* remove : Boolean, whether a previously sent reaction (emoji) should be removed
* targetAuthor : String with the phone number of the author of the message to which to react
* targetSentTimestamp : Long representing timestamp of the message to which to react
* groupId : Byte array with base64 encoded group identifier
* timestamp : Long, can be used to identify the corresponding signal reply
Exceptions: Failure, InvalidNumber, GroupNotFound
sendMessageReaction(emoji<s>, remove<b>, targetAuthor<s>, targetSentTimestamp<x>, recipient<s>) -> timestamp<x>::
sendMessageReaction(emoji<s>, remove<b>, targetAuthor<s>, targetSentTimestamp<x>, recipients<as>) -> timestamp<x>::
* emoji : Unicode grapheme cluster of the emoji
* remove : Boolean, whether a previously sent reaction (emoji) should be removed
* targetAuthor : String with the phone number of the author of the message to which to react
* targetSentTimestamp : Long representing timestamp of the message to which to react
* recipient : String with the phone number of a single recipient
* recipients : Array of strings with phone numbers, should there be more recipients
* timestamp : Long, can be used to identify the corresponding signal reply
Depending on the type of the recipient(s) field this sends a reaction to one or multiple recipients.
Exceptions: Failure, InvalidNumber
sendGroupRemoteDeleteMessage(targetSentTimestamp<x>, groupId<ay>) -> timestamp<x>::
* targetSentTimestamp : Long representing timestamp of the message to delete
* groupId : Byte array with base64 encoded group identifier
* timestamp : Long, can be used to identify the corresponding signal reply
Exceptions: Failure, GroupNotFound
sendRemoteDeleteMessage(targetSentTimestamp<x>, recipient<s>) -> timestamp<x>::
sendRemoteDeleteMessage(targetSentTimestamp<x>, recipients<as>) -> timestamp<x>::
* targetSentTimestamp : Long representing timestamp of the message to delete
* recipient : String with the phone number of a single recipient
* recipients : Array of strings with phone numbers, should there be more recipients
* timestamp : Long, can be used to identify the corresponding signal reply
Depending on the type of the recipient(s) field this deletes a message with one or multiple recipients.
Exceptions: Failure, InvalidNumber
getContactName(number<s>) -> name<s>::
* number : Phone number
* name : Contact's name in local storage (from the master device for a linked account, or the one set with setContactName); if not set, contact's profile name is used
setContactName(number<s>,name<>) -> <>::
* number : Phone number
* name : Name to be set in contacts (in local storage with signal-cli)
getGroupIds() -> groupList<aay>::
groupList : Array of Byte arrays representing the internal group identifiers
All groups known are returned, regardless of their active or blocked status. To query that use isMember() and isGroupBlocked()
getGroupName(groupId<ay>) -> groupName<s>::
groupName : The display name of the group
groupId : Byte array representing the internal group identifier
Exceptions: None, if the group name is not found an empty string is returned
getGroupMembers(groupId<ay>) -> members<as>::
members : String array with the phone numbers of all active members of a group
groupId : Byte array representing the internal group identifier
Exceptions: None, if the group name is not found an empty array is returned
listNumbers() -> numbers<as>::
numbers : String array of all known numbers
This is a concatenated list of all defined contacts as well of profiles known (e.g. peer group members or sender of received messages)
getContactNumber(name<s>) -> numbers<as>::
* numbers : Array of phone number
* name : Contact or profile name ("firstname lastname")
Searches contacts and known profiles for a given name and returns the list of all known numbers. May result in e.g. two entries if a contact and profile name is set.
isContactBlocked(number<s>) -> state<b>::
* number : Phone number
* state : 1=blocked, 0=not blocked
Exceptions: None, for unknown numbers 0 (false) is returned
isGroupBlocked(groupId<ay>) -> state<b>::
* groupId : Byte array representing the internal group identifier
* state : 1=blocked, 0=not blocked
Exceptions: None, for unknown groups 0 (false) is returned
version() -> version<s>::
* version : Version string of signal-cli
isRegistred -> result<b>::
* result : Currently always returns 1=true
== Signals
SyncMessageReceived (timestamp<x>, sender<s>, destination<s>, groupId<ay>,message<s>, attachments<as>)::
The sync message is received when the user sends a message from a linked device.
ReceiptReceived (timestamp<x>, sender<s>)::
* timestamp : Integer value that can be used to associate this e.g. with a sendMessage()
* sender : Phone number of the sender
This signal is sent by each recipient (e.g. each group member) after the message was successfully delivered to the device
MessageReceived(timestamp<x>, sender<s>, groupId<ay>, message<s>, attachments<as>)::
* timestamp : Integer value that is used by the system to send a ReceiptReceived reply
* sender : Phone number of the sender
* groupId : Byte array representing the internal group identifier (empty when private message)
* message : Message text
* attachments : String array of filenames for the attachments. These files are located in the signal-cli storage and the current user needs to have read access there
This signal is received whenever we get a private message or a message is posted in a group we are an active member
== Examples
Send a text message (without attachment) to a contact::
dbus-send --print-reply --type=method_call --dest="org.asamk.Signal" /org/asamk/Signal org.asamk.Signal.sendMessage string:"Message text goes here" array:string: string:+123456789
Send a group message::
dbus-send --session --print-reply --type=method_call --dest=org.asamk.Signal /org/asamk/Signal org.asamk.Signal.sendGroupMessage string:'The message goes here' array:string:'/path/to/attachmnt1','/path/to/attachmnt2' array:byte:139,22,72,247,116,32,170,104,205,164,207,21,248,77,185
Print the group name corresponding to a groupId; the daemon runs on system bus, and was started without an explicit `-u USERNAME`::
dbus-send --system --print-reply --type=method_call --dest='org.asamk.Signal' /org/asamk/Signal/_1234567890 org.asamk.Signal.getGroupName array:byte:139,22,72,247,116,32,170,104,205,164,207,21,248,77,185
== Authors
Maintained by AsamK <asamk@gmx.de>, who is assisted by other open source contributors.
For more information about signal-cli development, see
<https://github.com/AsamK/signal-cli>.

View file

@ -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*::
Dont download attachments of received messages.
*--json*::
Output received messages in json format, one object per line.
=== joinGroup
Join a group via an invitation link.
To be able to join a v2 group the account needs to have a profile (can be created
with the `updateProfile` command)
*--uri*::
The invitation link URI (starts with `https://signal.group/#`)
@ -189,6 +231,8 @@ The invitation link URI (starts with `https://signal.group/#`)
Create or update a group.
If the user is a pending member, this command will accept the group invitation.
To be able to join or create a v2 group the account needs to have a profile (can
be created with the `updateProfile` command)
*-g* GROUP, *--group* GROUP::
Specify the recipient group ID in base64 encoding.
@ -213,10 +257,11 @@ Specify the recipient group ID in base64 encoding.
=== listGroups
Show a list of known groups.
Show a list of known groups and related information.
In json mode this is outputted as an list of objects and is always in detailed mode.
*-d*, *--detailed*::
Include the list of members of each group.
Include the list of members of each group and the group invite link.
=== listIdentities
@ -328,6 +373,8 @@ The path of the manifest.json or a zip file containing the sticker pack you wish
=== daemon
signal-cli can run in daemon mode and provides an experimental dbus interface.
If no `-u` username is given, all local users will be exported as separate dbus
objects under the same bus name.
*--system*::
Use DBus system bus instead of user bus.
@ -366,6 +413,12 @@ signal-cli -u USERNAME trust -v SAFETY_NUMBER NUMBER
Trust new key, without having verified it. Only use this if you don't care about security::
signal-cli -u USERNAME trust -a NUMBER
== Exit codes
* *1*: Error is probably caused and fixable by the user
* *2*: Some unexpected error
* *3*: Server or IO error
* *4*: Sending failed due to untrusted key
== Files
The password and cryptographic keys are created when registering and stored in the current users home directory, the directory can be changed with *--config*:

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