diff --git a/app/build.gradle b/app/build.gradle index b55942ef3..41c82319a 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -115,7 +115,7 @@ dependencies { implementation 'org.conscrypt:conscrypt-android:2.0.0' implementation 'org.signal:aesgcmprovider:0.0.3' - implementation 'org.whispersystems:signal-service-android:2.15.3' + implementation project(':libsignal-service') implementation 'org.signal:ringrtc-android:0.2.0' @@ -453,9 +453,3 @@ def getLastCommitTimestamp() { return os.toString() + "000" } } - -task qa { - group 'Verification' - description 'Quality Assurance. Run before pushing.' - dependsOn 'testPlayReleaseUnitTest', 'lintPlayRelease', 'assemblePlayDebug' -} diff --git a/app/witness-verifications.gradle b/app/witness-verifications.gradle index c7ff0fb7c..931d2fb46 100644 --- a/app/witness-verifications.gradle +++ b/app/witness-verifications.gradle @@ -354,33 +354,18 @@ dependencyVerification { ['org.signal:ringrtc-android:0.2.0', 'bce32dfe6c4fe107ccd4b9f155dcfaefe574e7740ef57190614b7e34c914f5f0'], - ['org.signal:signal-metadata-android:0.1.0', - 'e79ca9231ec07b05849bc048c643fe2cec48ee781ba5aa8165847a3c90178684'], - ['org.signal:signal-metadata-java:0.1.0', 'f3faa23b7d9b5096d12979c35679d1e3b5e007522d8bef167a28e456f2a7c7d9'], ['org.threeten:threetenbp:1.3.6', 'f4c23ffaaed717c3b99c003e0ee02d6d66377fd47d866fec7d971bd8644fc1a7'], - ['org.whispersystems:curve25519-android:0.5.0', - 'b502bcf83efe001f09a7a9efda6f0fa772c43ed5924e97816296ed3503caa092'], - ['org.whispersystems:curve25519-java:0.5.0', '0aadd43cf01d11e9b58f867b3c4f25c3194e8b0623d1953d32dfbfbee009e38d'], - ['org.whispersystems:signal-protocol-android:2.8.1', - '9eff33fa6a541334f647906cb6f4a9bb26efa6988c7abdb985f3995a6d10aa48'], - ['org.whispersystems:signal-protocol-java:2.8.1', 'b19db36839ab008fdccefc7f8c005f2ea43dc7c7298a209bc424e6f9b6d5617b'], - ['org.whispersystems:signal-service-android:2.15.3', - '24e7a7760f14a261d44b8d76fe6004157f7e09d7898c88ddb501ceabea47b6f6'], - - ['org.whispersystems:signal-service-java:2.15.3', - 'bc6d58924daf7c15e700164ce602e2647260c41820a43837f3f48f3d5be2e963'], - ['pl.tajchert:waitingdots:0.1.0', '2835d49e0787dbcb606c5a60021ced66578503b1e9fddcd7a5ef0cd5f095ba2c'], diff --git a/buildSrc/src/main/groovy/org/whispersystems/witness/WitnessPlugin.groovy b/buildSrc/src/main/groovy/org/whispersystems/witness/WitnessPlugin.groovy index d75ff8fb8..6b9865020 100644 --- a/buildSrc/src/main/groovy/org/whispersystems/witness/WitnessPlugin.groovy +++ b/buildSrc/src/main/groovy/org/whispersystems/witness/WitnessPlugin.groovy @@ -63,6 +63,7 @@ class WitnessPlugin implements Plugin { stringBuilder.append ' verify = [\n' allArtifacts(project) + .findAll { dep -> !dep.id.componentIdentifier.displayName.startsWith('project :') } .collect { dep -> "['$dep.moduleVersion.id.group:$dep.name:$dep.moduleVersion.id.version',\n '${calculateSha256(dep.file)}']" } .sort() .each { @@ -72,7 +73,7 @@ class WitnessPlugin implements Plugin { stringBuilder.append " ]\n" stringBuilder.append "}\n" - new File("witness-verifications.gradle").write(stringBuilder.toString()) + project.file("witness-verifications.gradle").write(stringBuilder.toString()) } } diff --git a/libsignal/service/.gitignore b/libsignal/service/.gitignore new file mode 100644 index 000000000..796b96d1c --- /dev/null +++ b/libsignal/service/.gitignore @@ -0,0 +1 @@ +/build diff --git a/libsignal/service/build.gradle b/libsignal/service/build.gradle new file mode 100644 index 000000000..434f17e63 --- /dev/null +++ b/libsignal/service/build.gradle @@ -0,0 +1,155 @@ +buildscript { + repositories { + google() + mavenCentral() + } + + dependencies { + classpath 'com.google.protobuf:protobuf-gradle-plugin:0.8.10' + } +} + +apply plugin: 'java-library' +apply plugin: 'com.google.protobuf' +apply plugin: 'maven' +apply plugin: 'signing' +apply plugin: 'witness' +apply from: 'witness-verifications.gradle' + +sourceCompatibility = 1.7 +archivesBaseName = "signal-service-java" +version = lib_signal_service_version_number +group = lib_signal_service_group_info + +repositories { + mavenCentral() + mavenLocal() +} + +dependencies { + implementation 'com.google.protobuf:protobuf-javalite:3.10.0' + api 'com.googlecode.libphonenumber:libphonenumber:8.10.7' + api 'com.fasterxml.jackson.core:jackson-databind:2.9.9.2' + + api "org.signal:signal-metadata-java:${lib_signal_metadata_version}" + api 'com.squareup.okhttp3:okhttp:3.12.1' + implementation 'org.threeten:threetenbp:1.3.6' + + testImplementation 'junit:junit:3.8.2' + testImplementation 'org.assertj:assertj-core:1.7.1' + testImplementation 'org.conscrypt:conscrypt-openjdk-uber:2.0.0' +} + +dependencyVerification { + configuration = '(runtime|compile)Classpath' +} + +tasks.whenTaskAdded { task -> + if (task.name.equals("lint")) { + task.enabled = false + } +} + +protobuf { + protoc { + artifact = 'com.google.protobuf:protoc:3.10.0' + } + generateProtoTasks { + all().each { task -> + task.builtins { + java { + option "lite" + } + } + } + } +} + +sourceSets { + main.proto.srcDir 'protobuf' +} + +def isReleaseBuild() { + return version.contains("SNAPSHOT") == false +} + +def getReleaseRepositoryUrl() { + return hasProperty('sonatypeRepo') ? sonatypeRepo + : "https://oss.sonatype.org/service/local/staging/deploy/maven2/" +} + +def getRepositoryUsername() { + return hasProperty('whisperSonatypeUsername') ? whisperSonatypeUsername : "" +} + +def getRepositoryPassword() { + return hasProperty('whisperSonatypePassword') ? whisperSonatypePassword : "" +} + +signing { + required { isReleaseBuild() && gradle.taskGraph.hasTask("uploadArchives") } + sign configurations.archives +} + +uploadArchives { + configuration = configurations.archives + repositories.mavenDeployer { + beforeDeployment { MavenDeployment deployment -> signing.signPom(deployment) } + + repository(url: getReleaseRepositoryUrl()) { + authentication(userName: getRepositoryUsername(), password: getRepositoryPassword()) + } + + pom.project { + name 'signal-service-java' + packaging 'jar' + description 'Signal Service communication library for Java' + url 'https://github.com/WhisperSystems/libsignal-service-java' + + scm { + url 'scm:git@github.com:WhisperSystems/libsignal-service-java.git' + connection 'scm:git@github.com:WhisperSystems/libsignal-service-java.git' + developerConnection 'scm:git@github.com:WhisperSystems/libsignal-service-java.git' + } + + licenses { + license { + name 'GPLv3' + url 'https://www.gnu.org/licenses/gpl-3.0.txt' + distribution 'repo' + } + } + + developers { + developer { + name 'Moxie Marlinspike' + } + } + } + } +} + +task installArchives(type: Upload) { + description "Installs the artifacts to the local Maven repository." + configuration = configurations['archives'] + repositories { + mavenDeployer { + repository url: "file://${System.properties['user.home']}/.m2/repository" + } + } +} + +task packageJavadoc(type: Jar, dependsOn: 'javadoc') { + from javadoc.destinationDir + classifier = 'javadoc' +} + +task packageSources(type: Jar) { + from sourceSets.main.allSource + classifier = 'sources' +} + +artifacts { + archives packageJavadoc + archives packageSources +} diff --git a/libsignal/service/protobuf/Provisioning.proto b/libsignal/service/protobuf/Provisioning.proto new file mode 100644 index 000000000..1f1e452d9 --- /dev/null +++ b/libsignal/service/protobuf/Provisioning.proto @@ -0,0 +1,36 @@ +/** + * Copyright (C) 2014-2016 Open Whisper Systems + * + * Licensed according to the LICENSE file in this repository. + */ +syntax = "proto2"; + +package signalservice; + +option java_package = "org.whispersystems.signalservice.internal.push"; +option java_outer_classname = "ProvisioningProtos"; + +message ProvisionEnvelope { + optional bytes publicKey = 1; + optional bytes body = 2; // Encrypted ProvisionMessage +} + +message ProvisionMessage { + optional bytes identityKeyPublic = 1; + optional bytes identityKeyPrivate = 2; + optional string number = 3; + optional string uuid = 8; + optional string provisioningCode = 4; + optional string userAgent = 5; + optional bytes profileKey = 6; + optional bool readReceipts = 7; + optional uint32 provisioningVersion = 9; +} + +enum ProvisioningVersion { + option allow_alias = true; + + INITIAL = 0; + TABLET_SUPPORT = 1; + CURRENT = 1; +} diff --git a/libsignal/service/protobuf/SignalService.proto b/libsignal/service/protobuf/SignalService.proto new file mode 100644 index 000000000..9e34b9ba9 --- /dev/null +++ b/libsignal/service/protobuf/SignalService.proto @@ -0,0 +1,422 @@ +/** + * Copyright (C) 2014-2016 Open Whisper Systems + * + * Licensed according to the LICENSE file in this repository. + */ +syntax = "proto2"; + +package signalservice; + +option java_package = "org.whispersystems.signalservice.internal.push"; +option java_outer_classname = "SignalServiceProtos"; + +message Envelope { + enum Type { + UNKNOWN = 0; + CIPHERTEXT = 1; + KEY_EXCHANGE = 2; + PREKEY_BUNDLE = 3; + RECEIPT = 5; + UNIDENTIFIED_SENDER = 6; + } + + optional Type type = 1; + optional string sourceE164 = 2; + optional string sourceUuid = 11; + optional uint32 sourceDevice = 7; + optional string relay = 3; + optional uint64 timestamp = 5; + optional bytes legacyMessage = 6; // Contains an encrypted DataMessage + optional bytes content = 8; // Contains an encrypted Content + optional string serverGuid = 9; + optional uint64 serverTimestamp = 10; +} + +message Content { + optional DataMessage dataMessage = 1; + optional SyncMessage syncMessage = 2; + optional CallMessage callMessage = 3; + optional NullMessage nullMessage = 4; + optional ReceiptMessage receiptMessage = 5; + optional TypingMessage typingMessage = 6; +} + +message CallMessage { + message Offer { + optional uint64 id = 1; + optional string description = 2; + } + + message Answer { + optional uint64 id = 1; + optional string description = 2; + } + + message IceUpdate { + optional uint64 id = 1; + optional string sdpMid = 2; + optional uint32 sdpMLineIndex = 3; + optional string sdp = 4; + } + + message Busy { + optional uint64 id = 1; + } + + message Hangup { + optional uint64 id = 1; + } + + + optional Offer offer = 1; + optional Answer answer = 2; + repeated IceUpdate iceUpdate = 3; + optional Hangup hangup = 4; + optional Busy busy = 5; +} + +message DataMessage { + enum Flags { + END_SESSION = 1; + EXPIRATION_TIMER_UPDATE = 2; + PROFILE_KEY_UPDATE = 4; + } + + message Quote { + message QuotedAttachment { + optional string contentType = 1; + optional string fileName = 2; + optional AttachmentPointer thumbnail = 3; + } + + optional uint64 id = 1; + optional string authorE164 = 2; + optional string authorUuid = 5; + optional string text = 3; + repeated QuotedAttachment attachments = 4; + } + + message Contact { + message Name { + optional string givenName = 1; + optional string familyName = 2; + optional string prefix = 3; + optional string suffix = 4; + optional string middleName = 5; + optional string displayName = 6; + } + + message Phone { + enum Type { + HOME = 1; + MOBILE = 2; + WORK = 3; + CUSTOM = 4; + } + + optional string value = 1; + optional Type type = 2; + optional string label = 3; + } + + message Email { + enum Type { + HOME = 1; + MOBILE = 2; + WORK = 3; + CUSTOM = 4; + } + + optional string value = 1; + optional Type type = 2; + optional string label = 3; + } + + message PostalAddress { + enum Type { + HOME = 1; + WORK = 2; + CUSTOM = 3; + } + + optional Type type = 1; + optional string label = 2; + optional string street = 3; + optional string pobox = 4; + optional string neighborhood = 5; + optional string city = 6; + optional string region = 7; + optional string postcode = 8; + optional string country = 9; + } + + message Avatar { + optional AttachmentPointer avatar = 1; + optional bool isProfile = 2; + } + + optional Name name = 1; + repeated Phone number = 3; + repeated Email email = 4; + repeated PostalAddress address = 5; + optional Avatar avatar = 6; + optional string organization = 7; + } + + message Preview { + optional string url = 1; + optional string title = 2; + optional AttachmentPointer image = 3; + } + + message Sticker { + optional bytes packId = 1; + optional bytes packKey = 2; + optional uint32 stickerId = 3; + optional AttachmentPointer data = 4; + } + + enum ProtocolVersion { + option allow_alias = true; + + INITIAL = 0; + MESSAGE_TIMERS = 1; + VIEW_ONCE = 2; + VIEW_ONCE_VIDEO = 3; + CURRENT = 3; + } + + optional string body = 1; + repeated AttachmentPointer attachments = 2; + optional GroupContext group = 3; + optional uint32 flags = 4; + optional uint32 expireTimer = 5; + optional bytes profileKey = 6; + optional uint64 timestamp = 7; + optional Quote quote = 8; + repeated Contact contact = 9; + repeated Preview preview = 10; + optional Sticker sticker = 11; + optional uint32 requiredProtocolVersion = 12; + optional bool isViewOnce = 14; +} + +message NullMessage { + optional bytes padding = 1; +} + +message ReceiptMessage { + enum Type { + DELIVERY = 0; + READ = 1; + } + + optional Type type = 1; + repeated uint64 timestamp = 2; +} + +message TypingMessage { + enum Action { + STARTED = 0; + STOPPED = 1; + } + + optional uint64 timestamp = 1; + optional Action action = 2; + optional bytes groupId = 3; +} + +message Verified { + enum State { + DEFAULT = 0; + VERIFIED = 1; + UNVERIFIED = 2; + } + + optional string destinationE164 = 1; + optional string destinationUuid = 5; + optional bytes identityKey = 2; + optional State state = 3; + optional bytes nullMessage = 4; +} + +message SyncMessage { + message Sent { + message UnidentifiedDeliveryStatus { + optional string destinationE164 = 1; + optional string destinationUuid = 3; + optional bool unidentified = 2; + } + + optional string destinationE164 = 1; + optional string destinationUuid = 7; + optional uint64 timestamp = 2; + optional DataMessage message = 3; + optional uint64 expirationStartTimestamp = 4; + repeated UnidentifiedDeliveryStatus unidentifiedStatus = 5; + optional bool isRecipientUpdate = 6 [default = false]; + } + + message Contacts { + optional AttachmentPointer blob = 1; + optional bool complete = 2 [default = false]; + } + + message Groups { + optional AttachmentPointer blob = 1; + } + + message Blocked { + repeated string numbers = 1; + repeated string uuids = 3; + repeated bytes groupIds = 2; + } + + message Request { + enum Type { + UNKNOWN = 0; + CONTACTS = 1; + GROUPS = 2; + BLOCKED = 3; + CONFIGURATION = 4; + } + + optional Type type = 1; + } + + message Read { + optional string senderE164 = 1; + optional string senderUuid = 3; + optional uint64 timestamp = 2; + } + + message Configuration { + optional bool readReceipts = 1; + optional bool unidentifiedDeliveryIndicators = 2; + optional bool typingIndicators = 3; + optional bool linkPreviews = 4; + optional uint32 provisioningVersion = 5; + } + + message StickerPackOperation { + enum Type { + INSTALL = 0; + REMOVE = 1; + } + + optional bytes packId = 1; + optional bytes packKey = 2; + optional Type type = 3; + } + + message ViewOnceOpen { + optional string senderE164 = 1; + optional string senderUuid = 3; + optional uint64 timestamp = 2; + } + + + message FetchLatest { + enum Type { + UNKNOWN = 0; + LOCAL_PROFILE = 1; + STORAGE_MANIFEST = 2; + } + + optional Type type = 1; + } + + + optional Sent sent = 1; + optional Contacts contacts = 2; + optional Groups groups = 3; + optional Request request = 4; + repeated Read read = 5; + optional Blocked blocked = 6; + optional Verified verified = 7; + optional Configuration configuration = 9; + optional bytes padding = 8; + repeated StickerPackOperation stickerPackOperation = 10; + optional ViewOnceOpen viewOnceOpen = 11; + optional FetchLatest fetchLatest = 12; +} + +message AttachmentPointer { + enum Flags { + VOICE_MESSAGE = 1; + } + + optional fixed64 id = 1; + optional string contentType = 2; + optional bytes key = 3; + optional uint32 size = 4; + optional bytes thumbnail = 5; + optional bytes digest = 6; + optional string fileName = 7; + optional uint32 flags = 8; + optional uint32 width = 9; + optional uint32 height = 10; + optional string caption = 11; + optional string blurHash = 12; +} + +message GroupContext { + enum Type { + UNKNOWN = 0; + UPDATE = 1; + DELIVER = 2; + QUIT = 3; + REQUEST_INFO = 4; + } + + message Member { + optional string uuid = 1; + optional string e164 = 2; + } + + optional bytes id = 1; + optional Type type = 2; + optional string name = 3; + repeated string membersE164 = 4; + repeated Member members = 6; + optional AttachmentPointer avatar = 5; +} + +message ContactDetails { + message Avatar { + optional string contentType = 1; + optional uint32 length = 2; + } + + optional string number = 1; + optional string uuid = 9; + optional string name = 2; + optional Avatar avatar = 3; + optional string color = 4; + optional Verified verified = 5; + optional bytes profileKey = 6; + optional bool blocked = 7; + optional uint32 expireTimer = 8; +} + +message GroupDetails { + message Avatar { + optional string contentType = 1; + optional uint32 length = 2; + } + + message Member { + optional string uuid = 1; + optional string e164 = 2; + } + + optional bytes id = 1; + optional string name = 2; + repeated string membersE164 = 3; + repeated Member members = 9; + optional Avatar avatar = 4; + optional bool active = 5 [default = true]; + optional uint32 expireTimer = 6; + optional string color = 7; + optional bool blocked = 8; +} diff --git a/libsignal/service/protobuf/StickerResources.proto b/libsignal/service/protobuf/StickerResources.proto new file mode 100644 index 000000000..409360883 --- /dev/null +++ b/libsignal/service/protobuf/StickerResources.proto @@ -0,0 +1,24 @@ +/** + * Copyright (C) 2019 Open Whisper Systems + * + * Licensed according to the LICENSE file in this repository. + */ +syntax = "proto2"; + +package signalservice; + +option java_package = "org.whispersystems.signalservice.internal.sticker"; +option java_outer_classname = "StickerProtos"; + +message Pack { + message Sticker { + optional uint32 id = 1; + optional string emoji = 2; + } + + optional string title = 1; + optional string author = 2; + optional Sticker cover = 3; + repeated Sticker stickers = 4; +} + diff --git a/libsignal/service/protobuf/WebSocketResources.proto b/libsignal/service/protobuf/WebSocketResources.proto new file mode 100644 index 000000000..46ea45326 --- /dev/null +++ b/libsignal/service/protobuf/WebSocketResources.proto @@ -0,0 +1,39 @@ +/** + * Copyright (C) 2014-2016 Open Whisper Systems + * + * Licensed according to the LICENSE file in this repository. + */ +syntax = "proto2"; + +package signalservice; + +option java_package = "org.whispersystems.signalservice.internal.websocket"; +option java_outer_classname = "WebSocketProtos"; + +message WebSocketRequestMessage { + optional string verb = 1; + optional string path = 2; + optional bytes body = 3; + repeated string headers = 5; + optional uint64 id = 4; +} + +message WebSocketResponseMessage { + optional uint64 id = 1; + optional uint32 status = 2; + optional string message = 3; + repeated string headers = 5; + optional bytes body = 4; +} + +message WebSocketMessage { + enum Type { + UNKNOWN = 0; + REQUEST = 1; + RESPONSE = 2; + } + + optional Type type = 1; + optional WebSocketRequestMessage request = 2; + optional WebSocketResponseMessage response = 3; +} \ No newline at end of file diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceAccountManager.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceAccountManager.java new file mode 100644 index 000000000..735556461 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceAccountManager.java @@ -0,0 +1,487 @@ +/** + * Copyright (C) 2014-2016 Open Whisper Systems + * + * Licensed according to the LICENSE file in this repository. + */ + +package org.whispersystems.signalservice.api; + + +import com.google.protobuf.ByteString; + +import org.whispersystems.curve25519.Curve25519; +import org.whispersystems.curve25519.Curve25519KeyPair; +import org.whispersystems.libsignal.IdentityKey; +import org.whispersystems.libsignal.IdentityKeyPair; +import org.whispersystems.libsignal.InvalidKeyException; +import org.whispersystems.libsignal.ecc.ECPublicKey; +import org.whispersystems.libsignal.logging.Log; +import org.whispersystems.libsignal.state.PreKeyRecord; +import org.whispersystems.libsignal.state.SignedPreKeyRecord; +import org.whispersystems.libsignal.util.Pair; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.crypto.InvalidCiphertextException; +import org.whispersystems.signalservice.api.crypto.ProfileCipher; +import org.whispersystems.signalservice.api.crypto.ProfileCipherOutputStream; +import org.whispersystems.signalservice.api.messages.calls.TurnServerInfo; +import org.whispersystems.signalservice.api.messages.multidevice.DeviceInfo; +import org.whispersystems.signalservice.api.push.ContactTokenDetails; +import org.whispersystems.signalservice.api.push.SignedPreKeyEntity; +import org.whispersystems.signalservice.api.util.CredentialsProvider; +import org.whispersystems.signalservice.api.util.StreamDetails; +import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration; +import org.whispersystems.signalservice.internal.contacts.crypto.ContactDiscoveryCipher; +import org.whispersystems.signalservice.internal.contacts.crypto.Quote; +import org.whispersystems.signalservice.internal.contacts.crypto.RemoteAttestation; +import org.whispersystems.signalservice.internal.contacts.crypto.RemoteAttestationKeys; +import org.whispersystems.signalservice.internal.contacts.crypto.UnauthenticatedQuoteException; +import org.whispersystems.signalservice.internal.contacts.crypto.UnauthenticatedResponseException; +import org.whispersystems.signalservice.internal.contacts.entities.DiscoveryRequest; +import org.whispersystems.signalservice.internal.contacts.entities.DiscoveryResponse; +import org.whispersystems.signalservice.internal.contacts.entities.RemoteAttestationRequest; +import org.whispersystems.signalservice.internal.contacts.entities.RemoteAttestationResponse; +import org.whispersystems.signalservice.internal.crypto.ProvisioningCipher; +import org.whispersystems.signalservice.internal.push.ProfileAvatarData; +import org.whispersystems.signalservice.internal.push.PushServiceSocket; +import org.whispersystems.signalservice.internal.push.http.ProfileCipherOutputStreamFactory; +import org.whispersystems.signalservice.internal.util.StaticCredentialsProvider; +import org.whispersystems.signalservice.internal.util.Util; +import org.whispersystems.util.Base64; + +import java.io.ByteArrayInputStream; +import java.io.DataInputStream; +import java.io.IOException; +import java.security.KeyStore; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SignatureException; +import java.util.Collection; +import java.util.HashMap; +import java.util.Iterator; +import java.util.LinkedList; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +import static org.whispersystems.signalservice.internal.push.ProvisioningProtos.ProvisionMessage; +import static org.whispersystems.signalservice.internal.push.ProvisioningProtos.ProvisioningVersion; + +/** + * The main interface for creating, registering, and + * managing a Signal Service account. + * + * @author Moxie Marlinspike + */ +public class SignalServiceAccountManager { + + private static final String TAG = SignalServiceAccountManager.class.getSimpleName(); + + private final PushServiceSocket pushServiceSocket; + private final UUID userUuid; + private final String userE164; + private final String userAgent; + + /** + * Construct a SignalServiceAccountManager. + * + * @param configuration The URL for the Signal Service. + * @param uuid The Signal Service UUID. + * @param e164 The Signal Service phone number. + * @param password A Signal Service password. + * @param userAgent A string which identifies the client software. + */ + public SignalServiceAccountManager(SignalServiceConfiguration configuration, + UUID uuid, String e164, String password, + String userAgent) + { + this(configuration, new StaticCredentialsProvider(uuid, e164, password, null), userAgent); + } + + public SignalServiceAccountManager(SignalServiceConfiguration configuration, + CredentialsProvider credentialsProvider, + String userAgent) + { + this.pushServiceSocket = new PushServiceSocket(configuration, credentialsProvider, userAgent); + this.userUuid = credentialsProvider.getUuid(); + this.userE164 = credentialsProvider.getE164(); + this.userAgent = userAgent; + } + + public byte[] getSenderCertificate() throws IOException { + return this.pushServiceSocket.getSenderCertificate(); + } + + public byte[] getSenderCertificateLegacy() throws IOException { + return this.pushServiceSocket.getSenderCertificateLegacy(); + } + + public void setPin(Optional pin) throws IOException { + if (pin.isPresent()) { + this.pushServiceSocket.setPin(pin.get()); + } else { + this.pushServiceSocket.removePin(); + } + } + + public UUID getOwnUuid() throws IOException { + return this.pushServiceSocket.getOwnUuid(); + } + + /** + * Register/Unregister a Google Cloud Messaging registration ID. + * + * @param gcmRegistrationId The GCM id to register. A call with an absent value will unregister. + * @throws IOException + */ + public void setGcmId(Optional gcmRegistrationId) throws IOException { + if (gcmRegistrationId.isPresent()) { + this.pushServiceSocket.registerGcmId(gcmRegistrationId.get()); + } else { + this.pushServiceSocket.unregisterGcmId(); + } + } + + /** + * Request a push challenge. A number will be pushed to the GCM (FCM) id. This can then be used + * during SMS/call requests to bypass the CAPTCHA. + * + * @param gcmRegistrationId The GCM (FCM) id to use. + * @param e164number The number to associate it with. + * @throws IOException + */ + public void requestPushChallenge(String gcmRegistrationId, String e164number) throws IOException { + this.pushServiceSocket.requestPushChallenge(gcmRegistrationId, e164number); + } + + /** + * Request an SMS verification code. On success, the server will send + * an SMS verification code to this Signal user. + * + * @param androidSmsRetrieverSupported + * @param captchaToken If the user has done a CAPTCHA, include this. + * @param challenge If present, it can bypass the CAPTCHA. + * @throws IOException + */ + public void requestSmsVerificationCode(boolean androidSmsRetrieverSupported, Optional captchaToken, Optional challenge) throws IOException { + this.pushServiceSocket.requestSmsVerificationCode(androidSmsRetrieverSupported, captchaToken, challenge); + } + + /** + * Request a Voice verification code. On success, the server will + * make a voice call to this Signal user. + * + * @param locale + * @param captchaToken If the user has done a CAPTCHA, include this. + * @param challenge If present, it can bypass the CAPTCHA. + * @throws IOException + */ + public void requestVoiceVerificationCode(Locale locale, Optional captchaToken, Optional challenge) throws IOException { + this.pushServiceSocket.requestVoiceVerificationCode(locale, captchaToken, challenge); + } + + /** + * Verify a Signal Service account with a received SMS or voice verification code. + * + * @param verificationCode The verification code received via SMS or Voice + * (see {@link #requestSmsVerificationCode} and + * {@link #requestVoiceVerificationCode}). + * @param signalingKey 52 random bytes. A 32 byte AES key and a 20 byte Hmac256 key, + * concatenated. + * @param signalProtocolRegistrationId A random 14-bit number that identifies this Signal install. + * This value should remain consistent across registrations for the + * same install, but probabilistically differ across registrations + * for separate installs. + * @return The UUID of the user that was registered. + * @throws IOException + */ + public UUID verifyAccountWithCode(String verificationCode, String signalingKey, int signalProtocolRegistrationId, boolean fetchesMessages, String pin, + byte[] unidentifiedAccessKey, boolean unrestrictedUnidentifiedAccess) + throws IOException + { + return this.pushServiceSocket.verifyAccountCode(verificationCode, signalingKey, + signalProtocolRegistrationId, + fetchesMessages, pin, + unidentifiedAccessKey, + unrestrictedUnidentifiedAccess); + } + + /** + * Refresh account attributes with server. + * + * @param signalingKey 52 random bytes. A 32 byte AES key and a 20 byte Hmac256 key, concatenated. + * @param signalProtocolRegistrationId A random 14-bit number that identifies this Signal install. + * This value should remain consistent across registrations for the same + * install, but probabilistically differ across registrations for + * separate installs. + * + * @throws IOException + */ + public void setAccountAttributes(String signalingKey, int signalProtocolRegistrationId, boolean fetchesMessages, String pin, + byte[] unidentifiedAccessKey, boolean unrestrictedUnidentifiedAccess) + throws IOException + { + this.pushServiceSocket.setAccountAttributes(signalingKey, signalProtocolRegistrationId, fetchesMessages, pin, + unidentifiedAccessKey, unrestrictedUnidentifiedAccess); + } + + /** + * Register an identity key, signed prekey, and list of one time prekeys + * with the server. + * + * @param identityKey The client's long-term identity keypair. + * @param signedPreKey The client's signed prekey. + * @param oneTimePreKeys The client's list of one-time prekeys. + * + * @throws IOException + */ + public void setPreKeys(IdentityKey identityKey, SignedPreKeyRecord signedPreKey, List oneTimePreKeys) + throws IOException + { + this.pushServiceSocket.registerPreKeys(identityKey, signedPreKey, oneTimePreKeys); + } + + /** + * @return The server's count of currently available (eg. unused) prekeys for this user. + * @throws IOException + */ + public int getPreKeysCount() throws IOException { + return this.pushServiceSocket.getAvailablePreKeys(); + } + + /** + * Set the client's signed prekey. + * + * @param signedPreKey The client's new signed prekey. + * @throws IOException + */ + public void setSignedPreKey(SignedPreKeyRecord signedPreKey) throws IOException { + this.pushServiceSocket.setCurrentSignedPreKey(signedPreKey); + } + + /** + * @return The server's view of the client's current signed prekey. + * @throws IOException + */ + public SignedPreKeyEntity getSignedPreKey() throws IOException { + return this.pushServiceSocket.getCurrentSignedPreKey(); + } + + /** + * Checks whether a contact is currently registered with the server. + * + * @param e164number The contact to check. + * @return An optional ContactTokenDetails, present if registered, absent if not. + * @throws IOException + */ + public Optional getContact(String e164number) throws IOException { + String contactToken = createDirectoryServerToken(e164number, true); + ContactTokenDetails contactTokenDetails = this.pushServiceSocket.getContactTokenDetails(contactToken); + + if (contactTokenDetails != null) { + contactTokenDetails.setNumber(e164number); + } + + return Optional.fromNullable(contactTokenDetails); + } + + /** + * Checks which contacts in a set are registered with the server. + * + * @param e164numbers The contacts to check. + * @return A list of ContactTokenDetails for the registered users. + * @throws IOException + */ + public List getContacts(Set e164numbers) + throws IOException + { + Map contactTokensMap = createDirectoryServerTokenMap(e164numbers); + List activeTokens = this.pushServiceSocket.retrieveDirectory(contactTokensMap.keySet()); + + for (ContactTokenDetails activeToken : activeTokens) { + activeToken.setNumber(contactTokensMap.get(activeToken.getToken())); + } + + return activeTokens; + } + + public List getRegisteredUsers(KeyStore iasKeyStore, Set e164numbers, String mrenclave) + throws IOException, Quote.InvalidQuoteFormatException, UnauthenticatedQuoteException, SignatureException, UnauthenticatedResponseException + { + try { + String authorization = this.pushServiceSocket.getContactDiscoveryAuthorization(); + Curve25519 curve = Curve25519.getInstance(Curve25519.BEST); + Curve25519KeyPair keyPair = curve.generateKeyPair(); + + ContactDiscoveryCipher cipher = new ContactDiscoveryCipher(); + RemoteAttestationRequest attestationRequest = new RemoteAttestationRequest(keyPair.getPublicKey()); + Pair> attestationResponse = this.pushServiceSocket.getContactDiscoveryRemoteAttestation(authorization, attestationRequest, mrenclave); + + RemoteAttestationKeys keys = new RemoteAttestationKeys(keyPair, attestationResponse.first().getServerEphemeralPublic(), attestationResponse.first().getServerStaticPublic()); + Quote quote = new Quote(attestationResponse.first().getQuote()); + byte[] requestId = cipher.getRequestId(keys, attestationResponse.first()); + + cipher.verifyServerQuote(quote, attestationResponse.first().getServerStaticPublic(), mrenclave); + cipher.verifyIasSignature(iasKeyStore, attestationResponse.first().getCertificates(), attestationResponse.first().getSignatureBody(), attestationResponse.first().getSignature(), quote); + + RemoteAttestation remoteAttestation = new RemoteAttestation(requestId, keys); + List addressBook = new LinkedList<>(); + + for (String e164number : e164numbers) { + addressBook.add(e164number.substring(1)); + } + + DiscoveryRequest request = cipher.createDiscoveryRequest(addressBook, remoteAttestation); + DiscoveryResponse response = this.pushServiceSocket.getContactDiscoveryRegisteredUsers(authorization, request, attestationResponse.second(), mrenclave); + byte[] data = cipher.getDiscoveryResponseData(response, remoteAttestation); + + Iterator addressBookIterator = addressBook.iterator(); + List results = new LinkedList<>(); + + for (byte aData : data) { + String candidate = addressBookIterator.next(); + + if (aData != 0) results.add('+' + candidate); + } + + return results; + } catch (InvalidCiphertextException e) { + throw new UnauthenticatedResponseException(e); + } + } + + public void reportContactDiscoveryServiceMatch() { + try { + this.pushServiceSocket.reportContactDiscoveryServiceMatch(); + } catch (IOException e) { + Log.w(TAG, "Request to indicate a contact discovery result match failed. Ignoring.", e); + } + } + + public void reportContactDiscoveryServiceMismatch() { + try { + this.pushServiceSocket.reportContactDiscoveryServiceMismatch(); + } catch (IOException e) { + Log.w(TAG, "Request to indicate a contact discovery result mismatch failed. Ignoring.", e); + } + } + + public void reportContactDiscoveryServiceAttestationError(String reason) { + try { + this.pushServiceSocket.reportContactDiscoveryServiceAttestationError(reason); + } catch (IOException e) { + Log.w(TAG, "Request to indicate a contact discovery attestation error failed. Ignoring.", e); + } + } + + public void reportContactDiscoveryServiceUnexpectedError(String reason) { + try { + this.pushServiceSocket.reportContactDiscoveryServiceUnexpectedError(reason); + } catch (IOException e) { + Log.w(TAG, "Request to indicate a contact discovery unexpected error failed. Ignoring.", e); + } + } + + public String getNewDeviceVerificationCode() throws IOException { + return this.pushServiceSocket.getNewDeviceVerificationCode(); + } + + public void addDevice(String deviceIdentifier, + ECPublicKey deviceKey, + IdentityKeyPair identityKeyPair, + Optional profileKey, + String code) + throws InvalidKeyException, IOException + { + ProvisioningCipher cipher = new ProvisioningCipher(deviceKey); + ProvisionMessage.Builder message = ProvisionMessage.newBuilder() + .setIdentityKeyPublic(ByteString.copyFrom(identityKeyPair.getPublicKey().serialize())) + .setIdentityKeyPrivate(ByteString.copyFrom(identityKeyPair.getPrivateKey().serialize())) + .setProvisioningCode(code) + .setProvisioningVersion(ProvisioningVersion.CURRENT_VALUE); + if (userE164 != null) { + message.setNumber(userE164); + } + + if (userUuid != null) { + message.setUuid(userUuid.toString()); + } + + if (profileKey.isPresent()) { + message.setProfileKey(ByteString.copyFrom(profileKey.get())); + } + + byte[] ciphertext = cipher.encrypt(message.build()); + this.pushServiceSocket.sendProvisioningMessage(deviceIdentifier, ciphertext); + } + + public List getDevices() throws IOException { + return this.pushServiceSocket.getDevices(); + } + + public void removeDevice(long deviceId) throws IOException { + this.pushServiceSocket.removeDevice(deviceId); + } + + public TurnServerInfo getTurnServerInfo() throws IOException { + return this.pushServiceSocket.getTurnServerInfo(); + } + + public void setProfileName(byte[] key, String name) + throws IOException + { + if (name == null) name = ""; + + String ciphertextName = Base64.encodeBytesWithoutPadding(new ProfileCipher(key).encryptName(name.getBytes("UTF-8"), ProfileCipher.NAME_PADDED_LENGTH)); + + this.pushServiceSocket.setProfileName(ciphertextName); + } + + public void setProfileAvatar(byte[] key, StreamDetails avatar) + throws IOException + { + ProfileAvatarData profileAvatarData = null; + + if (avatar != null) { + profileAvatarData = new ProfileAvatarData(avatar.getStream(), + ProfileCipherOutputStream.getCiphertextLength(avatar.getLength()), + avatar.getContentType(), + new ProfileCipherOutputStreamFactory(key)); + } + + this.pushServiceSocket.setProfileAvatar(profileAvatarData); + } + + public void setSoTimeoutMillis(long soTimeoutMillis) { + this.pushServiceSocket.setSoTimeoutMillis(soTimeoutMillis); + } + + public void cancelInFlightRequests() { + this.pushServiceSocket.cancelInFlightRequests(); + } + + private String createDirectoryServerToken(String e164number, boolean urlSafe) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA1"); + byte[] token = Util.trim(digest.digest(e164number.getBytes()), 10); + String encoded = Base64.encodeBytesWithoutPadding(token); + + if (urlSafe) return encoded.replace('+', '-').replace('/', '_'); + else return encoded; + } catch (NoSuchAlgorithmException e) { + throw new AssertionError(e); + } + } + + private Map createDirectoryServerTokenMap(Collection e164numbers) { + Map tokenMap = new HashMap<>(e164numbers.size()); + + for (String number : e164numbers) { + tokenMap.put(createDirectoryServerToken(number, false), number); + } + + return tokenMap; + } + +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessagePipe.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessagePipe.java new file mode 100644 index 000000000..2ad17f539 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessagePipe.java @@ -0,0 +1,263 @@ +/* + * Copyright (C) 2014-2016 Open Whisper Systems + * + * Licensed according to the LICENSE file in this repository. + */ + +package org.whispersystems.signalservice.api; + +import com.google.protobuf.ByteString; + +import org.whispersystems.libsignal.InvalidVersionException; +import org.whispersystems.libsignal.util.Pair; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.crypto.UnidentifiedAccess; +import org.whispersystems.signalservice.api.messages.SignalServiceEnvelope; +import org.whispersystems.signalservice.api.profiles.SignalServiceProfile; +import org.whispersystems.signalservice.api.push.SignalServiceAddress; +import org.whispersystems.signalservice.api.util.CredentialsProvider; +import org.whispersystems.signalservice.internal.push.AttachmentUploadAttributes; +import org.whispersystems.signalservice.internal.push.OutgoingPushMessageList; +import org.whispersystems.signalservice.internal.push.SendMessageResponse; +import org.whispersystems.signalservice.internal.util.JsonUtil; +import org.whispersystems.signalservice.internal.util.Util; +import org.whispersystems.signalservice.internal.websocket.WebSocketConnection; +import org.whispersystems.util.Base64; + +import java.io.IOException; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.util.LinkedList; +import java.util.List; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +import static org.whispersystems.signalservice.internal.websocket.WebSocketProtos.WebSocketRequestMessage; +import static org.whispersystems.signalservice.internal.websocket.WebSocketProtos.WebSocketResponseMessage; + +/** + * A SignalServiceMessagePipe represents a dedicated connection + * to the Signal Service, which the server can push messages + * down through. + */ +public class SignalServiceMessagePipe { + + private static final String TAG = SignalServiceMessagePipe.class.getName(); + + private final WebSocketConnection websocket; + private final Optional credentialsProvider; + + SignalServiceMessagePipe(WebSocketConnection websocket, Optional credentialsProvider) { + this.websocket = websocket; + this.credentialsProvider = credentialsProvider; + + this.websocket.connect(); + } + + /** + * A blocking call that reads a message off the pipe. When this + * call returns, the message has been acknowledged and will not + * be retransmitted. + * + * @param timeout The timeout to wait for. + * @param unit The timeout time unit. + * @return A new message. + * + * @throws InvalidVersionException + * @throws IOException + * @throws TimeoutException + */ + public SignalServiceEnvelope read(long timeout, TimeUnit unit) + throws InvalidVersionException, IOException, TimeoutException + { + return read(timeout, unit, new NullMessagePipeCallback()); + } + + /** + * A blocking call that reads a message off the pipe (see {@link #read(long, java.util.concurrent.TimeUnit)} + * + * Unlike {@link #read(long, java.util.concurrent.TimeUnit)}, this method allows you + * to specify a callback that will be called before the received message is acknowledged. + * This allows you to write the received message to durable storage before acknowledging + * receipt of it to the server. + * + * @param timeout The timeout to wait for. + * @param unit The timeout time unit. + * @param callback A callback that will be called before the message receipt is + * acknowledged to the server. + * @return The message read (same as the message sent through the callback). + * @throws TimeoutException + * @throws IOException + * @throws InvalidVersionException + */ + public SignalServiceEnvelope read(long timeout, TimeUnit unit, MessagePipeCallback callback) + throws TimeoutException, IOException, InvalidVersionException + { + if (!credentialsProvider.isPresent()) { + throw new IllegalArgumentException("You can't read messages if you haven't specified credentials"); + } + + while (true) { + WebSocketRequestMessage request = websocket.readRequest(unit.toMillis(timeout)); + WebSocketResponseMessage response = createWebSocketResponse(request); + boolean signalKeyEncrypted = isSignalKeyEncrypted(request); + + try { + if (isSignalServiceEnvelope(request)) { + SignalServiceEnvelope envelope = new SignalServiceEnvelope(request.getBody().toByteArray(), + credentialsProvider.get().getSignalingKey(), + signalKeyEncrypted); + + callback.onMessage(envelope); + return envelope; + } + } finally { + websocket.sendResponse(response); + } + } + } + + public SendMessageResponse send(OutgoingPushMessageList list, Optional unidentifiedAccess) throws IOException { + try { + List headers = new LinkedList() {{ + add("content-type:application/json"); + }}; + + if (unidentifiedAccess.isPresent()) { + headers.add("Unidentified-Access-Key:" + Base64.encodeBytes(unidentifiedAccess.get().getUnidentifiedAccessKey())); + } + + WebSocketRequestMessage requestMessage = WebSocketRequestMessage.newBuilder() + .setId(SecureRandom.getInstance("SHA1PRNG").nextLong()) + .setVerb("PUT") + .setPath(String.format("/v1/messages/%s", list.getDestination())) + .addAllHeaders(headers) + .setBody(ByteString.copyFrom(JsonUtil.toJson(list).getBytes())) + .build(); + + Pair response = websocket.sendRequest(requestMessage).get(10, TimeUnit.SECONDS); + + if (response.first() < 200 || response.first() >= 300) { + throw new IOException("Non-successful response: " + response.first()); + } + + if (Util.isEmpty(response.second())) return new SendMessageResponse(false); + else return JsonUtil.fromJson(response.second(), SendMessageResponse.class); + } catch (NoSuchAlgorithmException e) { + throw new AssertionError(e); + } catch (InterruptedException | ExecutionException | TimeoutException e) { + throw new IOException(e); + } + } + + public SignalServiceProfile getProfile(SignalServiceAddress address, Optional unidentifiedAccess) throws IOException { + try { + List headers = new LinkedList<>(); + + if (unidentifiedAccess.isPresent()) { + headers.add("Unidentified-Access-Key:" + Base64.encodeBytes(unidentifiedAccess.get().getUnidentifiedAccessKey())); + } + + WebSocketRequestMessage requestMessage = WebSocketRequestMessage.newBuilder() + .setId(SecureRandom.getInstance("SHA1PRNG").nextLong()) + .setVerb("GET") + .setPath(String.format("/v1/profile/%s", address.getIdentifier())) + .addAllHeaders(headers) + .build(); + + Pair response = websocket.sendRequest(requestMessage).get(10, TimeUnit.SECONDS); + + if (response.first() < 200 || response.first() >= 300) { + throw new IOException("Non-successful response: " + response.first()); + } + + return JsonUtil.fromJson(response.second(), SignalServiceProfile.class); + } catch (NoSuchAlgorithmException nsae) { + throw new AssertionError(nsae); + } catch (InterruptedException | ExecutionException | TimeoutException e) { + throw new IOException(e); + } + } + + public AttachmentUploadAttributes getAttachmentUploadAttributes() throws IOException { + try { + WebSocketRequestMessage requestMessage = WebSocketRequestMessage.newBuilder() + .setId(new SecureRandom().nextLong()) + .setVerb("GET") + .setPath("/v2/attachments/form/upload") + .build(); + + Pair response = websocket.sendRequest(requestMessage).get(10, TimeUnit.SECONDS); + + if (response.first() < 200 || response.first() >= 300) { + throw new IOException("Non-successful response: " + response.first()); + } + + return JsonUtil.fromJson(response.second(), AttachmentUploadAttributes.class); + } catch (InterruptedException | ExecutionException | TimeoutException e) { + throw new IOException(e); + } + } + + /** + * Close this connection to the server. + */ + public void shutdown() { + websocket.disconnect(); + } + + private boolean isSignalServiceEnvelope(WebSocketRequestMessage message) { + return "PUT".equals(message.getVerb()) && "/api/v1/message".equals(message.getPath()); + } + + private boolean isSignalKeyEncrypted(WebSocketRequestMessage message) { + List headers = message.getHeadersList(); + + if (headers == null || headers.isEmpty()) { + return true; + } + + for (String header : headers) { + String[] parts = header.split(":"); + + if (parts.length == 2 && parts[0] != null && parts[0].trim().equalsIgnoreCase("X-Signal-Key")) { + if (parts[1] != null && parts[1].trim().equalsIgnoreCase("false")) { + return false; + } + } + } + + return true; + } + + private WebSocketResponseMessage createWebSocketResponse(WebSocketRequestMessage request) { + if (isSignalServiceEnvelope(request)) { + return WebSocketResponseMessage.newBuilder() + .setId(request.getId()) + .setStatus(200) + .setMessage("OK") + .build(); + } else { + return WebSocketResponseMessage.newBuilder() + .setId(request.getId()) + .setStatus(400) + .setMessage("Unknown") + .build(); + } + } + + /** + * For receiving a callback when a new message has been + * received. + */ + public static interface MessagePipeCallback { + public void onMessage(SignalServiceEnvelope envelope); + } + + private static class NullMessagePipeCallback implements MessagePipeCallback { + @Override + public void onMessage(SignalServiceEnvelope envelope) {} + } + +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageReceiver.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageReceiver.java new file mode 100644 index 000000000..c133e5183 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageReceiver.java @@ -0,0 +1,258 @@ +/* + * Copyright (C) 2014-2016 Open Whisper Systems + * + * Licensed according to the LICENSE file in this repository. + */ + +package org.whispersystems.signalservice.api; + +import org.whispersystems.libsignal.InvalidMessageException; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.crypto.AttachmentCipherInputStream; +import org.whispersystems.signalservice.api.crypto.ProfileCipherInputStream; +import org.whispersystems.signalservice.api.crypto.UnidentifiedAccess; +import org.whispersystems.signalservice.api.messages.SignalServiceAttachment.ProgressListener; +import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentPointer; +import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage; +import org.whispersystems.signalservice.api.messages.SignalServiceEnvelope; +import org.whispersystems.signalservice.api.messages.SignalServiceStickerManifest; +import org.whispersystems.signalservice.api.profiles.SignalServiceProfile; +import org.whispersystems.signalservice.api.push.SignalServiceAddress; +import org.whispersystems.signalservice.api.util.CredentialsProvider; +import org.whispersystems.signalservice.api.util.SleepTimer; +import org.whispersystems.signalservice.api.util.UuidUtil; +import org.whispersystems.signalservice.api.websocket.ConnectivityListener; +import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration; +import org.whispersystems.signalservice.internal.push.PushServiceSocket; +import org.whispersystems.signalservice.internal.push.SignalServiceEnvelopeEntity; +import org.whispersystems.signalservice.internal.sticker.StickerProtos; +import org.whispersystems.signalservice.internal.util.StaticCredentialsProvider; +import org.whispersystems.signalservice.internal.util.Util; +import org.whispersystems.signalservice.internal.websocket.WebSocketConnection; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.LinkedList; +import java.util.List; +import java.util.UUID; + +/** + * The primary interface for receiving Signal Service messages. + * + * @author Moxie Marlinspike + */ +@SuppressWarnings("OptionalUsedAsFieldOrParameterType") +public class SignalServiceMessageReceiver { + + private final PushServiceSocket socket; + private final SignalServiceConfiguration urls; + private final CredentialsProvider credentialsProvider; + private final String userAgent; + private final ConnectivityListener connectivityListener; + private final SleepTimer sleepTimer; + + /** + * Construct a SignalServiceMessageReceiver. + * + * @param urls The URL of the Signal Service. + * @param uuid The Signal Service UUID. + * @param e164 The Signal Service phone number. + * @param password The Signal Service user password. + * @param signalingKey The 52 byte signaling key assigned to this user at registration. + */ + public SignalServiceMessageReceiver(SignalServiceConfiguration urls, + UUID uuid, String e164, String password, + String signalingKey, String userAgent, + ConnectivityListener listener, + SleepTimer timer) + { + this(urls, new StaticCredentialsProvider(uuid, e164, password, signalingKey), userAgent, listener, timer); + } + + /** + * Construct a SignalServiceMessageReceiver. + * + * @param urls The URL of the Signal Service. + * @param credentials The Signal Service user's credentials. + */ + public SignalServiceMessageReceiver(SignalServiceConfiguration urls, + CredentialsProvider credentials, + String userAgent, + ConnectivityListener listener, + SleepTimer timer) + { + this.urls = urls; + this.credentialsProvider = credentials; + this.socket = new PushServiceSocket(urls, credentials, userAgent); + this.userAgent = userAgent; + this.connectivityListener = listener; + this.sleepTimer = timer; + } + + /** + * Retrieves a SignalServiceAttachment. + * + * @param pointer The {@link SignalServiceAttachmentPointer} + * received in a {@link SignalServiceDataMessage}. + * @param destination The download destination for this attachment. + * + * @return An InputStream that streams the plaintext attachment contents. + * @throws IOException + * @throws InvalidMessageException + */ + public InputStream retrieveAttachment(SignalServiceAttachmentPointer pointer, File destination, int maxSizeBytes) + throws IOException, InvalidMessageException + { + return retrieveAttachment(pointer, destination, maxSizeBytes, null); + } + + public SignalServiceProfile retrieveProfile(SignalServiceAddress address, Optional unidentifiedAccess) + throws IOException + { + return socket.retrieveProfile(address, unidentifiedAccess); + } + + public InputStream retrieveProfileAvatar(String path, File destination, byte[] profileKey, int maxSizeBytes) + throws IOException + { + socket.retrieveProfileAvatar(path, destination, maxSizeBytes); + return new ProfileCipherInputStream(new FileInputStream(destination), profileKey); + } + + /** + * Retrieves a SignalServiceAttachment. + * + * @param pointer The {@link SignalServiceAttachmentPointer} + * received in a {@link SignalServiceDataMessage}. + * @param destination The download destination for this attachment. + * @param listener An optional listener (may be null) to receive callbacks on download progress. + * + * @return An InputStream that streams the plaintext attachment contents. + * @throws IOException + * @throws InvalidMessageException + */ + public InputStream retrieveAttachment(SignalServiceAttachmentPointer pointer, File destination, int maxSizeBytes, ProgressListener listener) + throws IOException, InvalidMessageException + { + if (!pointer.getDigest().isPresent()) throw new InvalidMessageException("No attachment digest!"); + + socket.retrieveAttachment(pointer.getId(), destination, maxSizeBytes, listener); + return AttachmentCipherInputStream.createForAttachment(destination, pointer.getSize().or(0), pointer.getKey(), pointer.getDigest().get()); + } + + public InputStream retrieveSticker(byte[] packId, byte[] packKey, int stickerId) + throws IOException, InvalidMessageException + { + byte[] data = socket.retrieveSticker(packId, stickerId); + return AttachmentCipherInputStream.createForStickerData(data, packKey); + } + + /** + * Retrieves a {@link SignalServiceStickerManifest}. + * + * @param packId The 16-byte packId that identifies the sticker pack. + * @param packKey The 32-byte packKey that decrypts the sticker pack. + * @return The {@link SignalServiceStickerManifest} representing the sticker pack. + * @throws IOException + * @throws InvalidMessageException + */ + public SignalServiceStickerManifest retrieveStickerManifest(byte[] packId, byte[] packKey) + throws IOException, InvalidMessageException + { + byte[] manifestBytes = socket.retrieveStickerManifest(packId); + + InputStream cipherStream = AttachmentCipherInputStream.createForStickerData(manifestBytes, packKey); + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + + Util.copy(cipherStream, outputStream); + + StickerProtos.Pack pack = StickerProtos.Pack.parseFrom(outputStream.toByteArray()); + List stickers = new ArrayList<>(pack.getStickersCount()); + SignalServiceStickerManifest.StickerInfo cover = pack.hasCover() ? new SignalServiceStickerManifest.StickerInfo(pack.getCover().getId(), pack.getCover().getEmoji()) + : null; + + for (StickerProtos.Pack.Sticker sticker : pack.getStickersList()) { + stickers.add(new SignalServiceStickerManifest.StickerInfo(sticker.getId(), sticker.getEmoji())); + } + + return new SignalServiceStickerManifest(pack.getTitle(), pack.getAuthor(), cover, stickers); + } + + /** + * Creates a pipe for receiving SignalService messages. + * + * Callers must call {@link SignalServiceMessagePipe#shutdown()} when finished with the pipe. + * + * @return A SignalServiceMessagePipe for receiving Signal Service messages. + */ + public SignalServiceMessagePipe createMessagePipe() { + WebSocketConnection webSocket = new WebSocketConnection(urls.getSignalServiceUrls()[0].getUrl(), + urls.getSignalServiceUrls()[0].getTrustStore(), + Optional.of(credentialsProvider), userAgent, connectivityListener, + sleepTimer); + + return new SignalServiceMessagePipe(webSocket, Optional.of(credentialsProvider)); + } + + public SignalServiceMessagePipe createUnidentifiedMessagePipe() { + WebSocketConnection webSocket = new WebSocketConnection(urls.getSignalServiceUrls()[0].getUrl(), + urls.getSignalServiceUrls()[0].getTrustStore(), + Optional.absent(), userAgent, connectivityListener, + sleepTimer); + + return new SignalServiceMessagePipe(webSocket, Optional.of(credentialsProvider)); + } + + public List retrieveMessages() throws IOException { + return retrieveMessages(new NullMessageReceivedCallback()); + } + + public List retrieveMessages(MessageReceivedCallback callback) + throws IOException + { + List results = new LinkedList<>(); + List entities = socket.getMessages(); + + for (SignalServiceEnvelopeEntity entity : entities) { + SignalServiceEnvelope envelope; + + if (entity.hasSource() && entity.getSourceDevice() > 0) { + SignalServiceAddress address = new SignalServiceAddress(UuidUtil.parseOrNull(entity.getSourceUuid()), entity.getSourceE164()); + envelope = new SignalServiceEnvelope(entity.getType(), Optional.of(address), + entity.getSourceDevice(), entity.getTimestamp(), + entity.getMessage(), entity.getContent(), + entity.getServerTimestamp(), entity.getServerUuid()); + } else { + envelope = new SignalServiceEnvelope(entity.getType(), entity.getTimestamp(), + entity.getMessage(), entity.getContent(), + entity.getServerTimestamp(), entity.getServerUuid()); + } + + callback.onMessage(envelope); + results.add(envelope); + + if (envelope.hasUuid()) socket.acknowledgeMessage(envelope.getUuid()); + else socket.acknowledgeMessage(entity.getSourceE164(), entity.getTimestamp()); + } + + return results; + } + + public void setSoTimeoutMillis(long soTimeoutMillis) { + socket.setSoTimeoutMillis(soTimeoutMillis); + } + + public interface MessageReceivedCallback { + public void onMessage(SignalServiceEnvelope envelope); + } + + public static class NullMessageReceivedCallback implements MessageReceivedCallback { + @Override + public void onMessage(SignalServiceEnvelope envelope) {} + } + +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageSender.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageSender.java new file mode 100644 index 000000000..4c15e2206 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageSender.java @@ -0,0 +1,1268 @@ +/* + * Copyright (C) 2014-2016 Open Whisper Systems + * + * Licensed according to the LICENSE file in this repository. + */ +package org.whispersystems.signalservice.api; + +import com.google.protobuf.ByteString; +import com.google.protobuf.InvalidProtocolBufferException; + +import org.whispersystems.libsignal.InvalidKeyException; +import org.whispersystems.libsignal.SessionBuilder; +import org.whispersystems.libsignal.SignalProtocolAddress; +import org.whispersystems.libsignal.logging.Log; +import org.whispersystems.libsignal.state.PreKeyBundle; +import org.whispersystems.libsignal.state.SignalProtocolStore; +import org.whispersystems.libsignal.util.Pair; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.crypto.AttachmentCipherOutputStream; +import org.whispersystems.signalservice.api.crypto.SignalServiceCipher; +import org.whispersystems.signalservice.api.crypto.UnidentifiedAccess; +import org.whispersystems.signalservice.api.crypto.UnidentifiedAccessPair; +import org.whispersystems.signalservice.api.crypto.UntrustedIdentityException; +import org.whispersystems.signalservice.api.messages.SendMessageResult; +import org.whispersystems.signalservice.api.messages.SignalServiceAttachment; +import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentPointer; +import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentStream; +import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage; +import org.whispersystems.signalservice.api.messages.SignalServiceGroup; +import org.whispersystems.signalservice.api.messages.SignalServiceReceiptMessage; +import org.whispersystems.signalservice.api.messages.SignalServiceTypingMessage; +import org.whispersystems.signalservice.api.messages.calls.AnswerMessage; +import org.whispersystems.signalservice.api.messages.calls.IceUpdateMessage; +import org.whispersystems.signalservice.api.messages.calls.OfferMessage; +import org.whispersystems.signalservice.api.messages.calls.SignalServiceCallMessage; +import org.whispersystems.signalservice.api.messages.multidevice.BlockedListMessage; +import org.whispersystems.signalservice.api.messages.multidevice.ConfigurationMessage; +import org.whispersystems.signalservice.api.messages.multidevice.ReadMessage; +import org.whispersystems.signalservice.api.messages.multidevice.SentTranscriptMessage; +import org.whispersystems.signalservice.api.messages.multidevice.SignalServiceSyncMessage; +import org.whispersystems.signalservice.api.messages.multidevice.StickerPackOperationMessage; +import org.whispersystems.signalservice.api.messages.multidevice.VerifiedMessage; +import org.whispersystems.signalservice.api.messages.multidevice.ViewOnceOpenMessage; +import org.whispersystems.signalservice.api.messages.shared.SharedContact; +import org.whispersystems.signalservice.api.push.SignalServiceAddress; +import org.whispersystems.signalservice.api.push.exceptions.AuthorizationFailedException; +import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException; +import org.whispersystems.signalservice.api.push.exceptions.UnregisteredUserException; +import org.whispersystems.signalservice.api.util.CredentialsProvider; +import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration; +import org.whispersystems.signalservice.internal.crypto.PaddingInputStream; +import org.whispersystems.signalservice.internal.push.AttachmentUploadAttributes; +import org.whispersystems.signalservice.internal.push.MismatchedDevices; +import org.whispersystems.signalservice.internal.push.OutgoingPushMessage; +import org.whispersystems.signalservice.internal.push.OutgoingPushMessageList; +import org.whispersystems.signalservice.internal.push.ProvisioningProtos; +import org.whispersystems.signalservice.internal.push.PushAttachmentData; +import org.whispersystems.signalservice.internal.push.PushServiceSocket; +import org.whispersystems.signalservice.internal.push.SendMessageResponse; +import org.whispersystems.signalservice.internal.push.SignalServiceProtos.AttachmentPointer; +import org.whispersystems.signalservice.internal.push.SignalServiceProtos.CallMessage; +import org.whispersystems.signalservice.internal.push.SignalServiceProtos.Content; +import org.whispersystems.signalservice.internal.push.SignalServiceProtos.DataMessage; +import org.whispersystems.signalservice.internal.push.SignalServiceProtos.GroupContext; +import org.whispersystems.signalservice.internal.push.SignalServiceProtos.NullMessage; +import org.whispersystems.signalservice.internal.push.SignalServiceProtos.ReceiptMessage; +import org.whispersystems.signalservice.internal.push.SignalServiceProtos.SyncMessage; +import org.whispersystems.signalservice.internal.push.SignalServiceProtos.TypingMessage; +import org.whispersystems.signalservice.internal.push.SignalServiceProtos.Verified; +import org.whispersystems.signalservice.internal.push.StaleDevices; +import org.whispersystems.signalservice.internal.push.exceptions.MismatchedDevicesException; +import org.whispersystems.signalservice.internal.push.exceptions.StaleDevicesException; +import org.whispersystems.signalservice.internal.push.http.AttachmentCipherOutputStreamFactory; +import org.whispersystems.signalservice.internal.util.StaticCredentialsProvider; +import org.whispersystems.signalservice.internal.util.Util; +import org.whispersystems.util.Base64; + +import java.io.IOException; +import java.io.InputStream; +import java.security.SecureRandom; +import java.util.Collections; +import java.util.Iterator; +import java.util.LinkedList; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + +/** + * The main interface for sending Signal Service messages. + * + * @author Moxie Marlinspike + */ +public class SignalServiceMessageSender { + + private static final String TAG = SignalServiceMessageSender.class.getSimpleName(); + + private final PushServiceSocket socket; + private final SignalProtocolStore store; + private final SignalServiceAddress localAddress; + private final Optional eventListener; + + private final AtomicReference> pipe; + private final AtomicReference> unidentifiedPipe; + private final AtomicBoolean isMultiDevice; + + /** + * Construct a SignalServiceMessageSender. + * + * @param urls The URL of the Signal Service. + * @param uuid The Signal Service UUID. + * @param e164 The Signal Service phone number. + * @param password The Signal Service user password. + * @param store The SignalProtocolStore. + * @param eventListener An optional event listener, which fires whenever sessions are + * setup or torn down for a recipient. + */ + public SignalServiceMessageSender(SignalServiceConfiguration urls, + UUID uuid, String e164, String password, + SignalProtocolStore store, + String userAgent, + boolean isMultiDevice, + Optional pipe, + Optional unidentifiedPipe, + Optional eventListener) + { + this(urls, new StaticCredentialsProvider(uuid, e164, password, null), store, userAgent, isMultiDevice, pipe, unidentifiedPipe, eventListener); + } + + public SignalServiceMessageSender(SignalServiceConfiguration urls, + CredentialsProvider credentialsProvider, + SignalProtocolStore store, + String userAgent, + boolean isMultiDevice, + Optional pipe, + Optional unidentifiedPipe, + Optional eventListener) + { + this.socket = new PushServiceSocket(urls, credentialsProvider, userAgent); + this.store = store; + this.localAddress = new SignalServiceAddress(credentialsProvider.getUuid(), credentialsProvider.getE164()); + this.pipe = new AtomicReference<>(pipe); + this.unidentifiedPipe = new AtomicReference<>(unidentifiedPipe); + this.isMultiDevice = new AtomicBoolean(isMultiDevice); + this.eventListener = eventListener; + } + + /** + * Send a read receipt for a received message. + * + * @param recipient The sender of the received message you're acknowledging. + * @param message The read receipt to deliver. + * @throws IOException + * @throws UntrustedIdentityException + */ + public void sendReceipt(SignalServiceAddress recipient, + Optional unidentifiedAccess, + SignalServiceReceiptMessage message) + throws IOException, UntrustedIdentityException + { + byte[] content = createReceiptContent(message); + + sendMessage(recipient, getTargetUnidentifiedAccess(unidentifiedAccess), message.getWhen(), content, false); + } + + /** + * Send a typing indicator. + * + * @param recipient The destination + * @param message The typing indicator to deliver + * @throws IOException + * @throws UntrustedIdentityException + */ + public void sendTyping(SignalServiceAddress recipient, + Optional unidentifiedAccess, + SignalServiceTypingMessage message) + throws IOException, UntrustedIdentityException + { + byte[] content = createTypingContent(message); + + sendMessage(recipient, getTargetUnidentifiedAccess(unidentifiedAccess), message.getTimestamp(), content, true); + } + + public void sendTyping(List recipients, + List> unidentifiedAccess, + SignalServiceTypingMessage message) + throws IOException + { + byte[] content = createTypingContent(message); + sendMessage(recipients, getTargetUnidentifiedAccess(unidentifiedAccess), message.getTimestamp(), content, true); + } + + + /** + * Send a call setup message to a single recipient. + * + * @param recipient The message's destination. + * @param message The call message. + * @throws IOException + */ + public void sendCallMessage(SignalServiceAddress recipient, + Optional unidentifiedAccess, + SignalServiceCallMessage message) + throws IOException, UntrustedIdentityException + { + byte[] content = createCallContent(message); + sendMessage(recipient, getTargetUnidentifiedAccess(unidentifiedAccess), System.currentTimeMillis(), content, false); + } + + /** + * Send a message to a single recipient. + * + * @param recipient The message's destination. + * @param message The message. + * @throws UntrustedIdentityException + * @throws IOException + */ + public SendMessageResult sendMessage(SignalServiceAddress recipient, + Optional unidentifiedAccess, + SignalServiceDataMessage message) + throws UntrustedIdentityException, IOException + { + byte[] content = createMessageContent(message); + long timestamp = message.getTimestamp(); + SendMessageResult result = sendMessage(recipient, getTargetUnidentifiedAccess(unidentifiedAccess), timestamp, content, false); + + if (result.getSuccess() != null && result.getSuccess().isNeedsSync()) { + byte[] syncMessage = createMultiDeviceSentTranscriptContent(content, Optional.of(recipient), timestamp, Collections.singletonList(result), false); + sendMessage(localAddress, Optional.absent(), timestamp, syncMessage, false); + } + + if (message.isEndSession()) { + if (recipient.getUuid().isPresent()) { + store.deleteAllSessions(recipient.getUuid().get().toString()); + } + if (recipient.getNumber().isPresent()) { + store.deleteAllSessions(recipient.getNumber().get()); + } + + if (eventListener.isPresent()) { + eventListener.get().onSecurityEvent(recipient); + } + } + + return result; + } + + /** + * Send a message to a group. + * + * @param recipients The group members. + * @param message The group message. + * @throws IOException + */ + public List sendMessage(List recipients, + List> unidentifiedAccess, + boolean isRecipientUpdate, + SignalServiceDataMessage message) + throws IOException, UntrustedIdentityException + { + byte[] content = createMessageContent(message); + long timestamp = message.getTimestamp(); + List results = sendMessage(recipients, getTargetUnidentifiedAccess(unidentifiedAccess), timestamp, content, false); + boolean needsSyncInResults = false; + + for (SendMessageResult result : results) { + if (result.getSuccess() != null && result.getSuccess().isNeedsSync()) { + needsSyncInResults = true; + break; + } + } + + if (needsSyncInResults || isMultiDevice.get()) { + byte[] syncMessage = createMultiDeviceSentTranscriptContent(content, Optional.absent(), timestamp, results, isRecipientUpdate); + sendMessage(localAddress, Optional.absent(), timestamp, syncMessage, false); + } + + return results; + } + + public void sendMessage(SignalServiceSyncMessage message, Optional unidentifiedAccess) + throws IOException, UntrustedIdentityException + { + byte[] content; + + if (message.getContacts().isPresent()) { + content = createMultiDeviceContactsContent(message.getContacts().get().getContactsStream().asStream(), + message.getContacts().get().isComplete()); + } else if (message.getGroups().isPresent()) { + content = createMultiDeviceGroupsContent(message.getGroups().get().asStream()); + } else if (message.getRead().isPresent()) { + content = createMultiDeviceReadContent(message.getRead().get()); + } else if (message.getViewOnceOpen().isPresent()) { + content = createMultiDeviceViewOnceOpenContent(message.getViewOnceOpen().get()); + } else if (message.getBlockedList().isPresent()) { + content = createMultiDeviceBlockedContent(message.getBlockedList().get()); + } else if (message.getConfiguration().isPresent()) { + content = createMultiDeviceConfigurationContent(message.getConfiguration().get()); + } else if (message.getSent().isPresent()) { + content = createMultiDeviceSentTranscriptContent(message.getSent().get(), unidentifiedAccess); + } else if (message.getStickerPackOperations().isPresent()) { + content = createMultiDeviceStickerPackOperationContent(message.getStickerPackOperations().get()); + } else if (message.getFetchType().isPresent()) { + content = createMultiDeviceFetchTypeContent(message.getFetchType().get()); + } else if (message.getVerified().isPresent()) { + sendMessage(message.getVerified().get(), unidentifiedAccess); + return; + } else { + throw new IOException("Unsupported sync message!"); + } + + long timestamp = message.getSent().isPresent() ? message.getSent().get().getTimestamp() + : System.currentTimeMillis(); + + sendMessage(localAddress, Optional.absent(), timestamp, content, false); + } + + public void setSoTimeoutMillis(long soTimeoutMillis) { + socket.setSoTimeoutMillis(soTimeoutMillis); + } + + public void cancelInFlightRequests() { + socket.cancelInFlightRequests(); + } + + public void setMessagePipe(SignalServiceMessagePipe pipe, SignalServiceMessagePipe unidentifiedPipe) { + this.pipe.set(Optional.fromNullable(pipe)); + this.unidentifiedPipe.set(Optional.fromNullable(unidentifiedPipe)); + } + + public void setIsMultiDevice(boolean isMultiDevice) { + this.isMultiDevice.set(isMultiDevice); + } + + public SignalServiceAttachmentPointer uploadAttachment(SignalServiceAttachmentStream attachment) throws IOException { + byte[] attachmentKey = Util.getSecretBytes(64); + long paddedLength = PaddingInputStream.getPaddedSize(attachment.getLength()); + InputStream dataStream = new PaddingInputStream(attachment.getInputStream(), attachment.getLength()); + long ciphertextLength = AttachmentCipherOutputStream.getCiphertextLength(paddedLength); + PushAttachmentData attachmentData = new PushAttachmentData(attachment.getContentType(), + dataStream, + ciphertextLength, + new AttachmentCipherOutputStreamFactory(attachmentKey), + attachment.getListener()); + + AttachmentUploadAttributes uploadAttributes = null; + + if (pipe.get().isPresent()) { + Log.d(TAG, "Using pipe to retrieve attachment upload attributes..."); + try { + uploadAttributes = pipe.get().get().getAttachmentUploadAttributes(); + } catch (IOException e) { + Log.w(TAG, "Failed to retrieve attachment upload attributes using pipe. Falling back..."); + } + } + + if (uploadAttributes == null) { + Log.d(TAG, "Not using pipe to retrieve attachment upload attributes..."); + uploadAttributes = socket.getAttachmentUploadAttributes(); + } + + Pair attachmentIdAndDigest = socket.uploadAttachment(attachmentData, uploadAttributes); + + return new SignalServiceAttachmentPointer(attachmentIdAndDigest.first(), + attachment.getContentType(), + attachmentKey, + Optional.of(Util.toIntExact(attachment.getLength())), + attachment.getPreview(), + attachment.getWidth(), attachment.getHeight(), + Optional.of(attachmentIdAndDigest.second()), + attachment.getFileName(), + attachment.getVoiceNote(), + attachment.getCaption(), + attachment.getBlurHash()); + } + + + private void sendMessage(VerifiedMessage message, Optional unidentifiedAccess) + throws IOException, UntrustedIdentityException + { + byte[] nullMessageBody = DataMessage.newBuilder() + .setBody(Base64.encodeBytes(Util.getRandomLengthBytes(140))) + .build() + .toByteArray(); + + NullMessage nullMessage = NullMessage.newBuilder() + .setPadding(ByteString.copyFrom(nullMessageBody)) + .build(); + + byte[] content = Content.newBuilder() + .setNullMessage(nullMessage) + .build() + .toByteArray(); + + SendMessageResult result = sendMessage(message.getDestination(), getTargetUnidentifiedAccess(unidentifiedAccess), message.getTimestamp(), content, false); + + if (result.getSuccess().isNeedsSync()) { + byte[] syncMessage = createMultiDeviceVerifiedContent(message, nullMessage.toByteArray()); + sendMessage(localAddress, Optional.absent(), message.getTimestamp(), syncMessage, false); + } + } + + private byte[] createTypingContent(SignalServiceTypingMessage message) { + Content.Builder container = Content.newBuilder(); + TypingMessage.Builder builder = TypingMessage.newBuilder(); + + builder.setTimestamp(message.getTimestamp()); + + if (message.isTypingStarted()) builder.setAction(TypingMessage.Action.STARTED); + else if (message.isTypingStopped()) builder.setAction(TypingMessage.Action.STOPPED); + else throw new IllegalArgumentException("Unknown typing indicator"); + + if (message.getGroupId().isPresent()) { + builder.setGroupId(ByteString.copyFrom(message.getGroupId().get())); + } + + return container.setTypingMessage(builder).build().toByteArray(); + } + + private byte[] createReceiptContent(SignalServiceReceiptMessage message) { + Content.Builder container = Content.newBuilder(); + ReceiptMessage.Builder builder = ReceiptMessage.newBuilder(); + + for (long timestamp : message.getTimestamps()) { + builder.addTimestamp(timestamp); + } + + if (message.isDeliveryReceipt()) builder.setType(ReceiptMessage.Type.DELIVERY); + else if (message.isReadReceipt()) builder.setType(ReceiptMessage.Type.READ); + + return container.setReceiptMessage(builder).build().toByteArray(); + } + + private byte[] createMessageContent(SignalServiceDataMessage message) throws IOException { + Content.Builder container = Content.newBuilder(); + DataMessage.Builder builder = DataMessage.newBuilder(); + List pointers = createAttachmentPointers(message.getAttachments()); + + if (!pointers.isEmpty()) { + builder.addAllAttachments(pointers); + } + + if (message.getBody().isPresent()) { + builder.setBody(message.getBody().get()); + } + + if (message.getGroupInfo().isPresent()) { + builder.setGroup(createGroupContent(message.getGroupInfo().get())); + } + + if (message.isEndSession()) { + builder.setFlags(DataMessage.Flags.END_SESSION_VALUE); + } + + if (message.isExpirationUpdate()) { + builder.setFlags(DataMessage.Flags.EXPIRATION_TIMER_UPDATE_VALUE); + } + + if (message.isProfileKeyUpdate()) { + builder.setFlags(DataMessage.Flags.PROFILE_KEY_UPDATE_VALUE); + } + + if (message.getExpiresInSeconds() > 0) { + builder.setExpireTimer(message.getExpiresInSeconds()); + } + + if (message.getProfileKey().isPresent()) { + builder.setProfileKey(ByteString.copyFrom(message.getProfileKey().get())); + } + + if (message.getQuote().isPresent()) { + DataMessage.Quote.Builder quoteBuilder = DataMessage.Quote.newBuilder() + .setId(message.getQuote().get().getId()) + .setText(message.getQuote().get().getText()); + + if (message.getQuote().get().getAuthor().getUuid().isPresent()) { + quoteBuilder = quoteBuilder.setAuthorUuid(message.getQuote().get().getAuthor().getUuid().get().toString()); + } + + if (message.getQuote().get().getAuthor().getNumber().isPresent()) { + quoteBuilder = quoteBuilder.setAuthorE164(message.getQuote().get().getAuthor().getNumber().get()); + } + + for (SignalServiceDataMessage.Quote.QuotedAttachment attachment : message.getQuote().get().getAttachments()) { + DataMessage.Quote.QuotedAttachment.Builder quotedAttachment = DataMessage.Quote.QuotedAttachment.newBuilder(); + + quotedAttachment.setContentType(attachment.getContentType()); + + if (attachment.getFileName() != null) { + quotedAttachment.setFileName(attachment.getFileName()); + } + + if (attachment.getThumbnail() != null) { + quotedAttachment.setThumbnail(createAttachmentPointer(attachment.getThumbnail().asStream())); + } + + quoteBuilder.addAttachments(quotedAttachment); + } + + builder.setQuote(quoteBuilder); + } + + if (message.getSharedContacts().isPresent()) { + builder.addAllContact(createSharedContactContent(message.getSharedContacts().get())); + } + + if (message.getPreviews().isPresent()) { + for (SignalServiceDataMessage.Preview preview : message.getPreviews().get()) { + DataMessage.Preview.Builder previewBuilder = DataMessage.Preview.newBuilder(); + previewBuilder.setTitle(preview.getTitle()); + previewBuilder.setUrl(preview.getUrl()); + + if (preview.getImage().isPresent()) { + if (preview.getImage().get().isStream()) { + previewBuilder.setImage(createAttachmentPointer(preview.getImage().get().asStream())); + } else { + previewBuilder.setImage(createAttachmentPointer(preview.getImage().get().asPointer())); + } + } + + builder.addPreview(previewBuilder.build()); + } + } + + if (message.getSticker().isPresent()) { + DataMessage.Sticker.Builder stickerBuilder = DataMessage.Sticker.newBuilder(); + + stickerBuilder.setPackId(ByteString.copyFrom(message.getSticker().get().getPackId())); + stickerBuilder.setPackKey(ByteString.copyFrom(message.getSticker().get().getPackKey())); + stickerBuilder.setStickerId(message.getSticker().get().getStickerId()); + + if (message.getSticker().get().getAttachment().isStream()) { + stickerBuilder.setData(createAttachmentPointer(message.getSticker().get().getAttachment().asStream())); + } else { + stickerBuilder.setData(createAttachmentPointer(message.getSticker().get().getAttachment().asPointer())); + } + + builder.setSticker(stickerBuilder.build()); + } + + if (message.isViewOnce()) { + builder.setIsViewOnce(message.isViewOnce()); + builder.setRequiredProtocolVersion(Math.max(DataMessage.ProtocolVersion.VIEW_ONCE_VIDEO_VALUE, builder.getRequiredProtocolVersion())); + } + + builder.setTimestamp(message.getTimestamp()); + + return container.setDataMessage(builder).build().toByteArray(); + } + + private byte[] createCallContent(SignalServiceCallMessage callMessage) { + Content.Builder container = Content.newBuilder(); + CallMessage.Builder builder = CallMessage.newBuilder(); + + if (callMessage.getOfferMessage().isPresent()) { + OfferMessage offer = callMessage.getOfferMessage().get(); + builder.setOffer(CallMessage.Offer.newBuilder() + .setId(offer.getId()) + .setDescription(offer.getDescription())); + } else if (callMessage.getAnswerMessage().isPresent()) { + AnswerMessage answer = callMessage.getAnswerMessage().get(); + builder.setAnswer(CallMessage.Answer.newBuilder() + .setId(answer.getId()) + .setDescription(answer.getDescription())); + } else if (callMessage.getIceUpdateMessages().isPresent()) { + List updates = callMessage.getIceUpdateMessages().get(); + + for (IceUpdateMessage update : updates) { + builder.addIceUpdate(CallMessage.IceUpdate.newBuilder() + .setId(update.getId()) + .setSdp(update.getSdp()) + .setSdpMid(update.getSdpMid()) + .setSdpMLineIndex(update.getSdpMLineIndex())); + } + } else if (callMessage.getHangupMessage().isPresent()) { + builder.setHangup(CallMessage.Hangup.newBuilder().setId(callMessage.getHangupMessage().get().getId())); + } else if (callMessage.getBusyMessage().isPresent()) { + builder.setBusy(CallMessage.Busy.newBuilder().setId(callMessage.getBusyMessage().get().getId())); + } + + container.setCallMessage(builder); + return container.build().toByteArray(); + } + + private byte[] createMultiDeviceContactsContent(SignalServiceAttachmentStream contacts, boolean complete) throws IOException { + Content.Builder container = Content.newBuilder(); + SyncMessage.Builder builder = createSyncMessageBuilder(); + builder.setContacts(SyncMessage.Contacts.newBuilder() + .setBlob(createAttachmentPointer(contacts)) + .setComplete(complete)); + + return container.setSyncMessage(builder).build().toByteArray(); + } + + private byte[] createMultiDeviceGroupsContent(SignalServiceAttachmentStream groups) throws IOException { + Content.Builder container = Content.newBuilder(); + SyncMessage.Builder builder = createSyncMessageBuilder(); + builder.setGroups(SyncMessage.Groups.newBuilder() + .setBlob(createAttachmentPointer(groups))); + + return container.setSyncMessage(builder).build().toByteArray(); + } + + private byte[] createMultiDeviceSentTranscriptContent(SentTranscriptMessage transcript, Optional unidentifiedAccess) throws IOException { + SignalServiceAddress address = transcript.getDestination().get(); + SendMessageResult result = SendMessageResult.success(address, unidentifiedAccess.isPresent(), true); + + return createMultiDeviceSentTranscriptContent(createMessageContent(transcript.getMessage()), + Optional.of(address), + transcript.getTimestamp(), + Collections.singletonList(result), + false); + } + + private byte[] createMultiDeviceSentTranscriptContent(byte[] content, Optional recipient, + long timestamp, List sendMessageResults, + boolean isRecipientUpdate) + { + try { + Content.Builder container = Content.newBuilder(); + SyncMessage.Builder syncMessage = createSyncMessageBuilder(); + SyncMessage.Sent.Builder sentMessage = SyncMessage.Sent.newBuilder(); + DataMessage dataMessage = Content.parseFrom(content).getDataMessage(); + + sentMessage.setTimestamp(timestamp); + sentMessage.setMessage(dataMessage); + + for (SendMessageResult result : sendMessageResults) { + if (result.getSuccess() != null) { + SyncMessage.Sent.UnidentifiedDeliveryStatus.Builder builder = SyncMessage.Sent.UnidentifiedDeliveryStatus.newBuilder(); + + if (result.getAddress().getUuid().isPresent()) { + builder = builder.setDestinationUuid(result.getAddress().getUuid().get().toString()); + } + + if (result.getAddress().getNumber().isPresent()) { + builder = builder.setDestinationE164(result.getAddress().getNumber().get()); + } + + builder.setUnidentified(result.getSuccess().isUnidentified()); + + sentMessage.addUnidentifiedStatus(builder.build()); + } + } + + if (recipient.isPresent()) { + if (recipient.get().getUuid().isPresent()) sentMessage.setDestinationUuid(recipient.get().getUuid().get().toString()); + if (recipient.get().getNumber().isPresent()) sentMessage.setDestinationE164(recipient.get().getNumber().get()); + } + + if (dataMessage.getExpireTimer() > 0) { + sentMessage.setExpirationStartTimestamp(System.currentTimeMillis()); + } + + if (dataMessage.getIsViewOnce()) { + dataMessage = dataMessage.toBuilder().clearAttachments().build(); + sentMessage.setMessage(dataMessage); + } + + sentMessage.setIsRecipientUpdate(isRecipientUpdate); + + return container.setSyncMessage(syncMessage.setSent(sentMessage)).build().toByteArray(); + } catch (InvalidProtocolBufferException e) { + throw new AssertionError(e); + } + } + + private byte[] createMultiDeviceReadContent(List readMessages) { + Content.Builder container = Content.newBuilder(); + SyncMessage.Builder builder = createSyncMessageBuilder(); + + for (ReadMessage readMessage : readMessages) { + SyncMessage.Read.Builder readBuilder = SyncMessage.Read.newBuilder().setTimestamp(readMessage.getTimestamp()); + + if (readMessage.getSender().getUuid().isPresent()) { + readBuilder.setSenderUuid(readMessage.getSender().getUuid().get().toString()); + } + + if (readMessage.getSender().getNumber().isPresent()) { + readBuilder.setSenderE164(readMessage.getSender().getNumber().get()); + } + + builder.addRead(readBuilder.build()); + } + + return container.setSyncMessage(builder).build().toByteArray(); + } + + private byte[] createMultiDeviceViewOnceOpenContent(ViewOnceOpenMessage readMessage) { + Content.Builder container = Content.newBuilder(); + SyncMessage.Builder builder = createSyncMessageBuilder(); + SyncMessage.ViewOnceOpen.Builder viewOnceBuilder = SyncMessage.ViewOnceOpen.newBuilder().setTimestamp(readMessage.getTimestamp()); + + if (readMessage.getSender().getUuid().isPresent()) { + viewOnceBuilder.setSenderUuid(readMessage.getSender().getUuid().get().toString()); + } + + if (readMessage.getSender().getNumber().isPresent()) { + viewOnceBuilder.setSenderE164(readMessage.getSender().getNumber().get()); + } + + builder.setViewOnceOpen(viewOnceBuilder.build()); + + return container.setSyncMessage(builder).build().toByteArray(); + } + + private byte[] createMultiDeviceBlockedContent(BlockedListMessage blocked) { + Content.Builder container = Content.newBuilder(); + SyncMessage.Builder syncMessage = createSyncMessageBuilder(); + SyncMessage.Blocked.Builder blockedMessage = SyncMessage.Blocked.newBuilder(); + + for (SignalServiceAddress address : blocked.getAddresses()) { + if (address.getUuid().isPresent()) { + blockedMessage.addUuids(address.getUuid().get().toString()); + } + if (address.getNumber().isPresent()) { + blockedMessage.addNumbers(address.getNumber().get()); + } + } + + for (byte[] groupId : blocked.getGroupIds()) { + blockedMessage.addGroupIds(ByteString.copyFrom(groupId)); + } + + return container.setSyncMessage(syncMessage.setBlocked(blockedMessage)).build().toByteArray(); + } + + private byte[] createMultiDeviceConfigurationContent(ConfigurationMessage configuration) { + Content.Builder container = Content.newBuilder(); + SyncMessage.Builder syncMessage = createSyncMessageBuilder(); + SyncMessage.Configuration.Builder configurationMessage = SyncMessage.Configuration.newBuilder(); + + if (configuration.getReadReceipts().isPresent()) { + configurationMessage.setReadReceipts(configuration.getReadReceipts().get()); + } + + if (configuration.getUnidentifiedDeliveryIndicators().isPresent()) { + configurationMessage.setUnidentifiedDeliveryIndicators(configuration.getUnidentifiedDeliveryIndicators().get()); + } + + if (configuration.getTypingIndicators().isPresent()) { + configurationMessage.setTypingIndicators(configuration.getTypingIndicators().get()); + } + + if (configuration.getLinkPreviews().isPresent()) { + configurationMessage.setLinkPreviews(configuration.getLinkPreviews().get()); + } + + configurationMessage.setProvisioningVersion(ProvisioningProtos.ProvisioningVersion.CURRENT_VALUE); + + return container.setSyncMessage(syncMessage.setConfiguration(configurationMessage)).build().toByteArray(); + } + + private byte[] createMultiDeviceStickerPackOperationContent(List stickerPackOperations) { + Content.Builder container = Content.newBuilder(); + SyncMessage.Builder syncMessage = createSyncMessageBuilder(); + + for (StickerPackOperationMessage stickerPackOperation : stickerPackOperations) { + SyncMessage.StickerPackOperation.Builder builder = SyncMessage.StickerPackOperation.newBuilder(); + + if (stickerPackOperation.getPackId().isPresent()) { + builder.setPackId(ByteString.copyFrom(stickerPackOperation.getPackId().get())); + } + + if (stickerPackOperation.getPackKey().isPresent()) { + builder.setPackKey(ByteString.copyFrom(stickerPackOperation.getPackKey().get())); + } + + if (stickerPackOperation.getType().isPresent()) { + switch (stickerPackOperation.getType().get()) { + case INSTALL: builder.setType(SyncMessage.StickerPackOperation.Type.INSTALL); break; + case REMOVE: builder.setType(SyncMessage.StickerPackOperation.Type.REMOVE); break; + } + } + + syncMessage.addStickerPackOperation(builder); + } + + return container.setSyncMessage(syncMessage).build().toByteArray(); + } + + private byte[] createMultiDeviceFetchTypeContent(SignalServiceSyncMessage.FetchType fetchType) { + Content.Builder container = Content.newBuilder(); + SyncMessage.Builder syncMessage = createSyncMessageBuilder(); + SyncMessage.FetchLatest.Builder fetchMessage = SyncMessage.FetchLatest.newBuilder(); + + switch (fetchType) { + case LOCAL_PROFILE: + fetchMessage.setType(SyncMessage.FetchLatest.Type.LOCAL_PROFILE); + break; + case STORAGE_MANIFEST: + fetchMessage.setType(SyncMessage.FetchLatest.Type.STORAGE_MANIFEST); + break; + default: + Log.w(TAG, "Unknown fetch type!"); + break; + } + + return container.setSyncMessage(syncMessage.setFetchLatest(fetchMessage)).build().toByteArray(); + } + + private byte[] createMultiDeviceVerifiedContent(VerifiedMessage verifiedMessage, byte[] nullMessage) { + Content.Builder container = Content.newBuilder(); + SyncMessage.Builder syncMessage = createSyncMessageBuilder(); + Verified.Builder verifiedMessageBuilder = Verified.newBuilder(); + + verifiedMessageBuilder.setNullMessage(ByteString.copyFrom(nullMessage)); + verifiedMessageBuilder.setIdentityKey(ByteString.copyFrom(verifiedMessage.getIdentityKey().serialize())); + + if (verifiedMessage.getDestination().getUuid().isPresent()) { + verifiedMessageBuilder.setDestinationUuid(verifiedMessage.getDestination().getUuid().get().toString()); + } + + if (verifiedMessage.getDestination().getNumber().isPresent()) { + verifiedMessageBuilder.setDestinationE164(verifiedMessage.getDestination().getNumber().get()); + } + + switch(verifiedMessage.getVerified()) { + case DEFAULT: verifiedMessageBuilder.setState(Verified.State.DEFAULT); break; + case VERIFIED: verifiedMessageBuilder.setState(Verified.State.VERIFIED); break; + case UNVERIFIED: verifiedMessageBuilder.setState(Verified.State.UNVERIFIED); break; + default: throw new AssertionError("Unknown: " + verifiedMessage.getVerified()); + } + + syncMessage.setVerified(verifiedMessageBuilder); + return container.setSyncMessage(syncMessage).build().toByteArray(); + } + + private SyncMessage.Builder createSyncMessageBuilder() { + SecureRandom random = new SecureRandom(); + byte[] padding = Util.getRandomLengthBytes(512); + random.nextBytes(padding); + + SyncMessage.Builder builder = SyncMessage.newBuilder(); + builder.setPadding(ByteString.copyFrom(padding)); + + return builder; + } + + private GroupContext createGroupContent(SignalServiceGroup group) throws IOException { + GroupContext.Builder builder = GroupContext.newBuilder(); + builder.setId(ByteString.copyFrom(group.getGroupId())); + + if (group.getType() != SignalServiceGroup.Type.DELIVER) { + if (group.getType() == SignalServiceGroup.Type.UPDATE) builder.setType(GroupContext.Type.UPDATE); + else if (group.getType() == SignalServiceGroup.Type.QUIT) builder.setType(GroupContext.Type.QUIT); + else if (group.getType() == SignalServiceGroup.Type.REQUEST_INFO) builder.setType(GroupContext.Type.REQUEST_INFO); + else throw new AssertionError("Unknown type: " + group.getType()); + + if (group.getName().isPresent()) { + builder.setName(group.getName().get()); + } + + if (group.getMembers().isPresent()) { + for (SignalServiceAddress address : group.getMembers().get()) { + if (address.getNumber().isPresent()) { + builder.addMembersE164(address.getNumber().get()); + } + + GroupContext.Member.Builder memberBuilder = GroupContext.Member.newBuilder(); + + if (address.getUuid().isPresent()) { + memberBuilder.setUuid(address.getUuid().get().toString()); + } + + if (address.getNumber().isPresent()) { + memberBuilder.setE164(address.getNumber().get()); + } + + builder.addMembers(memberBuilder.build()); + } + } + + if (group.getAvatar().isPresent()) { + if (group.getAvatar().get().isStream()) { + builder.setAvatar(createAttachmentPointer(group.getAvatar().get().asStream())); + } else { + builder.setAvatar(createAttachmentPointer(group.getAvatar().get().asPointer())); + } + } + } else { + builder.setType(GroupContext.Type.DELIVER); + } + + return builder.build(); + } + + private List createSharedContactContent(List contacts) throws IOException { + List results = new LinkedList<>(); + + for (SharedContact contact : contacts) { + DataMessage.Contact.Name.Builder nameBuilder = DataMessage.Contact.Name.newBuilder(); + + if (contact.getName().getFamily().isPresent()) nameBuilder.setFamilyName(contact.getName().getFamily().get()); + if (contact.getName().getGiven().isPresent()) nameBuilder.setGivenName(contact.getName().getGiven().get()); + if (contact.getName().getMiddle().isPresent()) nameBuilder.setMiddleName(contact.getName().getMiddle().get()); + if (contact.getName().getPrefix().isPresent()) nameBuilder.setPrefix(contact.getName().getPrefix().get()); + if (contact.getName().getSuffix().isPresent()) nameBuilder.setSuffix(contact.getName().getSuffix().get()); + if (contact.getName().getDisplay().isPresent()) nameBuilder.setDisplayName(contact.getName().getDisplay().get()); + + DataMessage.Contact.Builder contactBuilder = DataMessage.Contact.newBuilder() + .setName(nameBuilder); + + if (contact.getAddress().isPresent()) { + for (SharedContact.PostalAddress address : contact.getAddress().get()) { + DataMessage.Contact.PostalAddress.Builder addressBuilder = DataMessage.Contact.PostalAddress.newBuilder(); + + switch (address.getType()) { + case HOME: addressBuilder.setType(DataMessage.Contact.PostalAddress.Type.HOME); break; + case WORK: addressBuilder.setType(DataMessage.Contact.PostalAddress.Type.WORK); break; + case CUSTOM: addressBuilder.setType(DataMessage.Contact.PostalAddress.Type.CUSTOM); break; + default: throw new AssertionError("Unknown type: " + address.getType()); + } + + if (address.getCity().isPresent()) addressBuilder.setCity(address.getCity().get()); + if (address.getCountry().isPresent()) addressBuilder.setCountry(address.getCountry().get()); + if (address.getLabel().isPresent()) addressBuilder.setLabel(address.getLabel().get()); + if (address.getNeighborhood().isPresent()) addressBuilder.setNeighborhood(address.getNeighborhood().get()); + if (address.getPobox().isPresent()) addressBuilder.setPobox(address.getPobox().get()); + if (address.getPostcode().isPresent()) addressBuilder.setPostcode(address.getPostcode().get()); + if (address.getRegion().isPresent()) addressBuilder.setRegion(address.getRegion().get()); + if (address.getStreet().isPresent()) addressBuilder.setStreet(address.getStreet().get()); + + contactBuilder.addAddress(addressBuilder); + } + } + + if (contact.getEmail().isPresent()) { + for (SharedContact.Email email : contact.getEmail().get()) { + DataMessage.Contact.Email.Builder emailBuilder = DataMessage.Contact.Email.newBuilder() + .setValue(email.getValue()); + + switch (email.getType()) { + case HOME: emailBuilder.setType(DataMessage.Contact.Email.Type.HOME); break; + case WORK: emailBuilder.setType(DataMessage.Contact.Email.Type.WORK); break; + case MOBILE: emailBuilder.setType(DataMessage.Contact.Email.Type.MOBILE); break; + case CUSTOM: emailBuilder.setType(DataMessage.Contact.Email.Type.CUSTOM); break; + default: throw new AssertionError("Unknown type: " + email.getType()); + } + + if (email.getLabel().isPresent()) emailBuilder.setLabel(email.getLabel().get()); + + contactBuilder.addEmail(emailBuilder); + } + } + + if (contact.getPhone().isPresent()) { + for (SharedContact.Phone phone : contact.getPhone().get()) { + DataMessage.Contact.Phone.Builder phoneBuilder = DataMessage.Contact.Phone.newBuilder() + .setValue(phone.getValue()); + + switch (phone.getType()) { + case HOME: phoneBuilder.setType(DataMessage.Contact.Phone.Type.HOME); break; + case WORK: phoneBuilder.setType(DataMessage.Contact.Phone.Type.WORK); break; + case MOBILE: phoneBuilder.setType(DataMessage.Contact.Phone.Type.MOBILE); break; + case CUSTOM: phoneBuilder.setType(DataMessage.Contact.Phone.Type.CUSTOM); break; + default: throw new AssertionError("Unknown type: " + phone.getType()); + } + + if (phone.getLabel().isPresent()) phoneBuilder.setLabel(phone.getLabel().get()); + + contactBuilder.addNumber(phoneBuilder); + } + } + + if (contact.getAvatar().isPresent()) { + AttachmentPointer pointer = contact.getAvatar().get().getAttachment().isStream() ? createAttachmentPointer(contact.getAvatar().get().getAttachment().asStream()) + : createAttachmentPointer(contact.getAvatar().get().getAttachment().asPointer()); + contactBuilder.setAvatar(DataMessage.Contact.Avatar.newBuilder() + .setAvatar(pointer) + .setIsProfile(contact.getAvatar().get().isProfile())); + } + + if (contact.getOrganization().isPresent()) { + contactBuilder.setOrganization(contact.getOrganization().get()); + } + + results.add(contactBuilder.build()); + } + + return results; + } + + private List sendMessage(List recipients, + List> unidentifiedAccess, + long timestamp, + byte[] content, + boolean online) + throws IOException + { + List results = new LinkedList<>(); + Iterator recipientIterator = recipients.iterator(); + Iterator> unidentifiedAccessIterator = unidentifiedAccess.iterator(); + + while (recipientIterator.hasNext()) { + SignalServiceAddress recipient = recipientIterator.next(); + + try { + SendMessageResult result = sendMessage(recipient, unidentifiedAccessIterator.next(), timestamp, content, online); + results.add(result); + } catch (UntrustedIdentityException e) { + Log.w(TAG, e); + results.add(SendMessageResult.identityFailure(recipient, e.getIdentityKey())); + } catch (UnregisteredUserException e) { + Log.w(TAG, e); + results.add(SendMessageResult.unregisteredFailure(recipient)); + } catch (PushNetworkException e) { + Log.w(TAG, e); + results.add(SendMessageResult.networkFailure(recipient)); + } + } + + return results; + } + + private SendMessageResult sendMessage(SignalServiceAddress recipient, + Optional unidentifiedAccess, + long timestamp, + byte[] content, + boolean online) + throws UntrustedIdentityException, IOException + { + for (int i=0;i<4;i++) { + try { + OutgoingPushMessageList messages = getEncryptedMessages(socket, recipient, unidentifiedAccess, timestamp, content, online); + Optional pipe = this.pipe.get(); + Optional unidentifiedPipe = this.unidentifiedPipe.get(); + + if (pipe.isPresent() && !unidentifiedAccess.isPresent()) { + try { + Log.w(TAG, "Transmitting over pipe..."); + SendMessageResponse response = pipe.get().send(messages, Optional.absent()); + return SendMessageResult.success(recipient, false, response.getNeedsSync() || isMultiDevice.get()); + } catch (IOException e) { + Log.w(TAG, e); + Log.w(TAG, "Falling back to new connection..."); + } + } else if (unidentifiedPipe.isPresent() && unidentifiedAccess.isPresent()) { + try { + Log.w(TAG, "Transmitting over unidentified pipe..."); + SendMessageResponse response = unidentifiedPipe.get().send(messages, unidentifiedAccess); + return SendMessageResult.success(recipient, true, response.getNeedsSync() || isMultiDevice.get()); + } catch (IOException e) { + Log.w(TAG, e); + Log.w(TAG, "Falling back to new connection..."); + } + } + + Log.w(TAG, "Not transmitting over pipe..."); + SendMessageResponse response = socket.sendMessage(messages, unidentifiedAccess); + return SendMessageResult.success(recipient, unidentifiedAccess.isPresent(), response.getNeedsSync() || isMultiDevice.get()); + + } catch (InvalidKeyException ike) { + Log.w(TAG, ike); + unidentifiedAccess = Optional.absent(); + } catch (AuthorizationFailedException afe) { + Log.w(TAG, afe); + if (unidentifiedAccess.isPresent()) { + unidentifiedAccess = Optional.absent(); + } else { + throw afe; + } + } catch (MismatchedDevicesException mde) { + Log.w(TAG, mde); + handleMismatchedDevices(socket, recipient, mde.getMismatchedDevices()); + } catch (StaleDevicesException ste) { + Log.w(TAG, ste); + handleStaleDevices(recipient, ste.getStaleDevices()); + } + } + + throw new IOException("Failed to resolve conflicts after 3 attempts!"); + } + + private List createAttachmentPointers(Optional> attachments) throws IOException { + List pointers = new LinkedList<>(); + + if (!attachments.isPresent() || attachments.get().isEmpty()) { + Log.w(TAG, "No attachments present..."); + return pointers; + } + + for (SignalServiceAttachment attachment : attachments.get()) { + if (attachment.isStream()) { + Log.w(TAG, "Found attachment, creating pointer..."); + pointers.add(createAttachmentPointer(attachment.asStream())); + } else if (attachment.isPointer()) { + Log.w(TAG, "Including existing attachment pointer..."); + pointers.add(createAttachmentPointer(attachment.asPointer())); + } + } + + return pointers; + } + + private AttachmentPointer createAttachmentPointer(SignalServiceAttachmentPointer attachment) { + AttachmentPointer.Builder builder = AttachmentPointer.newBuilder() + .setContentType(attachment.getContentType()) + .setId(attachment.getId()) + .setKey(ByteString.copyFrom(attachment.getKey())) + .setDigest(ByteString.copyFrom(attachment.getDigest().get())) + .setSize(attachment.getSize().get()); + + if (attachment.getFileName().isPresent()) { + builder.setFileName(attachment.getFileName().get()); + } + + if (attachment.getPreview().isPresent()) { + builder.setThumbnail(ByteString.copyFrom(attachment.getPreview().get())); + } + + if (attachment.getWidth() > 0) { + builder.setWidth(attachment.getWidth()); + } + + if (attachment.getHeight() > 0) { + builder.setHeight(attachment.getHeight()); + } + + if (attachment.getVoiceNote()) { + builder.setFlags(AttachmentPointer.Flags.VOICE_MESSAGE_VALUE); + } + + if (attachment.getCaption().isPresent()) { + builder.setCaption(attachment.getCaption().get()); + } + + if (attachment.getBlurHash().isPresent()) { + builder.setBlurHash(attachment.getBlurHash().get()); + } + + return builder.build(); + } + + private AttachmentPointer createAttachmentPointer(SignalServiceAttachmentStream attachment) + throws IOException + { + SignalServiceAttachmentPointer pointer = uploadAttachment(attachment); + return createAttachmentPointer(pointer); + } + + + private OutgoingPushMessageList getEncryptedMessages(PushServiceSocket socket, + SignalServiceAddress recipient, + Optional unidentifiedAccess, + long timestamp, + byte[] plaintext, + boolean online) + throws IOException, InvalidKeyException, UntrustedIdentityException + { + List messages = new LinkedList<>(); + + if (!recipient.matches(localAddress) || unidentifiedAccess.isPresent()) { + messages.add(getEncryptedMessage(socket, recipient, unidentifiedAccess, SignalServiceAddress.DEFAULT_DEVICE_ID, plaintext)); + } + + for (int deviceId : store.getSubDeviceSessions(recipient.getIdentifier())) { + if (store.containsSession(new SignalProtocolAddress(recipient.getIdentifier(), deviceId))) { + messages.add(getEncryptedMessage(socket, recipient, unidentifiedAccess, deviceId, plaintext)); + } + } + + return new OutgoingPushMessageList(recipient.getIdentifier(), timestamp, messages, online); + } + + private OutgoingPushMessage getEncryptedMessage(PushServiceSocket socket, + SignalServiceAddress recipient, + Optional unidentifiedAccess, + int deviceId, + byte[] plaintext) + throws IOException, InvalidKeyException, UntrustedIdentityException + { + SignalProtocolAddress signalProtocolAddress = new SignalProtocolAddress(recipient.getIdentifier(), deviceId); + SignalServiceCipher cipher = new SignalServiceCipher(localAddress, store, null); + + if (!store.containsSession(signalProtocolAddress)) { + try { + List preKeys = socket.getPreKeys(recipient, unidentifiedAccess, deviceId); + + for (PreKeyBundle preKey : preKeys) { + try { + SignalProtocolAddress preKeyAddress = new SignalProtocolAddress(recipient.getIdentifier(), preKey.getDeviceId()); + SessionBuilder sessionBuilder = new SessionBuilder(store, preKeyAddress); + sessionBuilder.process(preKey); + } catch (org.whispersystems.libsignal.UntrustedIdentityException e) { + throw new UntrustedIdentityException("Untrusted identity key!", recipient.getIdentifier(), preKey.getIdentityKey()); + } + } + + if (eventListener.isPresent()) { + eventListener.get().onSecurityEvent(recipient); + } + } catch (InvalidKeyException e) { + throw new IOException(e); + } + } + + try { + return cipher.encrypt(signalProtocolAddress, unidentifiedAccess, plaintext); + } catch (org.whispersystems.libsignal.UntrustedIdentityException e) { + throw new UntrustedIdentityException("Untrusted on send", recipient.getIdentifier(), e.getUntrustedIdentity()); + } + } + + private void handleMismatchedDevices(PushServiceSocket socket, SignalServiceAddress recipient, + MismatchedDevices mismatchedDevices) + throws IOException, UntrustedIdentityException + { + try { + for (int extraDeviceId : mismatchedDevices.getExtraDevices()) { + if (recipient.getUuid().isPresent()) { + store.deleteSession(new SignalProtocolAddress(recipient.getUuid().get().toString(), extraDeviceId)); + } + if (recipient.getNumber().isPresent()) { + store.deleteSession(new SignalProtocolAddress(recipient.getNumber().get(), extraDeviceId)); + } + } + + for (int missingDeviceId : mismatchedDevices.getMissingDevices()) { + PreKeyBundle preKey = socket.getPreKey(recipient, missingDeviceId); + + try { + SessionBuilder sessionBuilder = new SessionBuilder(store, new SignalProtocolAddress(recipient.getIdentifier(), missingDeviceId)); + sessionBuilder.process(preKey); + } catch (org.whispersystems.libsignal.UntrustedIdentityException e) { + throw new UntrustedIdentityException("Untrusted identity key!", recipient.getIdentifier(), preKey.getIdentityKey()); + } + } + } catch (InvalidKeyException e) { + throw new IOException(e); + } + } + + private void handleStaleDevices(SignalServiceAddress recipient, StaleDevices staleDevices) { + for (int staleDeviceId : staleDevices.getStaleDevices()) { + if (recipient.getUuid().isPresent()) { + store.deleteSession(new SignalProtocolAddress(recipient.getUuid().get().toString(), staleDeviceId)); + } + if (recipient.getNumber().isPresent()) { + store.deleteSession(new SignalProtocolAddress(recipient.getNumber().get(), staleDeviceId)); + } + } + } + + private Optional getTargetUnidentifiedAccess(Optional unidentifiedAccess) { + if (unidentifiedAccess.isPresent()) { + return unidentifiedAccess.get().getTargetUnidentifiedAccess(); + } + + return Optional.absent(); + } + + private List> getTargetUnidentifiedAccess(List> unidentifiedAccess) { + List> results = new LinkedList<>(); + + for (Optional item : unidentifiedAccess) { + if (item.isPresent()) results.add(item.get().getTargetUnidentifiedAccess()); + else results.add(Optional.absent()); + } + + return results; + } + + public static interface EventListener { + public void onSecurityEvent(SignalServiceAddress address); + } + +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/crypto/AttachmentCipherInputStream.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/crypto/AttachmentCipherInputStream.java new file mode 100644 index 000000000..0be576153 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/crypto/AttachmentCipherInputStream.java @@ -0,0 +1,271 @@ +/* + * Copyright (C) 2014-2017 Open Whisper Systems + * + * Licensed according to the LICENSE file in this repository. + */ + +package org.whispersystems.signalservice.api.crypto; + +import org.whispersystems.libsignal.InvalidMacException; +import org.whispersystems.libsignal.InvalidMessageException; +import org.whispersystems.libsignal.kdf.HKDFv3; +import org.whispersystems.signalservice.internal.util.ContentLengthInputStream; +import org.whispersystems.signalservice.internal.util.Util; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FilterInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.security.InvalidAlgorithmParameterException; +import java.security.InvalidKeyException; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Arrays; + +import javax.crypto.BadPaddingException; +import javax.crypto.Cipher; +import javax.crypto.IllegalBlockSizeException; +import javax.crypto.Mac; +import javax.crypto.NoSuchPaddingException; +import javax.crypto.ShortBufferException; +import javax.crypto.spec.IvParameterSpec; +import javax.crypto.spec.SecretKeySpec; + +/** + * Class for streaming an encrypted push attachment off disk. + * + * @author Moxie Marlinspike + */ + +public class AttachmentCipherInputStream extends FilterInputStream { + + private static final int BLOCK_SIZE = 16; + private static final int CIPHER_KEY_SIZE = 32; + private static final int MAC_KEY_SIZE = 32; + + private Cipher cipher; + private boolean done; + private long totalDataSize; + private long totalRead; + private byte[] overflowBuffer; + + public static InputStream createForAttachment(File file, long plaintextLength, byte[] combinedKeyMaterial, byte[] digest) + throws InvalidMessageException, IOException + { + try { + byte[][] parts = Util.split(combinedKeyMaterial, CIPHER_KEY_SIZE, MAC_KEY_SIZE); + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(parts[1], "HmacSHA256")); + + if (file.length() <= BLOCK_SIZE + mac.getMacLength()) { + throw new InvalidMessageException("Message shorter than crypto overhead!"); + } + + if (digest == null) { + throw new InvalidMacException("Missing digest!"); + } + + try (FileInputStream fin = new FileInputStream(file)) { + verifyMac(fin, file.length(), mac, digest); + } + + InputStream inputStream = new AttachmentCipherInputStream(new FileInputStream(file), parts[0], file.length() - BLOCK_SIZE - mac.getMacLength()); + + if (plaintextLength != 0) { + inputStream = new ContentLengthInputStream(inputStream, plaintextLength); + } + + return inputStream; + } catch (NoSuchAlgorithmException | InvalidKeyException e) { + throw new AssertionError(e); + } catch (InvalidMacException e) { + throw new InvalidMessageException(e); + } + } + + public static InputStream createForStickerData(byte[] data, byte[] packKey) + throws InvalidMessageException, IOException + { + try { + byte[] combinedKeyMaterial = new HKDFv3().deriveSecrets(packKey, "Sticker Pack".getBytes(), 64); + byte[][] parts = Util.split(combinedKeyMaterial, CIPHER_KEY_SIZE, MAC_KEY_SIZE); + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(parts[1], "HmacSHA256")); + + if (data.length <= BLOCK_SIZE + mac.getMacLength()) { + throw new InvalidMessageException("Message shorter than crypto overhead!"); + } + + try (InputStream inputStream = new ByteArrayInputStream(data)) { + verifyMac(inputStream, data.length, mac, null); + } + + return new AttachmentCipherInputStream(new ByteArrayInputStream(data), parts[0], data.length - BLOCK_SIZE - mac.getMacLength()); + } catch (NoSuchAlgorithmException | InvalidKeyException e) { + throw new AssertionError(e); + } catch (InvalidMacException e) { + throw new InvalidMessageException(e); + } + } + + private AttachmentCipherInputStream(InputStream inputStream, byte[] cipherKey, long totalDataSize) + throws IOException + { + super(inputStream); + + try { + byte[] iv = new byte[BLOCK_SIZE]; + readFully(iv); + + this.cipher = Cipher.getInstance("AES/CBC/PKCS5Padding"); + this.cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(cipherKey, "AES"), new IvParameterSpec(iv)); + + this.done = false; + this.totalRead = 0; + this.totalDataSize = totalDataSize; + } catch (NoSuchAlgorithmException | InvalidKeyException | NoSuchPaddingException | InvalidAlgorithmParameterException e) { + throw new AssertionError(e); + } + } + + @Override + public int read(byte[] buffer) throws IOException { + return read(buffer, 0, buffer.length); + } + + @Override + public int read(byte[] buffer, int offset, int length) throws IOException { + if (totalRead != totalDataSize) return readIncremental(buffer, offset, length); + else if (!done) return readFinal(buffer, offset, length); + else return -1; + } + + @Override + public boolean markSupported() { + return false; + } + + @Override + public long skip(long byteCount) throws IOException { + long skipped = 0L; + while (skipped < byteCount) { + byte[] buf = new byte[Math.min(4096, (int)(byteCount-skipped))]; + int read = read(buf); + + skipped += read; + } + + return skipped; + } + + private int readFinal(byte[] buffer, int offset, int length) throws IOException { + try { + int flourish = cipher.doFinal(buffer, offset); + + done = true; + return flourish; + } catch (IllegalBlockSizeException | BadPaddingException | ShortBufferException e) { + throw new IOException(e); + } + } + + private int readIncremental(byte[] buffer, int offset, int length) throws IOException { + int readLength = 0; + if (null != overflowBuffer) { + if (overflowBuffer.length > length) { + System.arraycopy(overflowBuffer, 0, buffer, offset, length); + overflowBuffer = Arrays.copyOfRange(overflowBuffer, length, overflowBuffer.length); + return length; + } else if (overflowBuffer.length == length) { + System.arraycopy(overflowBuffer, 0, buffer, offset, length); + overflowBuffer = null; + return length; + } else { + System.arraycopy(overflowBuffer, 0, buffer, offset, overflowBuffer.length); + readLength += overflowBuffer.length; + offset += readLength; + length -= readLength; + overflowBuffer = null; + } + } + + if (length + totalRead > totalDataSize) + length = (int)(totalDataSize - totalRead); + + byte[] internalBuffer = new byte[length]; + int read = super.read(internalBuffer, 0, internalBuffer.length <= cipher.getBlockSize() ? internalBuffer.length : internalBuffer.length - cipher.getBlockSize()); + totalRead += read; + + try { + int outputLen = cipher.getOutputSize(read); + + if (outputLen <= length) { + readLength += cipher.update(internalBuffer, 0, read, buffer, offset); + return readLength; + } + + byte[] transientBuffer = new byte[outputLen]; + outputLen = cipher.update(internalBuffer, 0, read, transientBuffer, 0); + if (outputLen <= length) { + System.arraycopy(transientBuffer, 0, buffer, offset, outputLen); + readLength += outputLen; + } else { + System.arraycopy(transientBuffer, 0, buffer, offset, length); + overflowBuffer = Arrays.copyOfRange(transientBuffer, length, outputLen); + readLength += length; + } + return readLength; + } catch (ShortBufferException e) { + throw new AssertionError(e); + } + } + + private static void verifyMac(InputStream inputStream, long length, Mac mac, byte[] theirDigest) + throws InvalidMacException + { + try { + MessageDigest digest = MessageDigest.getInstance("SHA256"); + int remainingData = Util.toIntExact(length) - mac.getMacLength(); + byte[] buffer = new byte[4096]; + + while (remainingData > 0) { + int read = inputStream.read(buffer, 0, Math.min(buffer.length, remainingData)); + mac.update(buffer, 0, read); + digest.update(buffer, 0, read); + remainingData -= read; + } + + byte[] ourMac = mac.doFinal(); + byte[] theirMac = new byte[mac.getMacLength()]; + Util.readFully(inputStream, theirMac); + + if (!MessageDigest.isEqual(ourMac, theirMac)) { + throw new InvalidMacException("MAC doesn't match!"); + } + + byte[] ourDigest = digest.digest(theirMac); + + if (theirDigest != null && !MessageDigest.isEqual(ourDigest, theirDigest)) { + throw new InvalidMacException("Digest doesn't match!"); + } + + } catch (IOException | ArithmeticException e1) { + throw new InvalidMacException(e1); + } catch (NoSuchAlgorithmException e) { + throw new AssertionError(e); + } + } + + private void readFully(byte[] buffer) throws IOException { + int offset = 0; + + for (;;) { + int read = super.read(buffer, offset, buffer.length - offset); + + if (read + offset < buffer.length) offset += read; + else return; + } + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/crypto/AttachmentCipherOutputStream.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/crypto/AttachmentCipherOutputStream.java new file mode 100644 index 000000000..3e89b4046 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/crypto/AttachmentCipherOutputStream.java @@ -0,0 +1,102 @@ +/* + * Copyright (C) 2014-2017 Open Whisper Systems + * + * Licensed according to the LICENSE file in this repository. + */ + +package org.whispersystems.signalservice.api.crypto; + +import org.whispersystems.signalservice.internal.util.Util; + +import java.io.IOException; +import java.io.OutputStream; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; + +import javax.crypto.BadPaddingException; +import javax.crypto.Cipher; +import javax.crypto.IllegalBlockSizeException; +import javax.crypto.Mac; +import javax.crypto.NoSuchPaddingException; +import javax.crypto.spec.SecretKeySpec; + +public class AttachmentCipherOutputStream extends DigestingOutputStream { + + private final Cipher cipher; + private final Mac mac; + + public AttachmentCipherOutputStream(byte[] combinedKeyMaterial, + OutputStream outputStream) + throws IOException + { + super(outputStream); + try { + this.cipher = initializeCipher(); + this.mac = initializeMac(); + byte[][] keyParts = Util.split(combinedKeyMaterial, 32, 32); + + this.cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(keyParts[0], "AES")); + this.mac.init(new SecretKeySpec(keyParts[1], "HmacSHA256")); + + mac.update(cipher.getIV()); + super.write(cipher.getIV()); + } catch (InvalidKeyException e) { + throw new AssertionError(e); + } + } + + @Override + public void write(byte[] buffer) throws IOException { + write(buffer, 0, buffer.length); + } + + @Override + public void write(byte[] buffer, int offset, int length) throws IOException { + byte[] ciphertext = cipher.update(buffer, offset, length); + + if (ciphertext != null) { + mac.update(ciphertext); + super.write(ciphertext); + } + } + + @Override + public void write(int b) { + throw new AssertionError("NYI"); + } + + @Override + public void flush() throws IOException { + try { + byte[] ciphertext = cipher.doFinal(); + byte[] auth = mac.doFinal(ciphertext); + + super.write(ciphertext); + super.write(auth); + + super.flush(); + } catch (IllegalBlockSizeException | BadPaddingException e) { + throw new AssertionError(e); + } + } + + public static long getCiphertextLength(long plaintextLength) { + return 16 + (((plaintextLength / 16) +1) * 16) + 32; + } + + private Mac initializeMac() { + try { + return Mac.getInstance("HmacSHA256"); + } catch (NoSuchAlgorithmException e) { + throw new AssertionError(e); + } + } + + private Cipher initializeCipher() { + try { + return Cipher.getInstance("AES/CBC/PKCS5Padding"); + } catch (NoSuchAlgorithmException | NoSuchPaddingException e) { + throw new AssertionError(e); + } + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/crypto/DigestingOutputStream.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/crypto/DigestingOutputStream.java new file mode 100644 index 000000000..555c1e855 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/crypto/DigestingOutputStream.java @@ -0,0 +1,55 @@ +package org.whispersystems.signalservice.api.crypto; + + +import java.io.FilterOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + +public abstract class DigestingOutputStream extends FilterOutputStream { + + private final MessageDigest runningDigest; + + private byte[] digest; + + public DigestingOutputStream(OutputStream outputStream) { + super(outputStream); + + try { + this.runningDigest = MessageDigest.getInstance("SHA256"); + } catch (NoSuchAlgorithmException e) { + throw new AssertionError(e); + } + } + + @Override + public void write(byte[] buffer) throws IOException { + runningDigest.update(buffer, 0, buffer.length); + out.write(buffer, 0, buffer.length); + } + + public void write(byte[] buffer, int offset, int length) throws IOException { + runningDigest.update(buffer, offset, length); + out.write(buffer, offset, length); + } + + public void write(int b) throws IOException { + runningDigest.update((byte)b); + out.write(b); + } + + public void flush() throws IOException { + digest = runningDigest.digest(); + out.flush(); + } + + public void close() throws IOException { + out.close(); + } + + public byte[] getTransmittedDigest() { + return digest; + } + +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/crypto/InvalidCiphertextException.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/crypto/InvalidCiphertextException.java new file mode 100644 index 000000000..9ea11dad1 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/crypto/InvalidCiphertextException.java @@ -0,0 +1,11 @@ +package org.whispersystems.signalservice.api.crypto; + +public class InvalidCiphertextException extends Exception { + public InvalidCiphertextException(Exception nested) { + super(nested); + } + + public InvalidCiphertextException(String s) { + super(s); + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/crypto/ProfileCipher.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/crypto/ProfileCipher.java new file mode 100644 index 000000000..dc3365f28 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/crypto/ProfileCipher.java @@ -0,0 +1,101 @@ +package org.whispersystems.signalservice.api.crypto; + + +import org.whispersystems.libsignal.util.ByteUtil; +import org.whispersystems.signalservice.internal.util.Util; + +import java.security.InvalidAlgorithmParameterException; +import java.security.InvalidKeyException; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + +import javax.crypto.BadPaddingException; +import javax.crypto.Cipher; +import javax.crypto.IllegalBlockSizeException; +import javax.crypto.Mac; +import javax.crypto.NoSuchPaddingException; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; + +public class ProfileCipher { + + public static final int NAME_PADDED_LENGTH = 26; + + private final byte[] key; + + public ProfileCipher(byte[] key) { + this.key = key; + } + + public byte[] encryptName(byte[] input, int paddedLength) { + try { + byte[] inputPadded = new byte[paddedLength]; + + if (input.length > inputPadded.length) { + throw new IllegalArgumentException("Input is too long: " + new String(input)); + } + + System.arraycopy(input, 0, inputPadded, 0, input.length); + + byte[] nonce = Util.getSecretBytes(12); + + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); + cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(key, "AES"), new GCMParameterSpec(128, nonce)); + + return ByteUtil.combine(nonce, cipher.doFinal(inputPadded)); + } catch (NoSuchAlgorithmException | InvalidAlgorithmParameterException | BadPaddingException | NoSuchPaddingException | IllegalBlockSizeException | InvalidKeyException e) { + throw new AssertionError(e); + } + } + + public byte[] decryptName(byte[] input) throws InvalidCiphertextException { + try { + if (input.length < 12 + 16 + 1) { + throw new InvalidCiphertextException("Too short: " + input.length); + } + + byte[] nonce = new byte[12]; + System.arraycopy(input, 0, nonce, 0, nonce.length); + + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); + cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(key, "AES"), new GCMParameterSpec(128, nonce)); + + byte[] paddedPlaintext = cipher.doFinal(input, nonce.length, input.length - nonce.length); + int plaintextLength = 0; + + for (int i=paddedPlaintext.length-1;i>=0;i--) { + if (paddedPlaintext[i] != (byte)0x00) { + plaintextLength = i + 1; + break; + } + } + + byte[] plaintext = new byte[plaintextLength]; + System.arraycopy(paddedPlaintext, 0, plaintext, 0, plaintextLength); + + return plaintext; + } catch (NoSuchAlgorithmException | InvalidAlgorithmParameterException | NoSuchPaddingException | IllegalBlockSizeException e) { + throw new AssertionError(e); + } catch (InvalidKeyException | BadPaddingException e) { + throw new InvalidCiphertextException(e); + } + } + + public boolean verifyUnidentifiedAccess(byte[] theirUnidentifiedAccessVerifier) { + try { + if (theirUnidentifiedAccessVerifier == null || theirUnidentifiedAccessVerifier.length == 0) return false; + + byte[] unidentifiedAccessKey = UnidentifiedAccess.deriveAccessKeyFrom(key); + + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(unidentifiedAccessKey, "HmacSHA256")); + + byte[] ourUnidentifiedAccessVerifier = mac.doFinal(new byte[32]); + + return MessageDigest.isEqual(theirUnidentifiedAccessVerifier, ourUnidentifiedAccessVerifier); + } catch (NoSuchAlgorithmException | InvalidKeyException e) { + throw new AssertionError(e); + } + } + +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/crypto/ProfileCipherInputStream.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/crypto/ProfileCipherInputStream.java new file mode 100644 index 000000000..1c8f0d097 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/crypto/ProfileCipherInputStream.java @@ -0,0 +1,83 @@ +package org.whispersystems.signalservice.api.crypto; + + +import org.whispersystems.signalservice.internal.util.Util; + +import java.io.FilterInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.security.InvalidAlgorithmParameterException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; + +import javax.crypto.BadPaddingException; +import javax.crypto.Cipher; +import javax.crypto.IllegalBlockSizeException; +import javax.crypto.NoSuchPaddingException; +import javax.crypto.ShortBufferException; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; + +public class ProfileCipherInputStream extends FilterInputStream { + + private final Cipher cipher; + + private boolean finished = false; + + public ProfileCipherInputStream(InputStream in, byte[] key) throws IOException { + super(in); + + try { + this.cipher = Cipher.getInstance("AES/GCM/NoPadding"); + + byte[] nonce = new byte[12]; + Util.readFully(in, nonce); + + this.cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(key, "AES"), new GCMParameterSpec(128, nonce)); + } catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidAlgorithmParameterException e) { + throw new AssertionError(e); + } catch (InvalidKeyException e) { + throw new IOException(e); + } + } + + @Override + public int read() { + throw new AssertionError("Not supported!"); + } + + @Override + public int read(byte[] input) throws IOException { + return read(input, 0, input.length); + } + + @Override + public int read(byte[] output, int outputOffset, int outputLength) throws IOException { + if (finished) return -1; + + try { + byte[] ciphertext = new byte[outputLength / 2]; + int read = in.read(ciphertext, 0, ciphertext.length); + + if (read == -1) { + if (cipher.getOutputSize(0) > outputLength) { + throw new AssertionError("Need: " + cipher.getOutputSize(0) + " but only have: " + outputLength); + } + + finished = true; + return cipher.doFinal(output, outputOffset); + } else { + if (cipher.getOutputSize(read) > outputLength) { + throw new AssertionError("Need: " + cipher.getOutputSize(read) + " but only have: " + outputLength); + } + + return cipher.update(ciphertext, 0, read, output, outputOffset); + } + } catch (IllegalBlockSizeException | ShortBufferException e) { + throw new AssertionError(e); + } catch (BadPaddingException e) { + throw new IOException(e); + } + } + +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/crypto/ProfileCipherOutputStream.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/crypto/ProfileCipherOutputStream.java new file mode 100644 index 000000000..39bb8bc08 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/crypto/ProfileCipherOutputStream.java @@ -0,0 +1,78 @@ +package org.whispersystems.signalservice.api.crypto; + +import java.io.IOException; +import java.io.OutputStream; +import java.security.InvalidAlgorithmParameterException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; + +import javax.crypto.BadPaddingException; +import javax.crypto.Cipher; +import javax.crypto.IllegalBlockSizeException; +import javax.crypto.NoSuchPaddingException; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; + +public class ProfileCipherOutputStream extends DigestingOutputStream { + + private final Cipher cipher; + + public ProfileCipherOutputStream(OutputStream out, byte[] key) throws IOException { + super(out); + try { + this.cipher = Cipher.getInstance("AES/GCM/NoPadding"); + + byte[] nonce = generateNonce(); + this.cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(key, "AES"), new GCMParameterSpec(128, nonce)); + + super.write(nonce, 0, nonce.length); + } catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidAlgorithmParameterException e) { + throw new AssertionError(e); + } catch (InvalidKeyException e) { + throw new IOException(e); + } + } + + @Override + public void write(byte[] buffer) throws IOException { + write(buffer, 0, buffer.length); + } + + @Override + public void write(byte[] buffer, int offset, int length) throws IOException { + byte[] output = cipher.update(buffer, offset, length); + super.write(output); + } + + @Override + public void write(int b) throws IOException { + byte[] input = new byte[1]; + input[0] = (byte)b; + + byte[] output = cipher.update(input); + super.write(output); + } + + @Override + public void flush() throws IOException { + try { + byte[] output = cipher.doFinal(); + + super.write(output); + super.flush(); + } catch (BadPaddingException | IllegalBlockSizeException e) { + throw new AssertionError(e); + } + } + + private byte[] generateNonce() { + byte[] nonce = new byte[12]; + new SecureRandom().nextBytes(nonce); + return nonce; + } + + public static long getCiphertextLength(long plaintextLength) { + return 12 + 16 + plaintextLength; + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/crypto/SignalServiceCipher.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/crypto/SignalServiceCipher.java new file mode 100644 index 000000000..d7cc397c2 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/crypto/SignalServiceCipher.java @@ -0,0 +1,841 @@ +/* + * Copyright (C) 2014-2016 Open Whisper Systems + * + * Licensed according to the LICENSE file in this repository. + */ + +package org.whispersystems.signalservice.api.crypto; + +import com.google.protobuf.ByteString; +import com.google.protobuf.InvalidProtocolBufferException; + +import org.signal.libsignal.metadata.InvalidMetadataMessageException; +import org.signal.libsignal.metadata.InvalidMetadataVersionException; +import org.signal.libsignal.metadata.ProtocolDuplicateMessageException; +import org.signal.libsignal.metadata.ProtocolInvalidKeyException; +import org.signal.libsignal.metadata.ProtocolInvalidKeyIdException; +import org.signal.libsignal.metadata.ProtocolInvalidMessageException; +import org.signal.libsignal.metadata.ProtocolInvalidVersionException; +import org.signal.libsignal.metadata.ProtocolLegacyMessageException; +import org.signal.libsignal.metadata.ProtocolNoSessionException; +import org.signal.libsignal.metadata.ProtocolUntrustedIdentityException; +import org.signal.libsignal.metadata.SealedSessionCipher; +import org.signal.libsignal.metadata.SealedSessionCipher.DecryptionResult; +import org.signal.libsignal.metadata.SelfSendException; +import org.signal.libsignal.metadata.certificate.CertificateValidator; +import org.whispersystems.libsignal.DuplicateMessageException; +import org.whispersystems.libsignal.IdentityKey; +import org.whispersystems.libsignal.InvalidKeyException; +import org.whispersystems.libsignal.InvalidKeyIdException; +import org.whispersystems.libsignal.InvalidMessageException; +import org.whispersystems.libsignal.InvalidVersionException; +import org.whispersystems.libsignal.LegacyMessageException; +import org.whispersystems.libsignal.NoSessionException; +import org.whispersystems.libsignal.SessionCipher; +import org.whispersystems.libsignal.SignalProtocolAddress; +import org.whispersystems.libsignal.UntrustedIdentityException; +import org.whispersystems.libsignal.logging.Log; +import org.whispersystems.libsignal.protocol.CiphertextMessage; +import org.whispersystems.libsignal.protocol.PreKeySignalMessage; +import org.whispersystems.libsignal.protocol.SignalMessage; +import org.whispersystems.libsignal.state.SignalProtocolStore; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.messages.SignalServiceAttachment; +import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentPointer; +import org.whispersystems.signalservice.api.messages.SignalServiceContent; +import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage; +import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage.Preview; +import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage.Sticker; +import org.whispersystems.signalservice.api.messages.SignalServiceEnvelope; +import org.whispersystems.signalservice.api.messages.SignalServiceGroup; +import org.whispersystems.signalservice.api.messages.SignalServiceReceiptMessage; +import org.whispersystems.signalservice.api.messages.SignalServiceTypingMessage; +import org.whispersystems.signalservice.api.messages.calls.AnswerMessage; +import org.whispersystems.signalservice.api.messages.calls.BusyMessage; +import org.whispersystems.signalservice.api.messages.calls.HangupMessage; +import org.whispersystems.signalservice.api.messages.calls.IceUpdateMessage; +import org.whispersystems.signalservice.api.messages.calls.OfferMessage; +import org.whispersystems.signalservice.api.messages.calls.SignalServiceCallMessage; +import org.whispersystems.signalservice.api.messages.multidevice.BlockedListMessage; +import org.whispersystems.signalservice.api.messages.multidevice.ConfigurationMessage; +import org.whispersystems.signalservice.api.messages.multidevice.ReadMessage; +import org.whispersystems.signalservice.api.messages.multidevice.RequestMessage; +import org.whispersystems.signalservice.api.messages.multidevice.SentTranscriptMessage; +import org.whispersystems.signalservice.api.messages.multidevice.SignalServiceSyncMessage; +import org.whispersystems.signalservice.api.messages.multidevice.StickerPackOperationMessage; +import org.whispersystems.signalservice.api.messages.multidevice.VerifiedMessage; +import org.whispersystems.signalservice.api.messages.multidevice.VerifiedMessage.VerifiedState; +import org.whispersystems.signalservice.api.messages.multidevice.ViewOnceOpenMessage; +import org.whispersystems.signalservice.api.messages.shared.SharedContact; +import org.whispersystems.signalservice.api.push.SignalServiceAddress; +import org.whispersystems.signalservice.api.util.UuidUtil; +import org.whispersystems.signalservice.internal.push.OutgoingPushMessage; +import org.whispersystems.signalservice.internal.push.PushTransportDetails; +import org.whispersystems.signalservice.internal.push.SignalServiceProtos; +import org.whispersystems.signalservice.internal.push.SignalServiceProtos.AttachmentPointer; +import org.whispersystems.signalservice.internal.push.SignalServiceProtos.Content; +import org.whispersystems.signalservice.internal.push.SignalServiceProtos.DataMessage; +import org.whispersystems.signalservice.internal.push.SignalServiceProtos.Envelope.Type; +import org.whispersystems.signalservice.internal.push.SignalServiceProtos.ReceiptMessage; +import org.whispersystems.signalservice.internal.push.SignalServiceProtos.SyncMessage; +import org.whispersystems.signalservice.internal.push.SignalServiceProtos.TypingMessage; +import org.whispersystems.signalservice.internal.push.SignalServiceProtos.Verified; +import org.whispersystems.signalservice.internal.push.UnsupportedDataMessageException; +import org.whispersystems.util.Base64; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; + +import static org.whispersystems.signalservice.internal.push.SignalServiceProtos.CallMessage; +import static org.whispersystems.signalservice.internal.push.SignalServiceProtos.GroupContext.Type.DELIVER; + +/** + * This is used to decrypt received {@link SignalServiceEnvelope}s. + * + * @author Moxie Marlinspike + */ +public class SignalServiceCipher { + + @SuppressWarnings("unused") + private static final String TAG = SignalServiceCipher.class.getSimpleName(); + + private final SignalProtocolStore signalProtocolStore; + private final SignalServiceAddress localAddress; + private final CertificateValidator certificateValidator; + + public SignalServiceCipher(SignalServiceAddress localAddress, + SignalProtocolStore signalProtocolStore, + CertificateValidator certificateValidator) + { + this.signalProtocolStore = signalProtocolStore; + this.localAddress = localAddress; + this.certificateValidator = certificateValidator; + } + + public OutgoingPushMessage encrypt(SignalProtocolAddress destination, + Optional unidentifiedAccess, + byte[] unpaddedMessage) + throws UntrustedIdentityException, InvalidKeyException + { + if (unidentifiedAccess.isPresent()) { + SealedSessionCipher sessionCipher = new SealedSessionCipher(signalProtocolStore, localAddress.getUuid().orNull(), localAddress.getNumber().orNull(), 1); + PushTransportDetails transportDetails = new PushTransportDetails(sessionCipher.getSessionVersion(destination)); + byte[] ciphertext = sessionCipher.encrypt(destination, unidentifiedAccess.get().getUnidentifiedCertificate(), transportDetails.getPaddedMessageBody(unpaddedMessage)); + String body = Base64.encodeBytes(ciphertext); + int remoteRegistrationId = sessionCipher.getRemoteRegistrationId(destination); + + return new OutgoingPushMessage(Type.UNIDENTIFIED_SENDER_VALUE, destination.getDeviceId(), remoteRegistrationId, body); + } else { + SessionCipher sessionCipher = new SessionCipher(signalProtocolStore, destination); + PushTransportDetails transportDetails = new PushTransportDetails(sessionCipher.getSessionVersion()); + CiphertextMessage message = sessionCipher.encrypt(transportDetails.getPaddedMessageBody(unpaddedMessage)); + int remoteRegistrationId = sessionCipher.getRemoteRegistrationId(); + String body = Base64.encodeBytes(message.serialize()); + + int type; + + switch (message.getType()) { + case CiphertextMessage.PREKEY_TYPE: type = Type.PREKEY_BUNDLE_VALUE; break; + case CiphertextMessage.WHISPER_TYPE: type = Type.CIPHERTEXT_VALUE; break; + default: throw new AssertionError("Bad type: " + message.getType()); + } + + return new OutgoingPushMessage(type, destination.getDeviceId(), remoteRegistrationId, body); + } + } + + /** + * Decrypt a received {@link SignalServiceEnvelope} + * + * @param envelope The received SignalServiceEnvelope + * + * @return a decrypted SignalServiceContent + */ + public SignalServiceContent decrypt(SignalServiceEnvelope envelope) + throws InvalidMetadataMessageException, InvalidMetadataVersionException, + ProtocolInvalidKeyIdException, ProtocolLegacyMessageException, + ProtocolUntrustedIdentityException, ProtocolNoSessionException, + ProtocolInvalidVersionException, ProtocolInvalidMessageException, + ProtocolInvalidKeyException, ProtocolDuplicateMessageException, + SelfSendException, UnsupportedDataMessageException + + { + try { + if (envelope.hasLegacyMessage()) { + Plaintext plaintext = decrypt(envelope, envelope.getLegacyMessage()); + DataMessage message = DataMessage.parseFrom(plaintext.getData()); + return new SignalServiceContent(createSignalServiceMessage(plaintext.getMetadata(), message), + plaintext.getMetadata().getSender(), + plaintext.getMetadata().getSenderDevice(), + plaintext.getMetadata().getTimestamp(), + plaintext.getMetadata().isNeedsReceipt()); + } else if (envelope.hasContent()) { + Plaintext plaintext = decrypt(envelope, envelope.getContent()); + Content message = Content.parseFrom(plaintext.getData()); + + if (message.hasDataMessage()) { + return new SignalServiceContent(createSignalServiceMessage(plaintext.getMetadata(), message.getDataMessage()), + plaintext.getMetadata().getSender(), + plaintext.getMetadata().getSenderDevice(), + plaintext.getMetadata().getTimestamp(), + plaintext.getMetadata().isNeedsReceipt()); + } else if (message.hasSyncMessage() && localAddress.matches(plaintext.getMetadata().getSender())) { + return new SignalServiceContent(createSynchronizeMessage(plaintext.getMetadata(), message.getSyncMessage()), + plaintext.getMetadata().getSender(), + plaintext.getMetadata().getSenderDevice(), + plaintext.getMetadata().getTimestamp(), + plaintext.getMetadata().isNeedsReceipt()); + } else if (message.hasCallMessage()) { + return new SignalServiceContent(createCallMessage(message.getCallMessage()), + plaintext.getMetadata().getSender(), + plaintext.getMetadata().getSenderDevice(), + plaintext.getMetadata().getTimestamp(), + plaintext.getMetadata().isNeedsReceipt()); + } else if (message.hasReceiptMessage()) { + return new SignalServiceContent(createReceiptMessage(plaintext.getMetadata(), message.getReceiptMessage()), + plaintext.getMetadata().getSender(), + plaintext.getMetadata().getSenderDevice(), + plaintext.getMetadata().getTimestamp(), + plaintext.getMetadata().isNeedsReceipt()); + } else if (message.hasTypingMessage()) { + return new SignalServiceContent(createTypingMessage(plaintext.getMetadata(), message.getTypingMessage()), + plaintext.getMetadata().getSender(), + plaintext.getMetadata().getSenderDevice(), + plaintext.getMetadata().getTimestamp(), + false); + } + } + + return null; + } catch (InvalidProtocolBufferException e) { + throw new InvalidMetadataMessageException(e); + } + } + + private Plaintext decrypt(SignalServiceEnvelope envelope, byte[] ciphertext) + throws InvalidMetadataMessageException, InvalidMetadataVersionException, + ProtocolDuplicateMessageException, ProtocolUntrustedIdentityException, + ProtocolLegacyMessageException, ProtocolInvalidKeyException, + ProtocolInvalidVersionException, ProtocolInvalidMessageException, + ProtocolInvalidKeyIdException, ProtocolNoSessionException, + SelfSendException + { + try { + + byte[] paddedMessage; + Metadata metadata; + int sessionVersion; + + if (!envelope.hasSource() && !envelope.isUnidentifiedSender()) { + throw new ProtocolInvalidMessageException(new InvalidMessageException("Non-UD envelope is missing a source!"), null, 0); + } + + if (envelope.isPreKeySignalMessage()) { + SignalProtocolAddress sourceAddress = getPreferredProtocolAddress(signalProtocolStore, envelope.getSourceAddress(), envelope.getSourceDevice()); + SessionCipher sessionCipher = new SessionCipher(signalProtocolStore, sourceAddress); + + paddedMessage = sessionCipher.decrypt(new PreKeySignalMessage(ciphertext)); + metadata = new Metadata(envelope.getSourceAddress(), envelope.getSourceDevice(), envelope.getTimestamp(), false); + sessionVersion = sessionCipher.getSessionVersion(); + } else if (envelope.isSignalMessage()) { + SignalProtocolAddress sourceAddress = getPreferredProtocolAddress(signalProtocolStore, envelope.getSourceAddress(), envelope.getSourceDevice()); + SessionCipher sessionCipher = new SessionCipher(signalProtocolStore, sourceAddress); + + paddedMessage = sessionCipher.decrypt(new SignalMessage(ciphertext)); + metadata = new Metadata(envelope.getSourceAddress(), envelope.getSourceDevice(), envelope.getTimestamp(), false); + sessionVersion = sessionCipher.getSessionVersion(); + } else if (envelope.isUnidentifiedSender()) { + SealedSessionCipher sealedSessionCipher = new SealedSessionCipher(signalProtocolStore, localAddress.getUuid().orNull(), localAddress.getNumber().orNull(), 1); + DecryptionResult result = sealedSessionCipher.decrypt(certificateValidator, ciphertext, envelope.getServerTimestamp()); + SignalServiceAddress resultAddress = new SignalServiceAddress(UuidUtil.parse(result.getSenderUuid().orNull()), result.getSenderE164()); + SignalProtocolAddress protocolAddress = getPreferredProtocolAddress(signalProtocolStore, resultAddress, result.getDeviceId()); + + paddedMessage = result.getPaddedMessage(); + metadata = new Metadata(resultAddress, result.getDeviceId(), envelope.getTimestamp(), true); + sessionVersion = sealedSessionCipher.getSessionVersion(protocolAddress); + } else { + throw new InvalidMetadataMessageException("Unknown type: " + envelope.getType()); + } + + PushTransportDetails transportDetails = new PushTransportDetails(sessionVersion); + byte[] data = transportDetails.getStrippedPaddingMessageBody(paddedMessage); + + return new Plaintext(metadata, data); + } catch (DuplicateMessageException e) { + throw new ProtocolDuplicateMessageException(e, envelope.getSourceIdentifier(), envelope.getSourceDevice()); + } catch (LegacyMessageException e) { + throw new ProtocolLegacyMessageException(e, envelope.getSourceIdentifier(), envelope.getSourceDevice()); + } catch (InvalidMessageException e) { + throw new ProtocolInvalidMessageException(e, envelope.getSourceIdentifier(), envelope.getSourceDevice()); + } catch (InvalidKeyIdException e) { + throw new ProtocolInvalidKeyIdException(e, envelope.getSourceIdentifier(), envelope.getSourceDevice()); + } catch (InvalidKeyException e) { + throw new ProtocolInvalidKeyException(e, envelope.getSourceIdentifier(), envelope.getSourceDevice()); + } catch (UntrustedIdentityException e) { + throw new ProtocolUntrustedIdentityException(e, envelope.getSourceIdentifier(), envelope.getSourceDevice()); + } catch (InvalidVersionException e) { + throw new ProtocolInvalidVersionException(e, envelope.getSourceIdentifier(), envelope.getSourceDevice()); + } catch (NoSessionException e) { + throw new ProtocolNoSessionException(e, envelope.getSourceIdentifier(), envelope.getSourceDevice()); + } + } + + private static SignalProtocolAddress getPreferredProtocolAddress(SignalProtocolStore store, SignalServiceAddress address, int sourceDevice) { + SignalProtocolAddress uuidAddress = address.getUuid().isPresent() ? new SignalProtocolAddress(address.getUuid().get().toString(), sourceDevice) : null; + SignalProtocolAddress e164Address = address.getNumber().isPresent() ? new SignalProtocolAddress(address.getNumber().get(), sourceDevice) : null; + + if (uuidAddress != null && store.containsSession(uuidAddress)) { + return uuidAddress; + } else if (e164Address != null && store.containsSession(e164Address)) { + return e164Address; + } else { + return new SignalProtocolAddress(address.getIdentifier(), sourceDevice); + } + } + + private SignalServiceDataMessage createSignalServiceMessage(Metadata metadata, DataMessage content) + throws ProtocolInvalidMessageException, UnsupportedDataMessageException + { + SignalServiceGroup groupInfo = createGroupInfo(content); + List attachments = new LinkedList<>(); + boolean endSession = ((content.getFlags() & DataMessage.Flags.END_SESSION_VALUE ) != 0); + boolean expirationUpdate = ((content.getFlags() & DataMessage.Flags.EXPIRATION_TIMER_UPDATE_VALUE) != 0); + boolean profileKeyUpdate = ((content.getFlags() & DataMessage.Flags.PROFILE_KEY_UPDATE_VALUE ) != 0); + SignalServiceDataMessage.Quote quote = createQuote(content); + List sharedContacts = createSharedContacts(content); + List previews = createPreviews(content); + Sticker sticker = createSticker(content); + + if (content.getRequiredProtocolVersion() > DataMessage.ProtocolVersion.CURRENT.getNumber()) { + throw new UnsupportedDataMessageException(DataMessage.ProtocolVersion.CURRENT.getNumber(), + content.getRequiredProtocolVersion(), + metadata.getSender().getIdentifier(), + metadata.getSenderDevice(), + Optional.fromNullable(groupInfo)); + } + + for (AttachmentPointer pointer : content.getAttachmentsList()) { + attachments.add(createAttachmentPointer(pointer)); + } + + if (content.hasTimestamp() && content.getTimestamp() != metadata.getTimestamp()) { + throw new ProtocolInvalidMessageException(new InvalidMessageException("Timestamps don't match: " + content.getTimestamp() + " vs " + metadata.getTimestamp()), + metadata.getSender().getIdentifier(), + metadata.getSenderDevice()); + } + + return new SignalServiceDataMessage(metadata.getTimestamp(), + groupInfo, + attachments, + content.getBody(), + endSession, + content.getExpireTimer(), + expirationUpdate, + content.hasProfileKey() ? content.getProfileKey().toByteArray() : null, + profileKeyUpdate, + quote, + sharedContacts, + previews, + sticker, + content.getIsViewOnce()); + } + + private SignalServiceSyncMessage createSynchronizeMessage(Metadata metadata, SyncMessage content) + throws ProtocolInvalidMessageException, ProtocolInvalidKeyException, UnsupportedDataMessageException + { + if (content.hasSent()) { + SyncMessage.Sent sentContent = content.getSent(); + SignalServiceDataMessage dataMessage = createSignalServiceMessage(metadata, sentContent.getMessage()); + Optional address = SignalServiceAddress.isValidAddress(sentContent.getDestinationUuid(), sentContent.getDestinationE164()) + ? Optional.of(new SignalServiceAddress(UuidUtil.parseOrNull(sentContent.getDestinationUuid()), sentContent.getDestinationE164())) + : Optional.absent(); + Map unidentifiedStatuses = new HashMap<>(); + + if (!address.isPresent() && !dataMessage.getGroupInfo().isPresent()) { + throw new ProtocolInvalidMessageException(new InvalidMessageException("SyncMessage missing both destination and group ID!"), null, 0); + } + + for (SyncMessage.Sent.UnidentifiedDeliveryStatus status : sentContent.getUnidentifiedStatusList()) { + if (SignalServiceAddress.isValidAddress(status.getDestinationUuid(), status.getDestinationE164())) { + SignalServiceAddress recipient = new SignalServiceAddress(UuidUtil.parseOrNull(status.getDestinationUuid()), status.getDestinationE164()); + unidentifiedStatuses.put(recipient, status.getUnidentified()); + } else { + Log.w(TAG, "Encountered an invalid UnidentifiedDeliveryStatus in a SentTranscript! Ignoring."); + } + } + + return SignalServiceSyncMessage.forSentTranscript(new SentTranscriptMessage(address, + sentContent.getTimestamp(), + dataMessage, + sentContent.getExpirationStartTimestamp(), + unidentifiedStatuses, + sentContent.getIsRecipientUpdate())); + } + + if (content.hasRequest()) { + return SignalServiceSyncMessage.forRequest(new RequestMessage(content.getRequest())); + } + + if (content.getReadList().size() > 0) { + List readMessages = new LinkedList<>(); + + for (SyncMessage.Read read : content.getReadList()) { + if (SignalServiceAddress.isValidAddress(read.getSenderUuid(), read.getSenderE164())) { + SignalServiceAddress address = new SignalServiceAddress(UuidUtil.parseOrNull(read.getSenderUuid()), read.getSenderE164()); + readMessages.add(new ReadMessage(address, read.getTimestamp())); + } else { + Log.w(TAG, "Encountered an invalid ReadMessage! Ignoring."); + } + } + + return SignalServiceSyncMessage.forRead(readMessages); + } + + if (content.hasViewOnceOpen()) { + if (SignalServiceAddress.isValidAddress(content.getViewOnceOpen().getSenderUuid(), content.getViewOnceOpen().getSenderE164())) { + SignalServiceAddress address = new SignalServiceAddress(UuidUtil.parseOrNull(content.getViewOnceOpen().getSenderUuid()), content.getViewOnceOpen().getSenderE164()); + ViewOnceOpenMessage timerRead = new ViewOnceOpenMessage(address, content.getViewOnceOpen().getTimestamp()); + return SignalServiceSyncMessage.forViewOnceOpen(timerRead); + } else { + throw new ProtocolInvalidMessageException(new InvalidMessageException("ViewOnceOpen message has no sender!"), null, 0); + } + } + + if (content.hasVerified()) { + if (SignalServiceAddress.isValidAddress(content.getVerified().getDestinationUuid(), content.getVerified().getDestinationE164())) { + try { + Verified verified = content.getVerified(); + SignalServiceAddress destination = new SignalServiceAddress(UuidUtil.parseOrNull(verified.getDestinationUuid()), verified.getDestinationE164()); + IdentityKey identityKey = new IdentityKey(verified.getIdentityKey().toByteArray(), 0); + + VerifiedState verifiedState; + + if (verified.getState() == Verified.State.DEFAULT) { + verifiedState = VerifiedState.DEFAULT; + } else if (verified.getState() == Verified.State.VERIFIED) { + verifiedState = VerifiedState.VERIFIED; + } else if (verified.getState() == Verified.State.UNVERIFIED) { + verifiedState = VerifiedState.UNVERIFIED; + } else { + throw new ProtocolInvalidMessageException(new InvalidMessageException("Unknown state: " + verified.getState().getNumber()), + metadata.getSender().getIdentifier(), metadata.getSenderDevice()); + } + + return SignalServiceSyncMessage.forVerified(new VerifiedMessage(destination, identityKey, verifiedState, System.currentTimeMillis())); + } catch (InvalidKeyException e) { + throw new ProtocolInvalidKeyException(e, metadata.getSender().getIdentifier(), metadata.getSenderDevice()); + } + } else { + throw new ProtocolInvalidMessageException(new InvalidMessageException("Verified message has no sender!"), null, 0); + } + } + + if (content.getStickerPackOperationList().size() > 0) { + List operations = new LinkedList<>(); + + for (SyncMessage.StickerPackOperation operation : content.getStickerPackOperationList()) { + byte[] packId = operation.hasPackId() ? operation.getPackId().toByteArray() : null; + byte[] packKey = operation.hasPackKey() ? operation.getPackKey().toByteArray() : null; + StickerPackOperationMessage.Type type = null; + + if (operation.hasType()) { + switch (operation.getType()) { + case INSTALL: type = StickerPackOperationMessage.Type.INSTALL; break; + case REMOVE: type = StickerPackOperationMessage.Type.REMOVE; break; + } + } + operations.add(new StickerPackOperationMessage(packId, packKey, type)); + } + + return SignalServiceSyncMessage.forStickerPackOperations(operations); + } + + if (content.hasBlocked()) { + List numbers = content.getBlocked().getNumbersList(); + List uuids = content.getBlocked().getUuidsList(); + List addresses = new ArrayList<>(numbers.size() + uuids.size()); + List groupIds = new ArrayList<>(content.getBlocked().getGroupIdsList().size()); + + for (String e164 : numbers) { + Optional address = SignalServiceAddress.fromRaw(null, e164); + if (address.isPresent()) { + addresses.add(address.get()); + } + } + + for (String uuid : uuids) { + Optional address = SignalServiceAddress.fromRaw(uuid, null); + if (address.isPresent()) { + addresses.add(address.get()); + } + } + + for (ByteString groupId : content.getBlocked().getGroupIdsList()) { + groupIds.add(groupId.toByteArray()); + } + + return SignalServiceSyncMessage.forBlocked(new BlockedListMessage(addresses, groupIds)); + } + + if (content.hasConfiguration()) { + Boolean readReceipts = content.getConfiguration().hasReadReceipts() ? content.getConfiguration().getReadReceipts() : null; + Boolean unidentifiedDeliveryIndicators = content.getConfiguration().hasUnidentifiedDeliveryIndicators() ? content.getConfiguration().getUnidentifiedDeliveryIndicators() : null; + Boolean typingIndicators = content.getConfiguration().hasTypingIndicators() ? content.getConfiguration().getTypingIndicators() : null; + Boolean linkPreviews = content.getConfiguration().hasLinkPreviews() ? content.getConfiguration().getLinkPreviews() : null; + + return SignalServiceSyncMessage.forConfiguration(new ConfigurationMessage(Optional.fromNullable(readReceipts), + Optional.fromNullable(unidentifiedDeliveryIndicators), + Optional.fromNullable(typingIndicators), + Optional.fromNullable(linkPreviews))); + } + + if (content.hasFetchLatest() && content.getFetchLatest().hasType()) { + switch (content.getFetchLatest().getType()) { + case LOCAL_PROFILE: return SignalServiceSyncMessage.forFetchLatest(SignalServiceSyncMessage.FetchType.LOCAL_PROFILE); + case STORAGE_MANIFEST: return SignalServiceSyncMessage.forFetchLatest(SignalServiceSyncMessage.FetchType.STORAGE_MANIFEST); + } + } + + return SignalServiceSyncMessage.empty(); + } + + private SignalServiceCallMessage createCallMessage(CallMessage content) { + if (content.hasOffer()) { + CallMessage.Offer offerContent = content.getOffer(); + return SignalServiceCallMessage.forOffer(new OfferMessage(offerContent.getId(), offerContent.getDescription())); + } else if (content.hasAnswer()) { + CallMessage.Answer answerContent = content.getAnswer(); + return SignalServiceCallMessage.forAnswer(new AnswerMessage(answerContent.getId(), answerContent.getDescription())); + } else if (content.getIceUpdateCount() > 0) { + List iceUpdates = new LinkedList<>(); + + for (CallMessage.IceUpdate iceUpdate : content.getIceUpdateList()) { + iceUpdates.add(new IceUpdateMessage(iceUpdate.getId(), iceUpdate.getSdpMid(), iceUpdate.getSdpMLineIndex(), iceUpdate.getSdp())); + } + + return SignalServiceCallMessage.forIceUpdates(iceUpdates); + } else if (content.hasHangup()) { + CallMessage.Hangup hangup = content.getHangup(); + return SignalServiceCallMessage.forHangup(new HangupMessage(hangup.getId())); + } else if (content.hasBusy()) { + CallMessage.Busy busy = content.getBusy(); + return SignalServiceCallMessage.forBusy(new BusyMessage(busy.getId())); + } + + return SignalServiceCallMessage.empty(); + } + + private SignalServiceReceiptMessage createReceiptMessage(Metadata metadata, ReceiptMessage content) { + SignalServiceReceiptMessage.Type type; + + if (content.getType() == ReceiptMessage.Type.DELIVERY) type = SignalServiceReceiptMessage.Type.DELIVERY; + else if (content.getType() == ReceiptMessage.Type.READ) type = SignalServiceReceiptMessage.Type.READ; + else type = SignalServiceReceiptMessage.Type.UNKNOWN; + + return new SignalServiceReceiptMessage(type, content.getTimestampList(), metadata.getTimestamp()); + } + + private SignalServiceTypingMessage createTypingMessage(Metadata metadata, TypingMessage content) throws ProtocolInvalidMessageException { + SignalServiceTypingMessage.Action action; + + if (content.getAction() == TypingMessage.Action.STARTED) action = SignalServiceTypingMessage.Action.STARTED; + else if (content.getAction() == TypingMessage.Action.STOPPED) action = SignalServiceTypingMessage.Action.STOPPED; + else action = SignalServiceTypingMessage.Action.UNKNOWN; + + if (content.hasTimestamp() && content.getTimestamp() != metadata.getTimestamp()) { + throw new ProtocolInvalidMessageException(new InvalidMessageException("Timestamps don't match: " + content.getTimestamp() + " vs " + metadata.getTimestamp()), + metadata.getSender().getIdentifier(), + metadata.getSenderDevice()); + } + + return new SignalServiceTypingMessage(action, content.getTimestamp(), + content.hasGroupId() ? Optional.of(content.getGroupId().toByteArray()) : + Optional.absent()); + } + + private SignalServiceDataMessage.Quote createQuote(DataMessage content) { + if (!content.hasQuote()) return null; + + List attachments = new LinkedList<>(); + + for (DataMessage.Quote.QuotedAttachment attachment : content.getQuote().getAttachmentsList()) { + attachments.add(new SignalServiceDataMessage.Quote.QuotedAttachment(attachment.getContentType(), + attachment.getFileName(), + attachment.hasThumbnail() ? createAttachmentPointer(attachment.getThumbnail()) : null)); + } + + if (SignalServiceAddress.isValidAddress(content.getQuote().getAuthorUuid(), content.getQuote().getAuthorE164())) { + SignalServiceAddress address = new SignalServiceAddress(UuidUtil.parseOrNull(content.getQuote().getAuthorUuid()), content.getQuote().getAuthorE164()); + + return new SignalServiceDataMessage.Quote(content.getQuote().getId(), + address, + content.getQuote().getText(), + attachments); + } else { + Log.w(TAG, "Quote was missing an author! Returning null."); + return null; + } + } + + private List createPreviews(DataMessage content) { + if (content.getPreviewCount() <= 0) return null; + + List results = new LinkedList<>(); + + for (DataMessage.Preview preview : content.getPreviewList()) { + SignalServiceAttachment attachment = null; + + if (preview.hasImage()) { + attachment = createAttachmentPointer(preview.getImage()); + } + + results.add(new Preview(preview.getUrl(), + preview.getTitle(), + Optional.fromNullable(attachment))); + } + + return results; + } + + private Sticker createSticker(DataMessage content) { + if (!content.hasSticker() || + !content.getSticker().hasPackId() || + !content.getSticker().hasPackKey() || + !content.getSticker().hasStickerId() || + !content.getSticker().hasData()) + { + return null; + } + + DataMessage.Sticker sticker = content.getSticker(); + + return new Sticker(sticker.getPackId().toByteArray(), + sticker.getPackKey().toByteArray(), + sticker.getStickerId(), + createAttachmentPointer(sticker.getData())); + } + + private List createSharedContacts(DataMessage content) { + if (content.getContactCount() <= 0) return null; + + List results = new LinkedList<>(); + + for (DataMessage.Contact contact : content.getContactList()) { + SharedContact.Builder builder = SharedContact.newBuilder() + .setName(SharedContact.Name.newBuilder() + .setDisplay(contact.getName().getDisplayName()) + .setFamily(contact.getName().getFamilyName()) + .setGiven(contact.getName().getGivenName()) + .setMiddle(contact.getName().getMiddleName()) + .setPrefix(contact.getName().getPrefix()) + .setSuffix(contact.getName().getSuffix()) + .build()); + + if (contact.getAddressCount() > 0) { + for (DataMessage.Contact.PostalAddress address : contact.getAddressList()) { + SharedContact.PostalAddress.Type type = SharedContact.PostalAddress.Type.HOME; + + switch (address.getType()) { + case WORK: type = SharedContact.PostalAddress.Type.WORK; break; + case HOME: type = SharedContact.PostalAddress.Type.HOME; break; + case CUSTOM: type = SharedContact.PostalAddress.Type.CUSTOM; break; + } + + builder.withAddress(SharedContact.PostalAddress.newBuilder() + .setCity(address.getCity()) + .setCountry(address.getCountry()) + .setLabel(address.getLabel()) + .setNeighborhood(address.getNeighborhood()) + .setPobox(address.getPobox()) + .setPostcode(address.getPostcode()) + .setRegion(address.getRegion()) + .setStreet(address.getStreet()) + .setType(type) + .build()); + } + } + + if (contact.getNumberCount() > 0) { + for (DataMessage.Contact.Phone phone : contact.getNumberList()) { + SharedContact.Phone.Type type = SharedContact.Phone.Type.HOME; + + switch (phone.getType()) { + case HOME: type = SharedContact.Phone.Type.HOME; break; + case WORK: type = SharedContact.Phone.Type.WORK; break; + case MOBILE: type = SharedContact.Phone.Type.MOBILE; break; + case CUSTOM: type = SharedContact.Phone.Type.CUSTOM; break; + } + + builder.withPhone(SharedContact.Phone.newBuilder() + .setLabel(phone.getLabel()) + .setType(type) + .setValue(phone.getValue()) + .build()); + } + } + + if (contact.getEmailCount() > 0) { + for (DataMessage.Contact.Email email : contact.getEmailList()) { + SharedContact.Email.Type type = SharedContact.Email.Type.HOME; + + switch (email.getType()) { + case HOME: type = SharedContact.Email.Type.HOME; break; + case WORK: type = SharedContact.Email.Type.WORK; break; + case MOBILE: type = SharedContact.Email.Type.MOBILE; break; + case CUSTOM: type = SharedContact.Email.Type.CUSTOM; break; + } + + builder.withEmail(SharedContact.Email.newBuilder() + .setLabel(email.getLabel()) + .setType(type) + .setValue(email.getValue()) + .build()); + } + } + + if (contact.hasAvatar()) { + builder.setAvatar(SharedContact.Avatar.newBuilder() + .withAttachment(createAttachmentPointer(contact.getAvatar().getAvatar())) + .withProfileFlag(contact.getAvatar().getIsProfile()) + .build()); + } + + if (contact.hasOrganization()) { + builder.withOrganization(contact.getOrganization()); + } + + results.add(builder.build()); + } + + return results; + } + + private SignalServiceAttachmentPointer createAttachmentPointer(AttachmentPointer pointer) { + return new SignalServiceAttachmentPointer(pointer.getId(), + pointer.getContentType(), + pointer.getKey().toByteArray(), + pointer.hasSize() ? Optional.of(pointer.getSize()) : Optional.absent(), + pointer.hasThumbnail() ? Optional.of(pointer.getThumbnail().toByteArray()): Optional.absent(), + pointer.getWidth(), pointer.getHeight(), + pointer.hasDigest() ? Optional.of(pointer.getDigest().toByteArray()) : Optional.absent(), + pointer.hasFileName() ? Optional.of(pointer.getFileName()) : Optional.absent(), + (pointer.getFlags() & AttachmentPointer.Flags.VOICE_MESSAGE_VALUE) != 0, + pointer.hasCaption() ? Optional.of(pointer.getCaption()) : Optional.absent(), + pointer.hasBlurHash() ? Optional.of(pointer.getBlurHash()) : Optional.absent()); + + } + + private SignalServiceGroup createGroupInfo(DataMessage content) throws ProtocolInvalidMessageException { + if (!content.hasGroup()) return null; + + SignalServiceGroup.Type type; + + switch (content.getGroup().getType()) { + case DELIVER: type = SignalServiceGroup.Type.DELIVER; break; + case UPDATE: type = SignalServiceGroup.Type.UPDATE; break; + case QUIT: type = SignalServiceGroup.Type.QUIT; break; + case REQUEST_INFO: type = SignalServiceGroup.Type.REQUEST_INFO; break; + default: type = SignalServiceGroup.Type.UNKNOWN; break; + } + + if (content.getGroup().getType() != DELIVER) { + String name = null; + List members = null; + SignalServiceAttachmentPointer avatar = null; + + if (content.getGroup().hasName()) { + name = content.getGroup().getName(); + } + + if (content.getGroup().getMembersCount() > 0) { + members = new ArrayList<>(content.getGroup().getMembersCount()); + + for (SignalServiceProtos.GroupContext.Member member : content.getGroup().getMembersList()) { + if (SignalServiceAddress.isValidAddress(member.getUuid(), member.getE164())) { + members.add(new SignalServiceAddress(UuidUtil.parseOrNull(member.getUuid()), member.getE164())); + } else { + throw new ProtocolInvalidMessageException(new InvalidMessageException("GroupContext.Member had no address!"), null, 0); + } + } + } else if (content.getGroup().getMembersE164Count() > 0) { + members = new ArrayList<>(content.getGroup().getMembersE164Count()); + + for (String member : content.getGroup().getMembersE164List()) { + members.add(new SignalServiceAddress(null, member)); + } + } + + if (content.getGroup().hasAvatar()) { + AttachmentPointer pointer = content.getGroup().getAvatar(); + + avatar = new SignalServiceAttachmentPointer(pointer.getId(), + pointer.getContentType(), + pointer.getKey().toByteArray(), + Optional.of(pointer.getSize()), + Optional.absent(), 0, 0, + Optional.fromNullable(pointer.hasDigest() ? pointer.getDigest().toByteArray() : null), + Optional.absent(), + false, + Optional.absent(), + Optional.absent()); + } + + return new SignalServiceGroup(type, content.getGroup().getId().toByteArray(), name, members, avatar); + } + + return new SignalServiceGroup(content.getGroup().getId().toByteArray()); + } + + private static class Metadata { + private final SignalServiceAddress sender; + private final int senderDevice; + private final long timestamp; + private final boolean needsReceipt; + + private Metadata(SignalServiceAddress sender, int senderDevice, long timestamp, boolean needsReceipt) { + this.sender = sender; + this.senderDevice = senderDevice; + this.timestamp = timestamp; + this.needsReceipt = needsReceipt; + } + + public SignalServiceAddress getSender() { + return sender; + } + + public int getSenderDevice() { + return senderDevice; + } + + public long getTimestamp() { + return timestamp; + } + + public boolean isNeedsReceipt() { + return needsReceipt; + } + } + + private static class Plaintext { + private final Metadata metadata; + private final byte[] data; + + private Plaintext(Metadata metadata, byte[] data) { + this.metadata = metadata; + this.data = data; + } + + public Metadata getMetadata() { + return metadata; + } + + public byte[] getData() { + return data; + } + } + +} + diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/crypto/UnidentifiedAccess.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/crypto/UnidentifiedAccess.java new file mode 100644 index 000000000..7ae5c23ed --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/crypto/UnidentifiedAccess.java @@ -0,0 +1,54 @@ +package org.whispersystems.signalservice.api.crypto; + + +import org.signal.libsignal.metadata.certificate.InvalidCertificateException; +import org.signal.libsignal.metadata.certificate.SenderCertificate; +import org.whispersystems.libsignal.util.ByteUtil; + +import java.security.InvalidAlgorithmParameterException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; + +import javax.crypto.BadPaddingException; +import javax.crypto.Cipher; +import javax.crypto.IllegalBlockSizeException; +import javax.crypto.NoSuchPaddingException; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; + +public class UnidentifiedAccess { + + private final byte[] unidentifiedAccessKey; + private final SenderCertificate unidentifiedCertificate; + + public UnidentifiedAccess(byte[] unidentifiedAccessKey, byte[] unidentifiedCertificate) + throws InvalidCertificateException + { + this.unidentifiedAccessKey = unidentifiedAccessKey; + this.unidentifiedCertificate = new SenderCertificate(unidentifiedCertificate); + } + + public byte[] getUnidentifiedAccessKey() { + return unidentifiedAccessKey; + } + + public SenderCertificate getUnidentifiedCertificate() { + return unidentifiedCertificate; + } + + public static byte[] deriveAccessKeyFrom(byte[] profileKey) { + try { + byte[] nonce = new byte[12]; + byte[] input = new byte[16]; + + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); + cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(profileKey, "AES"), new GCMParameterSpec(128, nonce)); + + byte[] ciphertext = cipher.doFinal(input); + + return ByteUtil.trim(ciphertext, 16); + } catch (NoSuchAlgorithmException | InvalidKeyException | NoSuchPaddingException | InvalidAlgorithmParameterException | BadPaddingException | IllegalBlockSizeException e) { + throw new AssertionError(e); + } + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/crypto/UnidentifiedAccessPair.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/crypto/UnidentifiedAccessPair.java new file mode 100644 index 000000000..5e9ae1903 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/crypto/UnidentifiedAccessPair.java @@ -0,0 +1,23 @@ +package org.whispersystems.signalservice.api.crypto; + + +import org.whispersystems.libsignal.util.guava.Optional; + +public class UnidentifiedAccessPair { + + private final Optional targetUnidentifiedAccess; + private final Optional selfUnidentifiedAccess; + + public UnidentifiedAccessPair(UnidentifiedAccess targetUnidentifiedAccess, UnidentifiedAccess selfUnidentifiedAccess) { + this.targetUnidentifiedAccess = Optional.of(targetUnidentifiedAccess); + this.selfUnidentifiedAccess = Optional.of(selfUnidentifiedAccess); + } + + public Optional getTargetUnidentifiedAccess() { + return targetUnidentifiedAccess; + } + + public Optional getSelfUnidentifiedAccess() { + return selfUnidentifiedAccess; + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/crypto/UntrustedIdentityException.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/crypto/UntrustedIdentityException.java new file mode 100644 index 000000000..b0ec66464 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/crypto/UntrustedIdentityException.java @@ -0,0 +1,34 @@ +/** + * Copyright (C) 2014-2016 Open Whisper Systems + * + * Licensed according to the LICENSE file in this repository. + */ + +package org.whispersystems.signalservice.api.crypto; + +import org.whispersystems.libsignal.IdentityKey; + +public class UntrustedIdentityException extends Exception { + + private final IdentityKey identityKey; + private final String identifier; + + public UntrustedIdentityException(String s, String identifier, IdentityKey identityKey) { + super(s); + this.identifier = identifier; + this.identityKey = identityKey; + } + + public UntrustedIdentityException(UntrustedIdentityException e) { + this(e.getMessage(), e.getIdentifier(), e.getIdentityKey()); + } + + public IdentityKey getIdentityKey() { + return identityKey; + } + + public String getIdentifier() { + return identifier; + } + +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SendMessageResult.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SendMessageResult.java new file mode 100644 index 000000000..da567f2a2 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SendMessageResult.java @@ -0,0 +1,91 @@ +package org.whispersystems.signalservice.api.messages; + + +import org.whispersystems.libsignal.IdentityKey; +import org.whispersystems.signalservice.api.push.SignalServiceAddress; + +public class SendMessageResult { + + private final SignalServiceAddress address; + private final Success success; + private final boolean networkFailure; + private final boolean unregisteredFailure; + private final IdentityFailure identityFailure; + + public static SendMessageResult success(SignalServiceAddress address, boolean unidentified, boolean needsSync) { + return new SendMessageResult(address, new Success(unidentified, needsSync), false, false, null); + } + + public static SendMessageResult networkFailure(SignalServiceAddress address) { + return new SendMessageResult(address, null, true, false, null); + } + + public static SendMessageResult unregisteredFailure(SignalServiceAddress address) { + return new SendMessageResult(address, null, false, true, null); + } + + public static SendMessageResult identityFailure(SignalServiceAddress address, IdentityKey identityKey) { + return new SendMessageResult(address, null, false, false, new IdentityFailure(identityKey)); + } + + public SignalServiceAddress getAddress() { + return address; + } + + public Success getSuccess() { + return success; + } + + public boolean isNetworkFailure() { + return networkFailure; + } + + public boolean isUnregisteredFailure() { + return unregisteredFailure; + } + + public IdentityFailure getIdentityFailure() { + return identityFailure; + } + + private SendMessageResult(SignalServiceAddress address, Success success, boolean networkFailure, boolean unregisteredFailure, IdentityFailure identityFailure) { + this.address = address; + this.success = success; + this.networkFailure = networkFailure; + this.unregisteredFailure = unregisteredFailure; + this.identityFailure = identityFailure; + } + + public static class Success { + private final boolean unidentified; + private final boolean needsSync; + + private Success(boolean unidentified, boolean needsSync) { + this.unidentified = unidentified; + this.needsSync = needsSync; + } + + public boolean isUnidentified() { + return unidentified; + } + + public boolean isNeedsSync() { + return needsSync; + } + } + + public static class IdentityFailure { + private final IdentityKey identityKey; + + private IdentityFailure(IdentityKey identityKey) { + this.identityKey = identityKey; + } + + public IdentityKey getIdentityKey() { + return identityKey; + } + } + + + +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceAttachment.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceAttachment.java new file mode 100644 index 000000000..2a4763316 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceAttachment.java @@ -0,0 +1,137 @@ +/* + * Copyright (C) 2014-2016 Open Whisper Systems + * + * Licensed according to the LICENSE file in this repository. + */ + +package org.whispersystems.signalservice.api.messages; + +import org.whispersystems.libsignal.util.guava.Optional; + +import java.io.InputStream; + +public abstract class SignalServiceAttachment { + + private final String contentType; + + protected SignalServiceAttachment(String contentType) { + this.contentType = contentType; + } + + public String getContentType() { + return contentType; + } + + public abstract boolean isStream(); + public abstract boolean isPointer(); + + public SignalServiceAttachmentStream asStream() { + return (SignalServiceAttachmentStream)this; + } + + public SignalServiceAttachmentPointer asPointer() { + return (SignalServiceAttachmentPointer)this; + } + + public static Builder newStreamBuilder() { + return new Builder(); + } + + public static class Builder { + + private InputStream inputStream; + private String contentType; + private String fileName; + private long length; + private ProgressListener listener; + private boolean voiceNote; + private int width; + private int height; + private String caption; + private String blurHash; + + private Builder() {} + + public Builder withStream(InputStream inputStream) { + this.inputStream = inputStream; + return this; + } + + public Builder withContentType(String contentType) { + this.contentType = contentType; + return this; + } + + public Builder withLength(long length) { + this.length = length; + return this; + } + + public Builder withFileName(String fileName) { + this.fileName = fileName; + return this; + } + + public Builder withListener(ProgressListener listener) { + this.listener = listener; + return this; + } + + public Builder withVoiceNote(boolean voiceNote) { + this.voiceNote = voiceNote; + return this; + } + + public Builder withWidth(int width) { + this.width = width; + return this; + } + + public Builder withHeight(int height) { + this.height = height; + return this; + } + + public Builder withCaption(String caption) { + this.caption = caption; + return this; + } + + public Builder withBlurHash(String blurHash) { + this.blurHash = blurHash; + return this; + } + + public SignalServiceAttachmentStream build() { + if (inputStream == null) throw new IllegalArgumentException("Must specify stream!"); + if (contentType == null) throw new IllegalArgumentException("No content type specified!"); + if (length == 0) throw new IllegalArgumentException("No length specified!"); + + return new SignalServiceAttachmentStream(inputStream, + contentType, + length, + Optional.fromNullable(fileName), + voiceNote, + Optional.absent(), + width, + height, + Optional.fromNullable(caption), + Optional.fromNullable(blurHash), + listener); + } + } + + /** + * An interface to receive progress information on upload/download of + * an attachment. + */ + public interface ProgressListener { + /** + * Called on a progress change event. + * + * @param total The total amount to transmit/receive in bytes. + * @param progress The amount that has been transmitted/received in bytes thus far + */ + public void onAttachmentProgress(long total, long progress); + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceAttachmentPointer.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceAttachmentPointer.java new file mode 100644 index 000000000..bf0b84083 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceAttachmentPointer.java @@ -0,0 +1,107 @@ +/* + * Copyright (C) 2014-2017 Open Whisper Systems + * + * Licensed according to the LICENSE file in this repository. + */ + +package org.whispersystems.signalservice.api.messages; + +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.SignalServiceMessageReceiver; + +/** + * Represents a received SignalServiceAttachment "handle." This + * is a pointer to the actual attachment content, which needs to be + * retrieved using {@link SignalServiceMessageReceiver#retrieveAttachment(SignalServiceAttachmentPointer, java.io.File, int)} + * + * @author Moxie Marlinspike + */ +public class SignalServiceAttachmentPointer extends SignalServiceAttachment { + + private final long id; + private final byte[] key; + private final Optional size; + private final Optional preview; + private final Optional digest; + private final Optional fileName; + private final boolean voiceNote; + private final int width; + private final int height; + private final Optional caption; + private final Optional blurHash; + + public SignalServiceAttachmentPointer(long id, String contentType, byte[] key, + Optional size, Optional preview, + int width, int height, + Optional digest, Optional fileName, + boolean voiceNote, Optional caption, + Optional blurHash) + { + super(contentType); + this.id = id; + this.key = key; + this.size = size; + this.preview = preview; + this.width = width; + this.height = height; + this.digest = digest; + this.fileName = fileName; + this.voiceNote = voiceNote; + this.caption = caption; + this.blurHash = blurHash; + } + + public long getId() { + return id; + } + + public byte[] getKey() { + return key; + } + + @Override + public boolean isStream() { + return false; + } + + @Override + public boolean isPointer() { + return true; + } + + public Optional getSize() { + return size; + } + + public Optional getFileName() { + return fileName; + } + + public Optional getPreview() { + return preview; + } + + public Optional getDigest() { + return digest; + } + + public boolean getVoiceNote() { + return voiceNote; + } + + public int getWidth() { + return width; + } + + public int getHeight() { + return height; + } + + public Optional getCaption() { + return caption; + } + + public Optional getBlurHash() { + return blurHash; + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceAttachmentStream.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceAttachmentStream.java new file mode 100644 index 000000000..87359501e --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceAttachmentStream.java @@ -0,0 +1,96 @@ +/** + * Copyright (C) 2014-2016 Open Whisper Systems + * + * Licensed according to the LICENSE file in this repository. + */ + +package org.whispersystems.signalservice.api.messages; + +import org.whispersystems.libsignal.util.guava.Optional; + +import java.io.InputStream; + +/** + * Represents a local SignalServiceAttachment to be sent. + */ +public class SignalServiceAttachmentStream extends SignalServiceAttachment { + + private final InputStream inputStream; + private final long length; + private final Optional fileName; + private final ProgressListener listener; + private final Optional preview; + private final boolean voiceNote; + private final int width; + private final int height; + private final Optional caption; + private final Optional blurHash; + + public SignalServiceAttachmentStream(InputStream inputStream, String contentType, long length, Optional fileName, boolean voiceNote, ProgressListener listener) { + this(inputStream, contentType, length, fileName, voiceNote, Optional.absent(), 0, 0, Optional.absent(), Optional.absent(), listener); + } + + public SignalServiceAttachmentStream(InputStream inputStream, String contentType, long length, Optional fileName, boolean voiceNote, Optional preview, int width, int height, Optional caption, Optional blurHash, ProgressListener listener) { + super(contentType); + this.inputStream = inputStream; + this.length = length; + this.fileName = fileName; + this.listener = listener; + this.voiceNote = voiceNote; + this.preview = preview; + this.width = width; + this.height = height; + this.caption = caption; + this.blurHash = blurHash; + } + + @Override + public boolean isStream() { + return true; + } + + @Override + public boolean isPointer() { + return false; + } + + public InputStream getInputStream() { + return inputStream; + } + + public long getLength() { + return length; + } + + public Optional getFileName() { + return fileName; + } + + public ProgressListener getListener() { + return listener; + } + + public Optional getPreview() { + return preview; + } + + public boolean getVoiceNote() { + return voiceNote; + } + + public int getWidth() { + return width; + } + + public int getHeight() { + return height; + } + + public Optional getCaption() { + return caption; + } + + public Optional getBlurHash() { + return blurHash; + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceContent.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceContent.java new file mode 100644 index 000000000..6be5d26ef --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceContent.java @@ -0,0 +1,127 @@ +/* + * Copyright (C) 2014-2016 Open Whisper Systems + * + * Licensed according to the LICENSE file in this repository. + */ + +package org.whispersystems.signalservice.api.messages; + +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.messages.calls.SignalServiceCallMessage; +import org.whispersystems.signalservice.api.messages.multidevice.SignalServiceSyncMessage; +import org.whispersystems.signalservice.api.push.SignalServiceAddress; + +public class SignalServiceContent { + + private final SignalServiceAddress sender; + private final int senderDevice; + private final long timestamp; + private final boolean needsReceipt; + + private final Optional message; + private final Optional synchronizeMessage; + private final Optional callMessage; + private final Optional readMessage; + private final Optional typingMessage; + + public SignalServiceContent(SignalServiceDataMessage message, SignalServiceAddress sender, int senderDevice, long timestamp, boolean needsReceipt) { + this.sender = sender; + this.senderDevice = senderDevice; + this.timestamp = timestamp; + this.needsReceipt = needsReceipt; + + this.message = Optional.fromNullable(message); + this.synchronizeMessage = Optional.absent(); + this.callMessage = Optional.absent(); + this.readMessage = Optional.absent(); + this.typingMessage = Optional.absent(); + } + + public SignalServiceContent(SignalServiceSyncMessage synchronizeMessage, SignalServiceAddress sender, int senderDevice, long timestamp, boolean needsReceipt) { + this.sender = sender; + this.senderDevice = senderDevice; + this.timestamp = timestamp; + this.needsReceipt = needsReceipt; + + this.message = Optional.absent(); + this.synchronizeMessage = Optional.fromNullable(synchronizeMessage); + this.callMessage = Optional.absent(); + this.readMessage = Optional.absent(); + this.typingMessage = Optional.absent(); + } + + public SignalServiceContent(SignalServiceCallMessage callMessage, SignalServiceAddress sender, int senderDevice, long timestamp, boolean needsReceipt) { + this.sender = sender; + this.senderDevice = senderDevice; + this.timestamp = timestamp; + this.needsReceipt = needsReceipt; + + this.message = Optional.absent(); + this.synchronizeMessage = Optional.absent(); + this.callMessage = Optional.of(callMessage); + this.readMessage = Optional.absent(); + this.typingMessage = Optional.absent(); + } + + public SignalServiceContent(SignalServiceReceiptMessage receiptMessage, SignalServiceAddress sender, int senderDevice, long timestamp, boolean needsReceipt) { + this.sender = sender; + this.senderDevice = senderDevice; + this.timestamp = timestamp; + this.needsReceipt = needsReceipt; + + this.message = Optional.absent(); + this.synchronizeMessage = Optional.absent(); + this.callMessage = Optional.absent(); + this.readMessage = Optional.of(receiptMessage); + this.typingMessage = Optional.absent(); + } + + public SignalServiceContent(SignalServiceTypingMessage typingMessage, SignalServiceAddress sender, int senderDevice, long timestamp, boolean needsReceipt) { + this.sender = sender; + this.senderDevice = senderDevice; + this.timestamp = timestamp; + this.needsReceipt = needsReceipt; + + this.message = Optional.absent(); + this.synchronizeMessage = Optional.absent(); + this.callMessage = Optional.absent(); + this.readMessage = Optional.absent(); + this.typingMessage = Optional.of(typingMessage); + } + + public Optional getDataMessage() { + return message; + } + + public Optional getSyncMessage() { + return synchronizeMessage; + } + + public Optional getCallMessage() { + return callMessage; + } + + public Optional getReceiptMessage() { + return readMessage; + } + + public Optional getTypingMessage() { + return typingMessage; + } + + public SignalServiceAddress getSender() { + return sender; + } + + public int getSenderDevice() { + return senderDevice; + } + + public long getTimestamp() { + return timestamp; + } + + public boolean isNeedsReceipt() { + return needsReceipt; + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceDataMessage.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceDataMessage.java new file mode 100644 index 000000000..2ac945024 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceDataMessage.java @@ -0,0 +1,459 @@ +/* + * Copyright (C) 2014-2016 Open Whisper Systems + * + * Licensed according to the LICENSE file in this repository. + */ + +package org.whispersystems.signalservice.api.messages; + +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.messages.shared.SharedContact; +import org.whispersystems.signalservice.api.push.SignalServiceAddress; + +import java.util.LinkedList; +import java.util.List; + +/** + * Represents a decrypted Signal Service data message. + */ +public class SignalServiceDataMessage { + + private final long timestamp; + private final Optional> attachments; + private final Optional body; + private final Optional group; + private final Optional profileKey; + private final boolean endSession; + private final boolean expirationUpdate; + private final int expiresInSeconds; + private final boolean profileKeyUpdate; + private final Optional quote; + private final Optional> contacts; + private final Optional> previews; + private final Optional sticker; + private final boolean viewOnce; + + /** + * Construct a SignalServiceDataMessage with a body and no attachments. + * + * @param timestamp The sent timestamp. + * @param body The message contents. + */ + public SignalServiceDataMessage(long timestamp, String body) { + this(timestamp, body, 0); + } + + /** + * Construct an expiring SignalServiceDataMessage with a body and no attachments. + * + * @param timestamp The sent timestamp. + * @param body The message contents. + * @param expiresInSeconds The number of seconds in which the message should expire after having been seen. + */ + public SignalServiceDataMessage(long timestamp, String body, int expiresInSeconds) { + this(timestamp, (List)null, body, expiresInSeconds); + } + + + public SignalServiceDataMessage(final long timestamp, final SignalServiceAttachment attachment, final String body) { + this(timestamp, new LinkedList() {{add(attachment);}}, body); + } + + /** + * Construct a SignalServiceDataMessage with a body and list of attachments. + * + * @param timestamp The sent timestamp. + * @param attachments The attachments. + * @param body The message contents. + */ + public SignalServiceDataMessage(long timestamp, List attachments, String body) { + this(timestamp, attachments, body, 0); + } + + /** + * Construct an expiring SignalServiceDataMessage with a body and list of attachments. + * + * @param timestamp The sent timestamp. + * @param attachments The attachments. + * @param body The message contents. + * @param expiresInSeconds The number of seconds in which the message should expire after having been seen. + */ + public SignalServiceDataMessage(long timestamp, List attachments, String body, int expiresInSeconds) { + this(timestamp, null, attachments, body, expiresInSeconds); + } + + /** + * Construct a SignalServiceDataMessage group message with attachments and body. + * + * @param timestamp The sent timestamp. + * @param group The group information. + * @param attachments The attachments. + * @param body The message contents. + */ + public SignalServiceDataMessage(long timestamp, SignalServiceGroup group, List attachments, String body) { + this(timestamp, group, attachments, body, 0); + } + + + /** + * Construct an expiring SignalServiceDataMessage group message with attachments and body. + * + * @param timestamp The sent timestamp. + * @param group The group information. + * @param attachments The attachments. + * @param body The message contents. + * @param expiresInSeconds The number of seconds in which a message should disappear after having been seen. + */ + public SignalServiceDataMessage(long timestamp, SignalServiceGroup group, List attachments, String body, int expiresInSeconds) { + this(timestamp, group, attachments, body, false, expiresInSeconds, false, null, false, null, null, null, null, false); + } + + /** + * Construct a SignalServiceDataMessage. + * + * @param timestamp The sent timestamp. + * @param group The group information (or null if none). + * @param attachments The attachments (or null if none). + * @param body The message contents. + * @param endSession Flag indicating whether this message should close a session. + * @param expiresInSeconds Number of seconds in which the message should disappear after being seen. + */ + public SignalServiceDataMessage(long timestamp, SignalServiceGroup group, + List attachments, + String body, boolean endSession, int expiresInSeconds, + boolean expirationUpdate, byte[] profileKey, boolean profileKeyUpdate, + Quote quote, List sharedContacts, List previews, + Sticker sticker, boolean viewOnce) + { + this.timestamp = timestamp; + this.body = Optional.fromNullable(body); + this.group = Optional.fromNullable(group); + this.endSession = endSession; + this.expiresInSeconds = expiresInSeconds; + this.expirationUpdate = expirationUpdate; + this.profileKey = Optional.fromNullable(profileKey); + this.profileKeyUpdate = profileKeyUpdate; + this.quote = Optional.fromNullable(quote); + this.sticker = Optional.fromNullable(sticker); + this.viewOnce = viewOnce; + + if (attachments != null && !attachments.isEmpty()) { + this.attachments = Optional.of(attachments); + } else { + this.attachments = Optional.absent(); + } + + if (sharedContacts != null && !sharedContacts.isEmpty()) { + this.contacts = Optional.of(sharedContacts); + } else { + this.contacts = Optional.absent(); + } + + if (previews != null && !previews.isEmpty()) { + this.previews = Optional.of(previews); + } else { + this.previews = Optional.absent(); + } + } + + public static Builder newBuilder() { + return new Builder(); + } + + /** + * @return The message timestamp. + */ + public long getTimestamp() { + return timestamp; + } + + /** + * @return The message attachments (if any). + */ + public Optional> getAttachments() { + return attachments; + } + + /** + * @return The message body (if any). + */ + public Optional getBody() { + return body; + } + + /** + * @return The message group info (if any). + */ + public Optional getGroupInfo() { + return group; + } + + public boolean isEndSession() { + return endSession; + } + + public boolean isExpirationUpdate() { + return expirationUpdate; + } + + public boolean isProfileKeyUpdate() { + return profileKeyUpdate; + } + + public boolean isGroupUpdate() { + return group.isPresent() && group.get().getType() != SignalServiceGroup.Type.DELIVER; + } + + public int getExpiresInSeconds() { + return expiresInSeconds; + } + + public Optional getProfileKey() { + return profileKey; + } + + public Optional getQuote() { + return quote; + } + + public Optional> getSharedContacts() { + return contacts; + } + + public Optional> getPreviews() { + return previews; + } + + public Optional getSticker() { + return sticker; + } + + public boolean isViewOnce() { + return viewOnce; + } + + public static class Builder { + + private List attachments = new LinkedList<>(); + private List sharedContacts = new LinkedList<>(); + private List previews = new LinkedList<>(); + + private long timestamp; + private SignalServiceGroup group; + private String body; + private boolean endSession; + private int expiresInSeconds; + private boolean expirationUpdate; + private byte[] profileKey; + private boolean profileKeyUpdate; + private Quote quote; + private Sticker sticker; + private boolean viewOnce; + + private Builder() {} + + public Builder withTimestamp(long timestamp) { + this.timestamp = timestamp; + return this; + } + + public Builder asGroupMessage(SignalServiceGroup group) { + this.group = group; + return this; + } + + public Builder withAttachment(SignalServiceAttachment attachment) { + this.attachments.add(attachment); + return this; + } + + public Builder withAttachments(List attachments) { + this.attachments.addAll(attachments); + return this; + } + + public Builder withBody(String body) { + this.body = body; + return this; + } + + public Builder asEndSessionMessage() { + return asEndSessionMessage(true); + } + + public Builder asEndSessionMessage(boolean endSession) { + this.endSession = endSession; + return this; + } + + public Builder asExpirationUpdate() { + return asExpirationUpdate(true); + } + + public Builder asExpirationUpdate(boolean expirationUpdate) { + this.expirationUpdate = expirationUpdate; + return this; + } + + public Builder withExpiration(int expiresInSeconds) { + this.expiresInSeconds = expiresInSeconds; + return this; + } + + public Builder withProfileKey(byte[] profileKey) { + this.profileKey = profileKey; + return this; + } + + public Builder asProfileKeyUpdate(boolean profileKeyUpdate) { + this.profileKeyUpdate = profileKeyUpdate; + return this; + } + + public Builder withQuote(Quote quote) { + this.quote = quote; + return this; + } + + public Builder withSharedContact(SharedContact contact) { + this.sharedContacts.add(contact); + return this; + } + + public Builder withSharedContacts(List contacts) { + this.sharedContacts.addAll(contacts); + return this; + } + + public Builder withPreviews(List previews) { + this.previews.addAll(previews); + return this; + } + + public Builder withSticker(Sticker sticker) { + this.sticker = sticker; + return this; + } + + public Builder withViewOnce(boolean viewOnce) { + this.viewOnce = viewOnce; + return this; + } + + public SignalServiceDataMessage build() { + if (timestamp == 0) timestamp = System.currentTimeMillis(); + return new SignalServiceDataMessage(timestamp, group, attachments, body, endSession, + expiresInSeconds, expirationUpdate, profileKey, + profileKeyUpdate, quote, sharedContacts, previews, + sticker, viewOnce); + } + } + + public static class Quote { + private final long id; + private final SignalServiceAddress author; + private final String text; + private final List attachments; + + public Quote(long id, SignalServiceAddress author, String text, List attachments) { + this.id = id; + this.author = author; + this.text = text; + this.attachments = attachments; + } + + public long getId() { + return id; + } + + public SignalServiceAddress getAuthor() { + return author; + } + + public String getText() { + return text; + } + + public List getAttachments() { + return attachments; + } + + public static class QuotedAttachment { + private final String contentType; + private final String fileName; + private final SignalServiceAttachment thumbnail; + + public QuotedAttachment(String contentType, String fileName, SignalServiceAttachment thumbnail) { + this.contentType = contentType; + this.fileName = fileName; + this.thumbnail = thumbnail; + } + + public String getContentType() { + return contentType; + } + + public String getFileName() { + return fileName; + } + + public SignalServiceAttachment getThumbnail() { + return thumbnail; + } + } + } + + public static class Preview { + private final String url; + private final String title; + private final Optional image; + + public Preview(String url, String title, Optional image) { + this.url = url; + this.title = title; + this.image = image; + } + + public String getUrl() { + return url; + } + + public String getTitle() { + return title; + } + + public Optional getImage() { + return image; + } + } + + public static class Sticker { + private final byte[] packId; + private final byte[] packKey; + private final int stickerId; + private final SignalServiceAttachment attachment; + + public Sticker(byte[] packId, byte[] packKey, int stickerId, SignalServiceAttachment attachment) { + this.packId = packId; + this.packKey = packKey; + this.stickerId = stickerId; + this.attachment = attachment; + } + + public byte[] getPackId() { + return packId; + } + + public byte[] getPackKey() { + return packKey; + } + + public int getStickerId() { + return stickerId; + } + + public SignalServiceAttachment getAttachment() { + return attachment; + } + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceEnvelope.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceEnvelope.java new file mode 100644 index 000000000..218f111b8 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceEnvelope.java @@ -0,0 +1,331 @@ +/** + * Copyright (C) 2014-2016 Open Whisper Systems + * + * Licensed according to the LICENSE file in this repository. + */ + +package org.whispersystems.signalservice.api.messages; + +import com.google.protobuf.ByteString; + +import org.whispersystems.libsignal.InvalidVersionException; +import org.whispersystems.libsignal.logging.Log; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.push.SignalServiceAddress; +import org.whispersystems.signalservice.api.util.UuidUtil; +import org.whispersystems.signalservice.internal.push.SignalServiceProtos.Envelope; +import org.whispersystems.signalservice.internal.util.Hex; +import org.whispersystems.util.Base64; + +import java.io.IOException; +import java.security.InvalidAlgorithmParameterException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.util.Arrays; + +import javax.crypto.BadPaddingException; +import javax.crypto.Cipher; +import javax.crypto.IllegalBlockSizeException; +import javax.crypto.Mac; +import javax.crypto.NoSuchPaddingException; +import javax.crypto.spec.IvParameterSpec; +import javax.crypto.spec.SecretKeySpec; + +/** + * This class represents an encrypted Signal Service envelope. + * + * The envelope contains the wrapping information, such as the sender, the + * message timestamp, the encrypted message type, etc. + * + * @author Moxie Marlinspike + */ +public class SignalServiceEnvelope { + + private static final String TAG = SignalServiceEnvelope.class.getSimpleName(); + + private static final int SUPPORTED_VERSION = 1; + private static final int CIPHER_KEY_SIZE = 32; + private static final int MAC_KEY_SIZE = 20; + private static final int MAC_SIZE = 10; + + private static final int VERSION_OFFSET = 0; + private static final int VERSION_LENGTH = 1; + private static final int IV_OFFSET = VERSION_OFFSET + VERSION_LENGTH; + private static final int IV_LENGTH = 16; + private static final int CIPHERTEXT_OFFSET = IV_OFFSET + IV_LENGTH; + + private final Envelope envelope; + + /** + * Construct an envelope from a serialized, Base64 encoded SignalServiceEnvelope, encrypted + * with a signaling key. + * + * @param message The serialized SignalServiceEnvelope, base64 encoded and encrypted. + * @param signalingKey The signaling key. + * @throws IOException + * @throws InvalidVersionException + */ + public SignalServiceEnvelope(String message, String signalingKey, boolean isSignalingKeyEncrypted) + throws IOException, InvalidVersionException + { + this(Base64.decode(message), signalingKey, isSignalingKeyEncrypted); + } + + /** + * Construct an envelope from a serialized SignalServiceEnvelope, encrypted with a signaling key. + * + * @param input The serialized and (optionally) encrypted SignalServiceEnvelope. + * @param signalingKey The signaling key. + * @throws InvalidVersionException + * @throws IOException + */ + public SignalServiceEnvelope(byte[] input, String signalingKey, boolean isSignalingKeyEncrypted) + throws InvalidVersionException, IOException + { + if (!isSignalingKeyEncrypted) { + this.envelope = Envelope.parseFrom(input); + } else { + if (input.length < VERSION_LENGTH || input[VERSION_OFFSET] != SUPPORTED_VERSION) { + throw new InvalidVersionException("Unsupported version!"); + } + + SecretKeySpec cipherKey = getCipherKey(signalingKey); + SecretKeySpec macKey = getMacKey(signalingKey); + + verifyMac(input, macKey); + + this.envelope = Envelope.parseFrom(getPlaintext(input, cipherKey)); + } + } + + public SignalServiceEnvelope(int type, Optional sender, int senderDevice, long timestamp, byte[] legacyMessage, byte[] content, long serverTimestamp, String uuid) { + Envelope.Builder builder = Envelope.newBuilder() + .setType(Envelope.Type.valueOf(type)) + .setSourceDevice(senderDevice) + .setTimestamp(timestamp) + .setServerTimestamp(serverTimestamp); + + if (sender.isPresent()) { + if (sender.get().getUuid().isPresent()) { + builder.setSourceUuid(sender.get().getUuid().get().toString()); + } + + if (sender.get().getNumber().isPresent()) { + builder.setSourceE164(sender.get().getNumber().get()); + } + } + + if (uuid != null) { + builder.setServerGuid(uuid); + } + + if (legacyMessage != null) builder.setLegacyMessage(ByteString.copyFrom(legacyMessage)); + if (content != null) builder.setContent(ByteString.copyFrom(content)); + + this.envelope = builder.build(); + } + + public SignalServiceEnvelope(int type, long timestamp, byte[] legacyMessage, byte[] content, long serverTimestamp, String uuid) { + Envelope.Builder builder = Envelope.newBuilder() + .setType(Envelope.Type.valueOf(type)) + .setTimestamp(timestamp) + .setServerTimestamp(serverTimestamp); + + if (uuid != null) { + builder.setServerGuid(uuid); + } + + if (legacyMessage != null) builder.setLegacyMessage(ByteString.copyFrom(legacyMessage)); + if (content != null) builder.setContent(ByteString.copyFrom(content)); + + this.envelope = builder.build(); + } + + public String getUuid() { + return envelope.getServerGuid(); + } + + public boolean hasUuid() { + return envelope.hasServerGuid(); + } + + /** + * @return True if either a source E164 or UUID is present. + */ + public boolean hasSource() { + return envelope.hasSourceE164() || envelope.hasSourceUuid(); + } + + /** + * @return The envelope's sender as an E164 number. + */ + public Optional getSourceE164() { + return Optional.fromNullable(envelope.getSourceE164()); + } + + /** + * @return The envelope's sender as a UUID. + */ + public Optional getSourceUuid() { + return Optional.fromNullable(envelope.getSourceUuid()); + } + + public String getSourceIdentifier() { + return getSourceUuid().or(getSourceE164()).orNull(); + } + + public boolean hasSourceDevice() { + return envelope.hasSourceDevice(); + } + + /** + * @return The envelope's sender device ID. + */ + public int getSourceDevice() { + return envelope.getSourceDevice(); + } + + /** + * @return The envelope's sender as a SignalServiceAddress. + */ + public SignalServiceAddress getSourceAddress() { + return new SignalServiceAddress(UuidUtil.parseOrNull(envelope.getSourceUuid()), envelope.getSourceE164()); + } + + /** + * @return The envelope content type. + */ + public int getType() { + return envelope.getType().getNumber(); + } + + /** + * @return The timestamp this envelope was sent. + */ + public long getTimestamp() { + return envelope.getTimestamp(); + } + + public long getServerTimestamp() { + return envelope.getServerTimestamp(); + } + + /** + * @return Whether the envelope contains a SignalServiceDataMessage + */ + public boolean hasLegacyMessage() { + return envelope.hasLegacyMessage(); + } + + /** + * @return The envelope's containing SignalService message. + */ + public byte[] getLegacyMessage() { + return envelope.getLegacyMessage().toByteArray(); + } + + /** + * @return Whether the envelope contains an encrypted SignalServiceContent + */ + public boolean hasContent() { + return envelope.hasContent(); + } + + /** + * @return The envelope's encrypted SignalServiceContent. + */ + public byte[] getContent() { + return envelope.getContent().toByteArray(); + } + + /** + * @return true if the containing message is a {@link org.whispersystems.libsignal.protocol.SignalMessage} + */ + public boolean isSignalMessage() { + return envelope.getType().getNumber() == Envelope.Type.CIPHERTEXT_VALUE; + } + + /** + * @return true if the containing message is a {@link org.whispersystems.libsignal.protocol.PreKeySignalMessage} + */ + public boolean isPreKeySignalMessage() { + return envelope.getType().getNumber() == Envelope.Type.PREKEY_BUNDLE_VALUE; + } + + /** + * @return true if the containing message is a delivery receipt. + */ + public boolean isReceipt() { + return envelope.getType().getNumber() == Envelope.Type.RECEIPT_VALUE; + } + + public boolean isUnidentifiedSender() { + return envelope.getType().getNumber() == Envelope.Type.UNIDENTIFIED_SENDER_VALUE; + } + + private byte[] getPlaintext(byte[] ciphertext, SecretKeySpec cipherKey) throws IOException { + try { + byte[] ivBytes = new byte[IV_LENGTH]; + System.arraycopy(ciphertext, IV_OFFSET, ivBytes, 0, ivBytes.length); + IvParameterSpec iv = new IvParameterSpec(ivBytes); + + Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding"); + cipher.init(Cipher.DECRYPT_MODE, cipherKey, iv); + + return cipher.doFinal(ciphertext, CIPHERTEXT_OFFSET, + ciphertext.length - VERSION_LENGTH - IV_LENGTH - MAC_SIZE); + } catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException | InvalidAlgorithmParameterException | IllegalBlockSizeException e) { + throw new AssertionError(e); + } catch (BadPaddingException e) { + Log.w(TAG, e); + throw new IOException("Bad padding?"); + } + } + + private void verifyMac(byte[] ciphertext, SecretKeySpec macKey) throws IOException { + try { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(macKey); + + if (ciphertext.length < MAC_SIZE + 1) + throw new IOException("Invalid MAC!"); + + mac.update(ciphertext, 0, ciphertext.length - MAC_SIZE); + + byte[] ourMacFull = mac.doFinal(); + byte[] ourMacBytes = new byte[MAC_SIZE]; + System.arraycopy(ourMacFull, 0, ourMacBytes, 0, ourMacBytes.length); + + byte[] theirMacBytes = new byte[MAC_SIZE]; + System.arraycopy(ciphertext, ciphertext.length-MAC_SIZE, theirMacBytes, 0, theirMacBytes.length); + + Log.w(TAG, "Our MAC: " + Hex.toString(ourMacBytes)); + Log.w(TAG, "Thr MAC: " + Hex.toString(theirMacBytes)); + + if (!Arrays.equals(ourMacBytes, theirMacBytes)) { + throw new IOException("Invalid MAC compare!"); + } + } catch (NoSuchAlgorithmException | InvalidKeyException e) { + throw new AssertionError(e); + } + } + + + private SecretKeySpec getCipherKey(String signalingKey) throws IOException { + byte[] signalingKeyBytes = Base64.decode(signalingKey); + byte[] cipherKey = new byte[CIPHER_KEY_SIZE]; + System.arraycopy(signalingKeyBytes, 0, cipherKey, 0, cipherKey.length); + + return new SecretKeySpec(cipherKey, "AES"); + } + + + private SecretKeySpec getMacKey(String signalingKey) throws IOException { + byte[] signalingKeyBytes = Base64.decode(signalingKey); + byte[] macKey = new byte[MAC_KEY_SIZE]; + System.arraycopy(signalingKeyBytes, CIPHER_KEY_SIZE, macKey, 0, macKey.length); + + return new SecretKeySpec(macKey, "HmacSHA256"); + } + +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceGroup.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceGroup.java new file mode 100644 index 000000000..ed22e67d6 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceGroup.java @@ -0,0 +1,143 @@ +/** + * Copyright (C) 2014-2016 Open Whisper Systems + * + * Licensed according to the LICENSE file in this repository. + */ + +package org.whispersystems.signalservice.api.messages; + +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.push.SignalServiceAddress; + +import java.util.List; + +/** + * Group information to include in SignalServiceMessages destined to groups. + * + * This class represents a "context" that is included with Signal Service messages + * to make them group messages. There are three types of context: + * + * 1) Update -- Sent when either creating a group, or updating the properties + * of a group (such as the avatar icon, membership list, or title). + * 2) Deliver -- Sent when a message is to be delivered to an existing group. + * 3) Quit -- Sent when the sender wishes to leave an existing group. + * + * @author Moxie Marlinspike + */ +public class SignalServiceGroup { + + public enum Type { + UNKNOWN, + UPDATE, + DELIVER, + QUIT, + REQUEST_INFO + } + + private final byte[] groupId; + private final Type type; + private final Optional name; + private final Optional> members; + private final Optional avatar; + + + /** + * Construct a DELIVER group context. + * @param groupId + */ + public SignalServiceGroup(byte[] groupId) { + this(Type.DELIVER, groupId, null, null, null); + } + + /** + * Construct a group context. + * @param type The group message type (update, deliver, quit). + * @param groupId The group ID. + * @param name The group title. + * @param members The group membership list. + * @param avatar The group avatar icon. + */ + public SignalServiceGroup(Type type, byte[] groupId, String name, + List members, + SignalServiceAttachment avatar) + { + this.type = type; + this.groupId = groupId; + this.name = Optional.fromNullable(name); + this.members = Optional.fromNullable(members); + this.avatar = Optional.fromNullable(avatar); + } + + public byte[] getGroupId() { + return groupId; + } + + public Type getType() { + return type; + } + + public Optional getName() { + return name; + } + + public Optional> getMembers() { + return members; + } + + public Optional getAvatar() { + return avatar; + } + + public static Builder newUpdateBuilder() { + return new Builder(Type.UPDATE); + } + + public static Builder newBuilder(Type type) { + return new Builder(type); + } + + public static class Builder { + + private Type type; + private byte[] id; + private String name; + private List members; + private SignalServiceAttachment avatar; + + private Builder(Type type) { + this.type = type; + } + + public Builder withId(byte[] id) { + this.id = id; + return this; + } + + public Builder withName(String name) { + this.name = name; + return this; + } + + public Builder withMembers(List members) { + this.members = members; + return this; + } + + public Builder withAvatar(SignalServiceAttachment avatar) { + this.avatar = avatar; + return this; + } + + public SignalServiceGroup build() { + if (id == null) throw new IllegalArgumentException("No group ID specified!"); + + if (type == Type.UPDATE && name == null && members == null && avatar == null) { + throw new IllegalArgumentException("Group update with no updates!"); + } + + return new SignalServiceGroup(type, id, name, members, avatar); + } + + } + +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceReceiptMessage.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceReceiptMessage.java new file mode 100644 index 000000000..be9475c96 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceReceiptMessage.java @@ -0,0 +1,41 @@ +package org.whispersystems.signalservice.api.messages; + + +import java.util.List; + +public class SignalServiceReceiptMessage { + + public enum Type { + UNKNOWN, DELIVERY, READ + } + + private final Type type; + private final List timestamps; + private final long when; + + public SignalServiceReceiptMessage(Type type, List timestamps, long when) { + this.type = type; + this.timestamps = timestamps; + this.when = when; + } + + public Type getType() { + return type; + } + + public List getTimestamps() { + return timestamps; + } + + public long getWhen() { + return when; + } + + public boolean isDeliveryReceipt() { + return type == Type.DELIVERY; + } + + public boolean isReadReceipt() { + return type == Type.READ; + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceStickerManifest.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceStickerManifest.java new file mode 100644 index 000000000..419ea4cba --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceStickerManifest.java @@ -0,0 +1,56 @@ +package org.whispersystems.signalservice.api.messages; + +import org.whispersystems.libsignal.util.guava.Optional; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public class SignalServiceStickerManifest { + + private final Optional title; + private final Optional author; + private final Optional cover; + private final List stickers; + + public SignalServiceStickerManifest(String title, String author, StickerInfo cover, List stickers) { + this.title = Optional.of(title); + this.author = Optional.of(author); + this.cover = Optional.of(cover); + this.stickers = (stickers == null) ? Collections.emptyList() : new ArrayList<>(stickers); + } + + public Optional getTitle() { + return title; + } + + public Optional getAuthor() { + return author; + } + + public Optional getCover() { + return cover; + } + + public List getStickers() { + return stickers; + } + + public static final class StickerInfo { + private final int id; + private final String emoji; + + public StickerInfo(int id, String emoji) { + this.id = id; + this.emoji = emoji; + } + + public int getId() { + return id; + } + + public String getEmoji() { + return emoji; + } + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceTypingMessage.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceTypingMessage.java new file mode 100644 index 000000000..1a83d945d --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceTypingMessage.java @@ -0,0 +1,40 @@ +package org.whispersystems.signalservice.api.messages; + +import org.whispersystems.libsignal.util.guava.Optional; + +public class SignalServiceTypingMessage { + + public enum Action { + UNKNOWN, STARTED, STOPPED + } + + private final Action action; + private final long timestamp; + private final Optional groupId; + + public SignalServiceTypingMessage(Action action, long timestamp, Optional groupId) { + this.action = action; + this.timestamp = timestamp; + this.groupId = groupId; + } + + public Action getAction() { + return action; + } + + public long getTimestamp() { + return timestamp; + } + + public Optional getGroupId() { + return groupId; + } + + public boolean isTypingStarted() { + return action == Action.STARTED; + } + + public boolean isTypingStopped() { + return action == Action.STOPPED; + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/calls/AnswerMessage.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/calls/AnswerMessage.java new file mode 100644 index 000000000..0ce68baad --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/calls/AnswerMessage.java @@ -0,0 +1,21 @@ +package org.whispersystems.signalservice.api.messages.calls; + + +public class AnswerMessage { + + private final long id; + private final String description; + + public AnswerMessage(long id, String description) { + this.id = id; + this.description = description; + } + + public String getDescription() { + return description; + } + + public long getId() { + return id; + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/calls/BusyMessage.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/calls/BusyMessage.java new file mode 100644 index 000000000..78de6cab7 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/calls/BusyMessage.java @@ -0,0 +1,15 @@ +package org.whispersystems.signalservice.api.messages.calls; + + +public class BusyMessage { + + private final long id; + + public BusyMessage(long id) { + this.id = id; + } + + public long getId() { + return id; + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/calls/HangupMessage.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/calls/HangupMessage.java new file mode 100644 index 000000000..f04ddc288 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/calls/HangupMessage.java @@ -0,0 +1,15 @@ +package org.whispersystems.signalservice.api.messages.calls; + + +public class HangupMessage { + + private final long id; + + public HangupMessage(long id) { + this.id = id; + } + + public long getId() { + return id; + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/calls/IceUpdateMessage.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/calls/IceUpdateMessage.java new file mode 100644 index 000000000..0e53bea89 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/calls/IceUpdateMessage.java @@ -0,0 +1,33 @@ +package org.whispersystems.signalservice.api.messages.calls; + + +public class IceUpdateMessage { + + private final long id; + private final String sdpMid; + private final int sdpMLineIndex; + private final String sdp; + + public IceUpdateMessage(long id, String sdpMid, int sdpMLineIndex, String sdp) { + this.id = id; + this.sdpMid = sdpMid; + this.sdpMLineIndex = sdpMLineIndex; + this.sdp = sdp; + } + + public String getSdpMid() { + return sdpMid; + } + + public int getSdpMLineIndex() { + return sdpMLineIndex; + } + + public String getSdp() { + return sdp; + } + + public long getId() { + return id; + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/calls/OfferMessage.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/calls/OfferMessage.java new file mode 100644 index 000000000..4bdccc448 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/calls/OfferMessage.java @@ -0,0 +1,21 @@ +package org.whispersystems.signalservice.api.messages.calls; + + +public class OfferMessage { + + private final long id; + private final String description; + + public OfferMessage(long id, String description) { + this.id = id; + this.description = description; + } + + public String getDescription() { + return description; + } + + public long getId() { + return id; + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/calls/SignalServiceCallMessage.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/calls/SignalServiceCallMessage.java new file mode 100644 index 000000000..db5b1e703 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/calls/SignalServiceCallMessage.java @@ -0,0 +1,108 @@ +package org.whispersystems.signalservice.api.messages.calls; + +import org.whispersystems.libsignal.util.guava.Optional; + +import java.util.LinkedList; +import java.util.List; + +public class SignalServiceCallMessage { + + private final Optional offerMessage; + private final Optional answerMessage; + private final Optional hangupMessage; + private final Optional busyMessage; + private final Optional> iceUpdateMessages; + + private SignalServiceCallMessage(Optional offerMessage, + Optional answerMessage, + Optional> iceUpdateMessages, + Optional hangupMessage, + Optional busyMessage) + { + this.offerMessage = offerMessage; + this.answerMessage = answerMessage; + this.iceUpdateMessages = iceUpdateMessages; + this.hangupMessage = hangupMessage; + this.busyMessage = busyMessage; + } + + public static SignalServiceCallMessage forOffer(OfferMessage offerMessage) { + return new SignalServiceCallMessage(Optional.of(offerMessage), + Optional.absent(), + Optional.>absent(), + Optional.absent(), + Optional.absent()); + } + + public static SignalServiceCallMessage forAnswer(AnswerMessage answerMessage) { + return new SignalServiceCallMessage(Optional.absent(), + Optional.of(answerMessage), + Optional.>absent(), + Optional.absent(), + Optional.absent()); + } + + public static SignalServiceCallMessage forIceUpdates(List iceUpdateMessages) { + return new SignalServiceCallMessage(Optional.absent(), + Optional.absent(), + Optional.of(iceUpdateMessages), + Optional.absent(), + Optional.absent()); + } + + public static SignalServiceCallMessage forIceUpdate(final IceUpdateMessage iceUpdateMessage) { + List iceUpdateMessages = new LinkedList<>(); + iceUpdateMessages.add(iceUpdateMessage); + + return new SignalServiceCallMessage(Optional.absent(), + Optional.absent(), + Optional.of(iceUpdateMessages), + Optional.absent(), + Optional.absent()); + } + + public static SignalServiceCallMessage forHangup(HangupMessage hangupMessage) { + return new SignalServiceCallMessage(Optional.absent(), + Optional.absent(), + Optional.>absent(), + Optional.of(hangupMessage), + Optional.absent()); + } + + public static SignalServiceCallMessage forBusy(BusyMessage busyMessage) { + return new SignalServiceCallMessage(Optional.absent(), + Optional.absent(), + Optional.>absent(), + Optional.absent(), + Optional.of(busyMessage)); + } + + + public static SignalServiceCallMessage empty() { + return new SignalServiceCallMessage(Optional.absent(), + Optional.absent(), + Optional.>absent(), + Optional.absent(), + Optional.absent()); + } + + public Optional> getIceUpdateMessages() { + return iceUpdateMessages; + } + + public Optional getAnswerMessage() { + return answerMessage; + } + + public Optional getOfferMessage() { + return offerMessage; + } + + public Optional getHangupMessage() { + return hangupMessage; + } + + public Optional getBusyMessage() { + return busyMessage; + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/calls/TurnServerInfo.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/calls/TurnServerInfo.java new file mode 100644 index 000000000..182caf671 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/calls/TurnServerInfo.java @@ -0,0 +1,30 @@ +package org.whispersystems.signalservice.api.messages.calls; + + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; + +public class TurnServerInfo { + + @JsonProperty + private String username; + + @JsonProperty + private String password; + + @JsonProperty + private List urls; + + public String getUsername() { + return username; + } + + public String getPassword() { + return password; + } + + public List getUrls() { + return urls; + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/BlockedListMessage.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/BlockedListMessage.java new file mode 100644 index 000000000..31652c0c5 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/BlockedListMessage.java @@ -0,0 +1,24 @@ +package org.whispersystems.signalservice.api.messages.multidevice; + +import org.whispersystems.signalservice.api.push.SignalServiceAddress; + +import java.util.List; + +public class BlockedListMessage { + + private final List addresses; + private final List groupIds; + + public BlockedListMessage(List addresses, List groupIds) { + this.addresses = addresses; + this.groupIds = groupIds; + } + + public List getAddresses() { + return addresses; + } + + public List getGroupIds() { + return groupIds; + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/ChunkedInputStream.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/ChunkedInputStream.java new file mode 100644 index 000000000..75f29b559 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/ChunkedInputStream.java @@ -0,0 +1,116 @@ +package org.whispersystems.signalservice.api.messages.multidevice; + +import java.io.FilterInputStream; +import java.io.IOException; +import java.io.InputStream; + +public class ChunkedInputStream { + + protected final InputStream in; + + public ChunkedInputStream(InputStream in) { + this.in = in; + } + + protected int readRawVarint32() throws IOException { + byte tmp = (byte)in.read(); + if (tmp >= 0) { + return tmp; + } + int result = tmp & 0x7f; + if ((tmp = (byte)in.read()) >= 0) { + result |= tmp << 7; + } else { + result |= (tmp & 0x7f) << 7; + if ((tmp = (byte)in.read()) >= 0) { + result |= tmp << 14; + } else { + result |= (tmp & 0x7f) << 14; + if ((tmp = (byte)in.read()) >= 0) { + result |= tmp << 21; + } else { + result |= (tmp & 0x7f) << 21; + result |= (tmp = (byte)in.read()) << 28; + if (tmp < 0) { + // Discard upper 32 bits. + for (int i = 0; i < 5; i++) { + if ((byte)in.read() >= 0) { + return result; + } + } + + throw new IOException("Malformed varint!"); + } + } + } + } + + return result; + } + + protected static final class LimitedInputStream extends FilterInputStream { + + private long left; + private long mark = -1; + + LimitedInputStream(InputStream in, long limit) { + super(in); + left = limit; + } + + @Override public int available() throws IOException { + return (int) Math.min(in.available(), left); + } + + // it's okay to mark even if mark isn't supported, as reset won't work + @Override public synchronized void mark(int readLimit) { + in.mark(readLimit); + mark = left; + } + + @Override public int read() throws IOException { + if (left == 0) { + return -1; + } + + int result = in.read(); + if (result != -1) { + --left; + } + return result; + } + + @Override public int read(byte[] b, int off, int len) throws IOException { + if (left == 0) { + return -1; + } + + len = (int) Math.min(len, left); + int result = in.read(b, off, len); + if (result != -1) { + left -= result; + } + return result; + } + + @Override public synchronized void reset() throws IOException { + if (!in.markSupported()) { + throw new IOException("Mark not supported"); + } + if (mark == -1) { + throw new IOException("Mark not set"); + } + + in.reset(); + left = mark; + } + + @Override public long skip(long n) throws IOException { + n = Math.min(n, left); + long skipped = in.skip(n); + left -= skipped; + return skipped; + } + } + +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/ChunkedOutputStream.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/ChunkedOutputStream.java new file mode 100644 index 000000000..683cfdcc3 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/ChunkedOutputStream.java @@ -0,0 +1,38 @@ +package org.whispersystems.signalservice.api.messages.multidevice; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +public class ChunkedOutputStream { + + protected final OutputStream out; + + public ChunkedOutputStream(OutputStream out) { + this.out = out; + } + + protected void writeVarint32(int value) throws IOException { + while (true) { + if ((value & ~0x7F) == 0) { + out.write(value); + return; + } else { + out.write((value & 0x7F) | 0x80); + value >>>= 7; + } + } + } + + protected void writeStream(InputStream in) throws IOException { + byte[] buffer = new byte[4096]; + int read; + + while ((read = in.read(buffer)) != -1) { + out.write(buffer, 0, read); + } + + in.close(); + } + +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/ConfigurationMessage.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/ConfigurationMessage.java new file mode 100644 index 000000000..338f23441 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/ConfigurationMessage.java @@ -0,0 +1,39 @@ +package org.whispersystems.signalservice.api.messages.multidevice; + + +import org.whispersystems.libsignal.util.guava.Optional; + +public class ConfigurationMessage { + + private final Optional readReceipts; + private final Optional unidentifiedDeliveryIndicators; + private final Optional typingIndicators; + private final Optional linkPreviews; + + public ConfigurationMessage(Optional readReceipts, + Optional unidentifiedDeliveryIndicators, + Optional typingIndicators, + Optional linkPreviews) + { + this.readReceipts = readReceipts; + this.unidentifiedDeliveryIndicators = unidentifiedDeliveryIndicators; + this.typingIndicators = typingIndicators; + this.linkPreviews = linkPreviews; + } + + public Optional getReadReceipts() { + return readReceipts; + } + + public Optional getUnidentifiedDeliveryIndicators() { + return unidentifiedDeliveryIndicators; + } + + public Optional getTypingIndicators() { + return typingIndicators; + } + + public Optional getLinkPreviews() { + return linkPreviews; + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/ContactsMessage.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/ContactsMessage.java new file mode 100644 index 000000000..7aaf141c4 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/ContactsMessage.java @@ -0,0 +1,23 @@ +package org.whispersystems.signalservice.api.messages.multidevice; + + +import org.whispersystems.signalservice.api.messages.SignalServiceAttachment; + +public class ContactsMessage { + + private final SignalServiceAttachment contacts; + private final boolean complete; + + public ContactsMessage(SignalServiceAttachment contacts, boolean complete) { + this.contacts = contacts; + this.complete = complete; + } + + public SignalServiceAttachment getContactsStream() { + return contacts; + } + + public boolean isComplete() { + return complete; + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/DeviceContact.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/DeviceContact.java new file mode 100644 index 000000000..839a86fdc --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/DeviceContact.java @@ -0,0 +1,73 @@ +/** + * Copyright (C) 2014-2016 Open Whisper Systems + * + * Licensed according to the LICENSE file in this repository. + */ + +package org.whispersystems.signalservice.api.messages.multidevice; + +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentStream; +import org.whispersystems.signalservice.api.push.SignalServiceAddress; + +public class DeviceContact { + + private final SignalServiceAddress address; + private final Optional name; + private final Optional avatar; + private final Optional color; + private final Optional verified; + private final Optional profileKey; + private final boolean blocked; + private final Optional expirationTimer; + + public DeviceContact(SignalServiceAddress address, Optional name, + Optional avatar, + Optional color, + Optional verified, + Optional profileKey, + boolean blocked, + Optional expirationTimer) + { + this.address = address; + this.name = name; + this.avatar = avatar; + this.color = color; + this.verified = verified; + this.profileKey = profileKey; + this.blocked = blocked; + this.expirationTimer = expirationTimer; + } + + public Optional getAvatar() { + return avatar; + } + + public Optional getName() { + return name; + } + + public SignalServiceAddress getAddress() { + return address; + } + + public Optional getColor() { + return color; + } + + public Optional getVerified() { + return verified; + } + + public Optional getProfileKey() { + return profileKey; + } + + public boolean isBlocked() { + return blocked; + } + + public Optional getExpirationTimer() { + return expirationTimer; + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/DeviceContactsInputStream.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/DeviceContactsInputStream.java new file mode 100644 index 000000000..46061c69e --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/DeviceContactsInputStream.java @@ -0,0 +1,97 @@ +/* + * Copyright (C) 2014-2018 Open Whisper Systems + * + * Licensed according to the LICENSE file in this repository. + */ + +package org.whispersystems.signalservice.api.messages.multidevice; + +import org.whispersystems.libsignal.IdentityKey; +import org.whispersystems.libsignal.InvalidKeyException; +import org.whispersystems.libsignal.InvalidMessageException; +import org.whispersystems.libsignal.logging.Log; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentStream; +import org.whispersystems.signalservice.api.push.SignalServiceAddress; +import org.whispersystems.signalservice.api.util.UuidUtil; +import org.whispersystems.signalservice.internal.push.SignalServiceProtos; +import org.whispersystems.signalservice.internal.util.Util; + +import java.io.IOException; +import java.io.InputStream; + +public class DeviceContactsInputStream extends ChunkedInputStream { + + private static final String TAG = DeviceContactsInputStream.class.getSimpleName(); + + public DeviceContactsInputStream(InputStream in) { + super(in); + } + + public DeviceContact read() throws IOException { + long detailsLength = readRawVarint32(); + byte[] detailsSerialized = new byte[(int)detailsLength]; + Util.readFully(in, detailsSerialized); + + SignalServiceProtos.ContactDetails details = SignalServiceProtos.ContactDetails.parseFrom(detailsSerialized); + + if (!SignalServiceAddress.isValidAddress(details.getUuid(), details.getNumber())) { + throw new IOException("Missing contact address!"); + } + + SignalServiceAddress address = new SignalServiceAddress(UuidUtil.parseOrNull(details.getUuid()), details.getNumber()); + Optional name = Optional.fromNullable(details.getName()); + Optional avatar = Optional.absent(); + Optional color = details.hasColor() ? Optional.of(details.getColor()) : Optional.absent(); + Optional verified = Optional.absent(); + Optional profileKey = Optional.absent(); + boolean blocked = false; + Optional expireTimer = Optional.absent(); + + if (details.hasAvatar()) { + long avatarLength = details.getAvatar().getLength(); + InputStream avatarStream = new LimitedInputStream(in, avatarLength); + String avatarContentType = details.getAvatar().getContentType(); + + avatar = Optional.of(new SignalServiceAttachmentStream(avatarStream, avatarContentType, avatarLength, Optional.absent(), false, null)); + } + + if (details.hasVerified()) { + try { + if (!SignalServiceAddress.isValidAddress(details.getVerified().getDestinationUuid(), details.getVerified().getDestinationE164())) { + throw new InvalidMessageException("Missing Verified address!"); + } + IdentityKey identityKey = new IdentityKey(details.getVerified().getIdentityKey().toByteArray(), 0); + SignalServiceAddress destination = new SignalServiceAddress(UuidUtil.parseOrNull(details.getVerified().getDestinationUuid()), + details.getVerified().getDestinationE164()); + + VerifiedMessage.VerifiedState state; + + switch (details.getVerified().getState()) { + case VERIFIED: state = VerifiedMessage.VerifiedState.VERIFIED; break; + case UNVERIFIED:state = VerifiedMessage.VerifiedState.UNVERIFIED; break; + case DEFAULT: state = VerifiedMessage.VerifiedState.DEFAULT; break; + default: throw new InvalidMessageException("Unknown state: " + details.getVerified().getState()); + } + + verified = Optional.of(new VerifiedMessage(destination, identityKey, state, System.currentTimeMillis())); + } catch (InvalidKeyException | InvalidMessageException e) { + Log.w(TAG, e); + verified = Optional.absent(); + } + } + + if (details.hasProfileKey()) { + profileKey = Optional.fromNullable(details.getProfileKey().toByteArray()); + } + + if (details.hasExpireTimer() && details.getExpireTimer() > 0) { + expireTimer = Optional.of(details.getExpireTimer()); + } + + blocked = details.getBlocked(); + + return new DeviceContact(address, name, avatar, color, verified, profileKey, blocked, expireTimer); + } + +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/DeviceContactsOutputStream.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/DeviceContactsOutputStream.java new file mode 100644 index 000000000..ca17e5b14 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/DeviceContactsOutputStream.java @@ -0,0 +1,103 @@ +/* + * Copyright (C) 2014-2018 Open Whisper Systems + * + * Licensed according to the LICENSE file in this repository. + */ + +package org.whispersystems.signalservice.api.messages.multidevice; + +import com.google.protobuf.ByteString; + +import org.whispersystems.signalservice.internal.push.SignalServiceProtos; + +import java.io.IOException; +import java.io.OutputStream; + +public class DeviceContactsOutputStream extends ChunkedOutputStream { + + public DeviceContactsOutputStream(OutputStream out) { + super(out); + } + + public void write(DeviceContact contact) throws IOException { + writeContactDetails(contact); + writeAvatarImage(contact); + } + + public void close() throws IOException { + out.close(); + } + + private void writeAvatarImage(DeviceContact contact) throws IOException { + if (contact.getAvatar().isPresent()) { + writeStream(contact.getAvatar().get().getInputStream()); + } + } + + private void writeContactDetails(DeviceContact contact) throws IOException { + SignalServiceProtos.ContactDetails.Builder contactDetails = SignalServiceProtos.ContactDetails.newBuilder(); + + if (contact.getAddress().getUuid().isPresent()) { + contactDetails.setUuid(contact.getAddress().getUuid().get().toString()); + } + + if (contact.getAddress().getNumber().isPresent()) { + contactDetails.setNumber(contact.getAddress().getNumber().get()); + } + + if (contact.getName().isPresent()) { + contactDetails.setName(contact.getName().get()); + } + + if (contact.getAvatar().isPresent()) { + SignalServiceProtos.ContactDetails.Avatar.Builder avatarBuilder = SignalServiceProtos.ContactDetails.Avatar.newBuilder(); + avatarBuilder.setContentType(contact.getAvatar().get().getContentType()); + avatarBuilder.setLength((int)contact.getAvatar().get().getLength()); + contactDetails.setAvatar(avatarBuilder); + } + + if (contact.getColor().isPresent()) { + contactDetails.setColor(contact.getColor().get()); + } + + if (contact.getVerified().isPresent()) { + SignalServiceProtos.Verified.State state; + + switch (contact.getVerified().get().getVerified()) { + case VERIFIED: state = SignalServiceProtos.Verified.State.VERIFIED; break; + case UNVERIFIED: state = SignalServiceProtos.Verified.State.UNVERIFIED; break; + default: state = SignalServiceProtos.Verified.State.DEFAULT; break; + } + + SignalServiceProtos.Verified.Builder verifiedBuilder = SignalServiceProtos.Verified.newBuilder() + .setIdentityKey(ByteString.copyFrom(contact.getVerified().get().getIdentityKey().serialize())) + .setState(state); + + if (contact.getVerified().get().getDestination().getUuid().isPresent()) { + verifiedBuilder.setDestinationUuid(contact.getVerified().get().getDestination().getUuid().get().toString()); + } + + if (contact.getVerified().get().getDestination().getNumber().isPresent()) { + verifiedBuilder.setDestinationE164(contact.getVerified().get().getDestination().getNumber().get()); + } + + contactDetails.setVerified(verifiedBuilder.build()); + } + + if (contact.getProfileKey().isPresent()) { + contactDetails.setProfileKey(ByteString.copyFrom(contact.getProfileKey().get())); + } + + if (contact.getExpirationTimer().isPresent()) { + contactDetails.setExpireTimer(contact.getExpirationTimer().get()); + } + + contactDetails.setBlocked(contact.isBlocked()); + + byte[] serializedContactDetails = contactDetails.build().toByteArray(); + + writeVarint32(serializedContactDetails.length); + out.write(serializedContactDetails); + } + +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/DeviceGroup.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/DeviceGroup.java new file mode 100644 index 000000000..ca7d89a01 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/DeviceGroup.java @@ -0,0 +1,72 @@ +/** + * Copyright (C) 2014-2016 Open Whisper Systems + * + * Licensed according to the LICENSE file in this repository. + */ + +package org.whispersystems.signalservice.api.messages.multidevice; + +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentStream; +import org.whispersystems.signalservice.api.push.SignalServiceAddress; + +import java.util.List; + +public class DeviceGroup { + + private final byte[] id; + private final Optional name; + private final List members; + private final Optional avatar; + private final boolean active; + private final Optional expirationTimer; + private final Optional color; + private final boolean blocked; + + public DeviceGroup(byte[] id, Optional name, List members, + Optional avatar, + boolean active, Optional expirationTimer, + Optional color, boolean blocked) + { + this.id = id; + this.name = name; + this.members = members; + this.avatar = avatar; + this.active = active; + this.expirationTimer = expirationTimer; + this.color = color; + this.blocked = blocked; + } + + public Optional getAvatar() { + return avatar; + } + + public Optional getName() { + return name; + } + + public byte[] getId() { + return id; + } + + public List getMembers() { + return members; + } + + public boolean isActive() { + return active; + } + + public Optional getExpirationTimer() { + return expirationTimer; + } + + public Optional getColor() { + return color; + } + + public boolean isBlocked() { + return blocked; + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/DeviceGroupsInputStream.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/DeviceGroupsInputStream.java new file mode 100644 index 000000000..b8addddcb --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/DeviceGroupsInputStream.java @@ -0,0 +1,72 @@ +/* + * Copyright (C) 2014-2018 Open Whisper Systems + * + * Licensed according to the LICENSE file in this repository. + */ + +package org.whispersystems.signalservice.api.messages.multidevice; + +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentStream; +import org.whispersystems.signalservice.api.push.SignalServiceAddress; +import org.whispersystems.signalservice.api.util.UuidUtil; +import org.whispersystems.signalservice.internal.push.SignalServiceProtos; +import org.whispersystems.signalservice.internal.push.SignalServiceProtos.GroupDetails; +import org.whispersystems.signalservice.internal.util.Util; + +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; + +public class DeviceGroupsInputStream extends ChunkedInputStream{ + + public DeviceGroupsInputStream(InputStream in) { + super(in); + } + + public DeviceGroup read() throws IOException { + long detailsLength = readRawVarint32(); + byte[] detailsSerialized = new byte[(int)detailsLength]; + Util.readFully(in, detailsSerialized); + + GroupDetails details = GroupDetails.parseFrom(detailsSerialized); + + if (!details.hasId()) { + throw new IOException("ID missing on group record!"); + } + + byte[] id = details.getId().toByteArray(); + Optional name = Optional.fromNullable(details.getName()); + List members = details.getMembersList(); + Optional avatar = Optional.absent(); + boolean active = details.getActive(); + Optional expirationTimer = Optional.absent(); + Optional color = Optional.fromNullable(details.getColor()); + boolean blocked = details.getBlocked(); + + if (details.hasAvatar()) { + long avatarLength = details.getAvatar().getLength(); + InputStream avatarStream = new ChunkedInputStream.LimitedInputStream(in, avatarLength); + String avatarContentType = details.getAvatar().getContentType(); + + avatar = Optional.of(new SignalServiceAttachmentStream(avatarStream, avatarContentType, avatarLength, Optional.absent(), false, null)); + } + + if (details.hasExpireTimer() && details.getExpireTimer() > 0) { + expirationTimer = Optional.of(details.getExpireTimer()); + } + + List addressMembers = new ArrayList<>(members.size()); + for (GroupDetails.Member member : members) { + if (SignalServiceAddress.isValidAddress(member.getUuid(), member.getE164())) { + addressMembers.add(new SignalServiceAddress(UuidUtil.parseOrNull(member.getUuid()), member.getE164())); + } else { + throw new IOException("Missing group member address!"); + } + } + + return new DeviceGroup(id, name, addressMembers, avatar, active, expirationTimer, color, blocked); + } + +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/DeviceGroupsOutputStream.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/DeviceGroupsOutputStream.java new file mode 100644 index 000000000..b59c17ae2 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/DeviceGroupsOutputStream.java @@ -0,0 +1,94 @@ +/** + * Copyright (C) 2014-2016 Open Whisper Systems + * + * Licensed according to the LICENSE file in this repository. + */ + +package org.whispersystems.signalservice.api.messages.multidevice; + +import com.google.protobuf.ByteString; + +import org.whispersystems.signalservice.api.push.SignalServiceAddress; +import org.whispersystems.signalservice.internal.push.SignalServiceProtos; +import org.whispersystems.signalservice.internal.push.SignalServiceProtos.GroupDetails; + +import java.io.IOException; +import java.io.OutputStream; +import java.util.ArrayList; +import java.util.List; + +public class DeviceGroupsOutputStream extends ChunkedOutputStream { + + public DeviceGroupsOutputStream(OutputStream out) { + super(out); + } + + public void write(DeviceGroup group) throws IOException { + writeGroupDetails(group); + writeAvatarImage(group); + } + + public void close() throws IOException { + out.close(); + } + + private void writeAvatarImage(DeviceGroup contact) throws IOException { + if (contact.getAvatar().isPresent()) { + writeStream(contact.getAvatar().get().getInputStream()); + } + } + + private void writeGroupDetails(DeviceGroup group) throws IOException { + GroupDetails.Builder groupDetails = GroupDetails.newBuilder(); + groupDetails.setId(ByteString.copyFrom(group.getId())); + + if (group.getName().isPresent()) { + groupDetails.setName(group.getName().get()); + } + + if (group.getAvatar().isPresent()) { + GroupDetails.Avatar.Builder avatarBuilder = GroupDetails.Avatar.newBuilder(); + avatarBuilder.setContentType(group.getAvatar().get().getContentType()); + avatarBuilder.setLength((int)group.getAvatar().get().getLength()); + groupDetails.setAvatar(avatarBuilder); + } + + if (group.getExpirationTimer().isPresent()) { + groupDetails.setExpireTimer(group.getExpirationTimer().get()); + } + + if (group.getColor().isPresent()) { + groupDetails.setColor(group.getColor().get()); + } + + List members = new ArrayList<>(group.getMembers().size()); + List membersE164 = new ArrayList<>(group.getMembers().size()); + + for (SignalServiceAddress address : group.getMembers()) { + GroupDetails.Member.Builder builder = GroupDetails.Member.newBuilder(); + + if (address.getUuid().isPresent()) { + builder.setUuid(address.getUuid().get().toString()); + } + + if (address.getNumber().isPresent()) { + builder.setE164(address.getNumber().get()); + membersE164.add(address.getNumber().get()); + } + + members.add(builder.build()); + } + + groupDetails.addAllMembers(members); + groupDetails.addAllMembersE164(membersE164); + groupDetails.setActive(group.isActive()); + groupDetails.setBlocked(group.isBlocked()); + + byte[] serializedContactDetails = groupDetails.build().toByteArray(); + + writeVarint32(serializedContactDetails.length); + out.write(serializedContactDetails); + } + + +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/DeviceInfo.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/DeviceInfo.java new file mode 100644 index 000000000..3fedaec13 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/DeviceInfo.java @@ -0,0 +1,42 @@ +/** + * Copyright (C) 2014-2016 Open Whisper Systems + * + * Licensed according to the LICENSE file in this repository. + */ + +package org.whispersystems.signalservice.api.messages.multidevice; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public class DeviceInfo { + + @JsonProperty + private long id; + + @JsonProperty + private String name; + + @JsonProperty + private long created; + + @JsonProperty + private long lastSeen; + + public DeviceInfo() {} + + public long getId() { + return id; + } + + public String getName() { + return name; + } + + public long getCreated() { + return created; + } + + public long getLastSeen() { + return lastSeen; + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/ReadMessage.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/ReadMessage.java new file mode 100644 index 000000000..fe503fc13 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/ReadMessage.java @@ -0,0 +1,29 @@ +/** + * Copyright (C) 2014-2016 Open Whisper Systems + * + * Licensed according to the LICENSE file in this repository. + */ + +package org.whispersystems.signalservice.api.messages.multidevice; + +import org.whispersystems.signalservice.api.push.SignalServiceAddress; + +public class ReadMessage { + + private final SignalServiceAddress sender; + private final long timestamp; + + public ReadMessage(SignalServiceAddress sender, long timestamp) { + this.sender = sender; + this.timestamp = timestamp; + } + + public long getTimestamp() { + return timestamp; + } + + public SignalServiceAddress getSender() { + return sender; + } + +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/RequestMessage.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/RequestMessage.java new file mode 100644 index 000000000..823d00cbd --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/RequestMessage.java @@ -0,0 +1,34 @@ +/** + * Copyright (C) 2014-2016 Open Whisper Systems + * + * Licensed according to the LICENSE file in this repository. + */ + +package org.whispersystems.signalservice.api.messages.multidevice; + +import org.whispersystems.signalservice.internal.push.SignalServiceProtos.SyncMessage.Request; + +public class RequestMessage { + + private final Request request; + + public RequestMessage(Request request) { + this.request = request; + } + + public boolean isContactsRequest() { + return request.getType() == Request.Type.CONTACTS; + } + + public boolean isGroupsRequest() { + return request.getType() == Request.Type.GROUPS; + } + + public boolean isBlockedListRequest() { + return request.getType() == Request.Type.BLOCKED; + } + + public boolean isConfigurationRequest() { + return request.getType() == Request.Type.CONFIGURATION; + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/SentTranscriptMessage.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/SentTranscriptMessage.java new file mode 100644 index 000000000..447599a96 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/SentTranscriptMessage.java @@ -0,0 +1,92 @@ +/** + * Copyright (C) 2014-2016 Open Whisper Systems + * + * Licensed according to the LICENSE file in this repository. + */ + +package org.whispersystems.signalservice.api.messages.multidevice; + +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage; +import org.whispersystems.signalservice.api.push.SignalServiceAddress; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +public class SentTranscriptMessage { + + private final Optional destination; + private final long timestamp; + private final long expirationStartTimestamp; + private final SignalServiceDataMessage message; + private final Map unidentifiedStatusByUuid; + private final Map unidentifiedStatusByE164; + private final Set recipients; + private final boolean isRecipientUpdate; + + public SentTranscriptMessage(Optional destination, long timestamp, SignalServiceDataMessage message, + long expirationStartTimestamp, Map unidentifiedStatus, + boolean isRecipientUpdate) + { + this.destination = destination; + this.timestamp = timestamp; + this.message = message; + this.expirationStartTimestamp = expirationStartTimestamp; + this.unidentifiedStatusByUuid = new HashMap<>(); + this.unidentifiedStatusByE164 = new HashMap<>(); + this.recipients = unidentifiedStatus.keySet(); + this.isRecipientUpdate = isRecipientUpdate; + + for (Map.Entry entry : unidentifiedStatus.entrySet()) { + if (entry.getKey().getUuid().isPresent()) { + unidentifiedStatusByUuid.put(entry.getKey().getUuid().get().toString(), entry.getValue()); + } + if (entry.getKey().getNumber().isPresent()) { + unidentifiedStatusByE164.put(entry.getKey().getNumber().get(), entry.getValue()); + } + } + } + + public Optional getDestination() { + return destination; + } + + public long getTimestamp() { + return timestamp; + } + + public long getExpirationStartTimestamp() { + return expirationStartTimestamp; + } + + public SignalServiceDataMessage getMessage() { + return message; + } + + public boolean isUnidentified(UUID uuid) { + return isUnidentified(uuid.toString()); + } + + public boolean isUnidentified(String destination) { + if (unidentifiedStatusByUuid.containsKey(destination)) { + return unidentifiedStatusByUuid.get(destination); + } else if (unidentifiedStatusByE164.containsKey(destination)) { + return unidentifiedStatusByE164.get(destination); + } else { + return false; + } + } + + public Set getRecipients() { + return recipients; + } + + public boolean isRecipientUpdate() { + return isRecipientUpdate; + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/SignalServiceSyncMessage.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/SignalServiceSyncMessage.java new file mode 100644 index 000000000..1a6eb5d51 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/SignalServiceSyncMessage.java @@ -0,0 +1,287 @@ +/** + * Copyright (C) 2014-2016 Open Whisper Systems + * + * Licensed according to the LICENSE file in this repository. + */ + +package org.whispersystems.signalservice.api.messages.multidevice; + +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.messages.SignalServiceAttachment; + +import java.util.LinkedList; +import java.util.List; + +public class SignalServiceSyncMessage { + + private final Optional sent; + private final Optional contacts; + private final Optional groups; + private final Optional blockedList; + private final Optional request; + private final Optional> reads; + private final Optional viewOnceOpen; + private final Optional verified; + private final Optional configuration; + private final Optional> stickerPackOperations; + private final Optional fetchType; + + private SignalServiceSyncMessage(Optional sent, + Optional contacts, + Optional groups, + Optional blockedList, + Optional request, + Optional> reads, + Optional viewOnceOpen, + Optional verified, + Optional configuration, + Optional> stickerPackOperations, + Optional fetchType) + { + this.sent = sent; + this.contacts = contacts; + this.groups = groups; + this.blockedList = blockedList; + this.request = request; + this.reads = reads; + this.viewOnceOpen = viewOnceOpen; + this.verified = verified; + this.configuration = configuration; + this.stickerPackOperations = stickerPackOperations; + this.fetchType = fetchType; + } + + public static SignalServiceSyncMessage forSentTranscript(SentTranscriptMessage sent) { + return new SignalServiceSyncMessage(Optional.of(sent), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.>absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.>absent(), + Optional.absent()); + } + + public static SignalServiceSyncMessage forContacts(ContactsMessage contacts) { + return new SignalServiceSyncMessage(Optional.absent(), + Optional.of(contacts), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.>absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.>absent(), + Optional.absent()); + } + + public static SignalServiceSyncMessage forGroups(SignalServiceAttachment groups) { + return new SignalServiceSyncMessage(Optional.absent(), + Optional.absent(), + Optional.of(groups), + Optional.absent(), + Optional.absent(), + Optional.>absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.>absent(), + Optional.absent()); + } + + public static SignalServiceSyncMessage forRequest(RequestMessage request) { + return new SignalServiceSyncMessage(Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.of(request), + Optional.>absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.>absent(), + Optional.absent()); + } + + public static SignalServiceSyncMessage forRead(List reads) { + return new SignalServiceSyncMessage(Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.of(reads), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.>absent(), + Optional.absent()); + } + + public static SignalServiceSyncMessage forViewOnceOpen(ViewOnceOpenMessage timerRead) { + return new SignalServiceSyncMessage(Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.>absent(), + Optional.of(timerRead), + Optional.absent(), + Optional.absent(), + Optional.>absent(), + Optional.absent()); + } + + public static SignalServiceSyncMessage forRead(ReadMessage read) { + List reads = new LinkedList<>(); + reads.add(read); + + return new SignalServiceSyncMessage(Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.of(reads), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.>absent(), + Optional.absent()); + } + + public static SignalServiceSyncMessage forVerified(VerifiedMessage verifiedMessage) { + return new SignalServiceSyncMessage(Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.>absent(), + Optional.absent(), + Optional.of(verifiedMessage), + Optional.absent(), + Optional.>absent(), + Optional.absent()); + } + + public static SignalServiceSyncMessage forBlocked(BlockedListMessage blocked) { + return new SignalServiceSyncMessage(Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.of(blocked), + Optional.absent(), + Optional.>absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.>absent(), + Optional.absent()); + } + + public static SignalServiceSyncMessage forConfiguration(ConfigurationMessage configuration) { + return new SignalServiceSyncMessage(Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.>absent(), + Optional.absent(), + Optional.absent(), + Optional.of(configuration), + Optional.>absent(), + Optional.absent()); + } + + public static SignalServiceSyncMessage forStickerPackOperations(List stickerPackOperations) { + return new SignalServiceSyncMessage(Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.>absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.of(stickerPackOperations), + Optional.absent()); + } + + public static SignalServiceSyncMessage forFetchLatest(FetchType fetchType) { + return new SignalServiceSyncMessage(Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.>absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.>absent(), + Optional.of(fetchType)); + } + + public static SignalServiceSyncMessage empty() { + return new SignalServiceSyncMessage(Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.>absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.>absent(), + Optional.absent()); + } + + public Optional getSent() { + return sent; + } + + public Optional getGroups() { + return groups; + } + + public Optional getContacts() { + return contacts; + } + + public Optional getRequest() { + return request; + } + + public Optional> getRead() { + return reads; + } + + public Optional getViewOnceOpen() { + return viewOnceOpen; + } + + public Optional getBlockedList() { + return blockedList; + } + + public Optional getVerified() { + return verified; + } + + public Optional getConfiguration() { + return configuration; + } + + public Optional> getStickerPackOperations() { + return stickerPackOperations; + } + + public Optional getFetchType() { + return fetchType; + } + + public enum FetchType { + LOCAL_PROFILE, + STORAGE_MANIFEST + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/StickerPackOperationMessage.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/StickerPackOperationMessage.java new file mode 100644 index 000000000..8e257f5ff --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/StickerPackOperationMessage.java @@ -0,0 +1,32 @@ +package org.whispersystems.signalservice.api.messages.multidevice; + +import org.whispersystems.libsignal.util.guava.Optional; + +public class StickerPackOperationMessage { + + private final Optional packId; + private final Optional packKey; + private final Optional type; + + public StickerPackOperationMessage(byte[] packId, byte[] packKey, Type type) { + this.packId = Optional.fromNullable(packId); + this.packKey = Optional.fromNullable(packKey); + this.type = Optional.fromNullable(type); + } + + public Optional getPackId() { + return packId; + } + + public Optional getPackKey() { + return packKey; + } + + public Optional getType() { + return type; + } + + public enum Type { + INSTALL, REMOVE + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/VerifiedMessage.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/VerifiedMessage.java new file mode 100644 index 000000000..501be584a --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/VerifiedMessage.java @@ -0,0 +1,40 @@ +package org.whispersystems.signalservice.api.messages.multidevice; + + +import org.whispersystems.libsignal.IdentityKey; +import org.whispersystems.signalservice.api.push.SignalServiceAddress; + +public class VerifiedMessage { + + public enum VerifiedState { + DEFAULT, VERIFIED, UNVERIFIED + } + + private final SignalServiceAddress destination; + private final IdentityKey identityKey; + private final VerifiedState verified; + private final long timestamp; + + public VerifiedMessage(SignalServiceAddress destination, IdentityKey identityKey, VerifiedState verified, long timestamp) { + this.destination = destination; + this.identityKey = identityKey; + this.verified = verified; + this.timestamp = timestamp; + } + + public SignalServiceAddress getDestination() { + return destination; + } + + public IdentityKey getIdentityKey() { + return identityKey; + } + + public VerifiedState getVerified() { + return verified; + } + + public long getTimestamp() { + return timestamp; + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/ViewOnceOpenMessage.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/ViewOnceOpenMessage.java new file mode 100644 index 000000000..5e3491d73 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/ViewOnceOpenMessage.java @@ -0,0 +1,23 @@ +package org.whispersystems.signalservice.api.messages.multidevice; + +import org.whispersystems.signalservice.api.push.SignalServiceAddress; + +public class ViewOnceOpenMessage { + + private final SignalServiceAddress sender; + private final long timestamp; + + public ViewOnceOpenMessage(SignalServiceAddress sender, long timestamp) { + this.sender = sender; + this.timestamp = timestamp; + } + + public long getTimestamp() { + return timestamp; + } + + public SignalServiceAddress getSender() { + return sender; + } + +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/shared/SharedContact.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/shared/SharedContact.java new file mode 100644 index 000000000..e779994ae --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/shared/SharedContact.java @@ -0,0 +1,513 @@ +package org.whispersystems.signalservice.api.messages.shared; + + +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.messages.SignalServiceAttachment; + +import java.util.LinkedList; +import java.util.List; + +public class SharedContact { + + private final Name name; + private final Optional avatar; + private final Optional> phone; + private final Optional> email; + private final Optional> address; + private final Optional organization; + + public SharedContact(Name name, + Optional avatar, + Optional> phone, + Optional> email, + Optional> address, + Optional organization) + { + this.name = name; + this.avatar = avatar; + this.phone = phone; + this.email = email; + this.address = address; + this.organization = organization; + } + + public static Builder newBuilder() { + return new Builder(); + } + + public Name getName() { + return name; + } + + public Optional getAvatar() { + return avatar; + } + + public Optional> getPhone() { + return phone; + } + + public Optional> getEmail() { + return email; + } + + public Optional> getAddress() { + return address; + } + + public Optional getOrganization() { + return organization; + } + + public static class Avatar { + private final SignalServiceAttachment attachment; + private final boolean isProfile; + + public Avatar(SignalServiceAttachment attachment, boolean isProfile) { + this.attachment = attachment; + this.isProfile = isProfile; + } + + public SignalServiceAttachment getAttachment() { + return attachment; + } + + public boolean isProfile() { + return isProfile; + } + + public static Builder newBuilder() { + return new Builder(); + } + + public static class Builder { + private SignalServiceAttachment attachment; + private boolean isProfile; + + public Builder withAttachment(SignalServiceAttachment attachment) { + this.attachment = attachment; + return this; + } + + public Builder withProfileFlag(boolean isProfile) { + this.isProfile = isProfile; + return this; + } + + public Avatar build() { + return new Avatar(attachment, isProfile); + } + } + } + + public static class Name { + + private final Optional display; + private final Optional given; + private final Optional family; + private final Optional prefix; + private final Optional suffix; + private final Optional middle; + + public Name(Optional display, Optional given, Optional family, Optional prefix, Optional suffix, Optional middle) { + this.display = display; + this.given = given; + this.family = family; + this.prefix = prefix; + this.suffix = suffix; + this.middle = middle; + } + + public static Builder newBuilder() { + return new Builder(); + } + + public Optional getDisplay() { + return display; + } + + public Optional getGiven() { + return given; + } + + public Optional getFamily() { + return family; + } + + public Optional getPrefix() { + return prefix; + } + + public Optional getSuffix() { + return suffix; + } + + public Optional getMiddle() { + return middle; + } + + public static class Builder { + private String display; + private String given; + private String family; + private String prefix; + private String suffix; + private String middle; + + public Builder setDisplay(String display) { + this.display = display; + return this; + } + + public Builder setGiven(String given) { + this.given = given; + return this; + } + + public Builder setFamily(String family) { + this.family = family; + return this; + } + + public Builder setPrefix(String prefix) { + this.prefix = prefix; + return this; + } + + public Builder setSuffix(String suffix) { + this.suffix = suffix; + return this; + } + + public Builder setMiddle(String middle) { + this.middle = middle; + return this; + } + + public Name build() { + return new Name(Optional.fromNullable(display), + Optional.fromNullable(given), + Optional.fromNullable(family), + Optional.fromNullable(prefix), + Optional.fromNullable(suffix), + Optional.fromNullable(middle)); + } + } + } + + public static class Phone { + + public enum Type { + HOME, WORK, MOBILE, CUSTOM + } + + private final String value; + private final Type type; + private final Optional label; + + public Phone(String value, Type type, Optional label) { + this.value = value; + this.type = type; + this.label = label; + } + + public static Builder newBuilder() { + return new Builder(); + } + + public String getValue() { + return value; + } + + public Type getType() { + return type; + } + + public Optional getLabel() { + return label; + } + + public static class Builder { + private String value; + private Type type; + private String label; + + public Builder setValue(String value) { + this.value = value; + return this; + } + + public Builder setType(Type type) { + this.type = type; + return this; + } + + public Builder setLabel(String label) { + this.label = label; + return this; + } + + public Phone build() { + return new Phone(value, type, Optional.fromNullable(label)); + } + } + } + + public static class Email { + + public enum Type { + HOME, WORK, MOBILE, CUSTOM + } + + private final String value; + private final Type type; + private final Optional label; + + public Email(String value, Type type, Optional label) { + this.value = value; + this.type = type; + this.label = label; + } + + public static Builder newBuilder() { + return new Builder(); + } + + public String getValue() { + return value; + } + + public Type getType() { + return type; + } + + public Optional getLabel() { + return label; + } + + public static class Builder { + private String value; + private Type type; + private String label; + + public Builder setValue(String value) { + this.value = value; + return this; + } + + public Builder setType(Type type) { + this.type = type; + return this; + } + + public Builder setLabel(String label) { + this.label = label; + return this; + } + + public Email build() { + return new Email(value, type, Optional.fromNullable(label)); + } + } + } + + public static class PostalAddress { + + public enum Type { + HOME, WORK, CUSTOM + } + + private final Type type; + private final Optional label; + private final Optional street; + private final Optional pobox; + private final Optional neighborhood; + private final Optional city; + private final Optional region; + private final Optional postcode; + private final Optional country; + + public PostalAddress(Type type, Optional label, Optional street, + Optional pobox, Optional neighborhood, + Optional city, Optional region, + Optional postcode, Optional country) + { + this.type = type; + this.label = label; + this.street = street; + this.pobox = pobox; + this.neighborhood = neighborhood; + this.city = city; + this.region = region; + this.postcode = postcode; + this.country = country; + } + + public static Builder newBuilder() { + return new Builder(); + } + + public Type getType() { + return type; + } + + public Optional getLabel() { + return label; + } + + public Optional getStreet() { + return street; + } + + public Optional getPobox() { + return pobox; + } + + public Optional getNeighborhood() { + return neighborhood; + } + + public Optional getCity() { + return city; + } + + public Optional getRegion() { + return region; + } + + public Optional getPostcode() { + return postcode; + } + + public Optional getCountry() { + return country; + } + + public static class Builder { + private Type type; + private String label; + private String street; + private String pobox; + private String neighborhood; + private String city; + private String region; + private String postcode; + private String country; + + public Builder setType(Type type) { + this.type = type; + return this; + } + + public Builder setLabel(String label) { + this.label = label; + return this; + } + + public Builder setStreet(String street) { + this.street = street; + return this; + } + + public Builder setPobox(String pobox) { + this.pobox = pobox; + return this; + } + + public Builder setNeighborhood(String neighborhood) { + this.neighborhood = neighborhood; + return this; + } + + public Builder setCity(String city) { + this.city = city; + return this; + } + + public Builder setRegion(String region) { + this.region = region; + return this; + } + + public Builder setPostcode(String postcode) { + this.postcode = postcode; + return this; + } + + public Builder setCountry(String country) { + this.country = country; + return this; + } + + public PostalAddress build() { + return new PostalAddress(type, Optional.fromNullable(label), Optional.fromNullable(street), + Optional.fromNullable(pobox), Optional.fromNullable(neighborhood), + Optional.fromNullable(city), Optional.fromNullable(region), + Optional.fromNullable(postcode), Optional.fromNullable(country)); + } + } + } + + public static class Builder { + private Name name; + private Avatar avatar; + private String organization; + + private List phone = new LinkedList<>(); + private List email = new LinkedList<>(); + private List address = new LinkedList<>(); + + public Builder setName(Name name) { + this.name = name; + return this; + } + + public Builder withOrganization(String organization) { + this.organization = organization; + return this; + } + + public Builder setAvatar(Avatar avatar) { + this.avatar = avatar; + return this; + } + + public Builder withPhone(Phone phone) { + this.phone.add(phone); + return this; + } + + public Builder withPhones(List phones) { + this.phone.addAll(phones); + return this; + } + + public Builder withEmail(Email email) { + this.email.add(email); + return this; + } + + public Builder withEmails(List emails) { + this.email.addAll(emails); + return this; + } + + public Builder withAddress(PostalAddress address) { + this.address.add(address); + return this; + } + + public Builder withAddresses(List addresses) { + this.address.addAll(addresses); + return this; + } + + public SharedContact build() { + return new SharedContact(name, Optional.fromNullable(avatar), + phone.isEmpty() ? Optional.>absent() : Optional.of(phone), + email.isEmpty() ? Optional.>absent() : Optional.of(email), + address.isEmpty() ? Optional.>absent() : Optional.of(address), + Optional.fromNullable(organization)); + } + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/profiles/SignalServiceProfile.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/profiles/SignalServiceProfile.java new file mode 100644 index 000000000..9fbe30767 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/profiles/SignalServiceProfile.java @@ -0,0 +1,62 @@ +package org.whispersystems.signalservice.api.profiles; + + +import com.fasterxml.jackson.annotation.JsonProperty; + +public class SignalServiceProfile { + + @JsonProperty + private String identityKey; + + @JsonProperty + private String name; + + @JsonProperty + private String avatar; + + @JsonProperty + private String unidentifiedAccess; + + @JsonProperty + private boolean unrestrictedUnidentifiedAccess; + + @JsonProperty + private Capabilities capabilities; + + public SignalServiceProfile() {} + + public String getIdentityKey() { + return identityKey; + } + + public String getName() { + return name; + } + + public String getAvatar() { + return avatar; + } + + public String getUnidentifiedAccess() { + return unidentifiedAccess; + } + + public boolean isUnrestrictedUnidentifiedAccess() { + return unrestrictedUnidentifiedAccess; + } + + public Capabilities getCapabilities() { + return capabilities; + } + + public static class Capabilities { + @JsonProperty + private boolean uuid; + + public Capabilities() {} + + public boolean isUuid() { + return uuid; + } + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/push/ContactTokenDetails.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/push/ContactTokenDetails.java new file mode 100644 index 000000000..8b6859f0c --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/push/ContactTokenDetails.java @@ -0,0 +1,69 @@ +/** + * Copyright (C) 2014-2016 Open Whisper Systems + * + * Licensed according to the LICENSE file in this repository. + */ + +package org.whispersystems.signalservice.api.push; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * A class that represents a contact's registration state. + */ +public class ContactTokenDetails { + + @JsonProperty + private String token; + + @JsonProperty + private String relay; + + @JsonProperty + private String number; + + @JsonProperty + private boolean voice; + + @JsonProperty + private boolean video; + + public ContactTokenDetails() {} + + /** + * @return The "anonymized" token (truncated hash) that's transmitted to the server. + */ + public String getToken() { + return token; + } + + /** + * @return The federated server this contact is registered with, or null if on your server. + */ + public String getRelay() { + return relay; + } + + /** + * @return Whether this contact supports secure voice calls. + */ + public boolean isVoice() { + return voice; + } + + public boolean isVideo() { + return video; + } + + public void setNumber(String number) { + this.number = number; + } + + /** + * @return This contact's username (e164 formatted number). + */ + public String getNumber() { + return number; + } + +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/push/SignalServiceAddress.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/push/SignalServiceAddress.java new file mode 100644 index 000000000..085464d94 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/push/SignalServiceAddress.java @@ -0,0 +1,119 @@ +/** + * Copyright (C) 2014-2016 Open Whisper Systems + * + * Licensed according to the LICENSE file in this repository. + */ + +package org.whispersystems.signalservice.api.push; + +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.util.UuidUtil; + +import java.util.UUID; + +/** + * A class representing a message destination or origin. + */ +public class SignalServiceAddress { + + public static final int DEFAULT_DEVICE_ID = 1; + + private final Optional uuid; + private final Optional e164; + private final Optional relay; + + /** + * Construct a PushAddress. + * + * @param uuid The UUID of the user, if available. + * @param e164 The phone number of the user, if available. + * @param relay The Signal service federated server this user is registered with (if not your own server). + */ + public SignalServiceAddress(Optional uuid, Optional e164, Optional relay) { + if (!uuid.isPresent() && !e164.isPresent()) { + throw new AssertionError("Must have either a UUID or E164 number!"); + } + + this.uuid = uuid; + this.e164 = e164; + this.relay = relay; + } + + /** + * Convenience constructor that will consider a UUID/E164 string absent if it is null or empty. + */ + public SignalServiceAddress(UUID uuid, String e164) { + this(Optional.fromNullable(uuid), + e164 != null && !e164.isEmpty() ? Optional.of(e164) : Optional.absent()); + } + + public SignalServiceAddress(Optional uuid, Optional e164) { + this(uuid, e164, Optional.absent()); + } + + public Optional getNumber() { + return e164; + } + + public Optional getUuid() { + return uuid; + } + + public String getIdentifier() { + if (uuid.isPresent()) { + return uuid.get().toString(); + } else if (e164.isPresent()) { + return e164.get(); + } else { + return null; + } + } + + public Optional getRelay() { + return relay; + } + + public boolean matches(SignalServiceAddress other) { + return (uuid.isPresent() && other.uuid.isPresent() && uuid.get().equals(other.uuid.get())) || + (e164.isPresent() && other.e164.isPresent() && e164.get().equals(other.e164.get())); + } + + public static boolean isValidAddress(String rawUuid, String e164) { + return (e164 != null && !e164.isEmpty()) || UuidUtil.parseOrNull(rawUuid) != null; + } + + public static Optional fromRaw(String rawUuid, String e164) { + if (isValidAddress(rawUuid, e164)) { + return Optional.of(new SignalServiceAddress(UuidUtil.parseOrNull(rawUuid), e164)); + } else { + return Optional.absent(); + } + } + + @Override + public boolean equals(Object other) { + if (other == null || !(other instanceof SignalServiceAddress)) return false; + + SignalServiceAddress that = (SignalServiceAddress)other; + + return equals(this.uuid, that.uuid) && + equals(this.e164, that.e164) && + equals(this.relay, that.relay); + } + + @Override + public int hashCode() { + int hashCode = 0; + + if (this.uuid != null) hashCode ^= this.uuid.hashCode(); + if (this.e164 != null) hashCode ^= this.e164.hashCode(); + if (this.relay.isPresent()) hashCode ^= this.relay.get().hashCode(); + + return hashCode; + } + + private boolean equals(Optional one, Optional two) { + if (one.isPresent()) return two.isPresent() && one.get().equals(two.get()); + else return !two.isPresent(); + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/push/SignedPreKeyEntity.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/push/SignedPreKeyEntity.java new file mode 100644 index 000000000..0908cb9ef --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/push/SignedPreKeyEntity.java @@ -0,0 +1,57 @@ +/** + * Copyright (C) 2014-2016 Open Whisper Systems + * + * Licensed according to the LICENSE file in this repository. + */ + +package org.whispersystems.signalservice.api.push; + +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.JsonSerializer; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; + +import org.whispersystems.libsignal.ecc.ECPublicKey; +import org.whispersystems.signalservice.internal.push.PreKeyEntity; +import org.whispersystems.util.Base64; + +import java.io.IOException; + +public class SignedPreKeyEntity extends PreKeyEntity { + + @JsonProperty + @JsonSerialize(using = ByteArraySerializer.class) + @JsonDeserialize(using = ByteArrayDeserializer.class) + private byte[] signature; + + public SignedPreKeyEntity() {} + + public SignedPreKeyEntity(int keyId, ECPublicKey publicKey, byte[] signature) { + super(keyId, publicKey); + this.signature = signature; + } + + public byte[] getSignature() { + return signature; + } + + private static class ByteArraySerializer extends JsonSerializer { + @Override + public void serialize(byte[] value, JsonGenerator gen, SerializerProvider serializers) throws IOException { + gen.writeString(Base64.encodeBytesWithoutPadding(value)); + } + } + + private static class ByteArrayDeserializer extends JsonDeserializer { + + @Override + public byte[] deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + return Base64.decodeWithoutPadding(p.getValueAsString()); + } + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/push/TrustStore.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/push/TrustStore.java new file mode 100644 index 000000000..5df0d13e5 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/push/TrustStore.java @@ -0,0 +1,19 @@ +/** + * Copyright (C) 2014-2016 Open Whisper Systems + * + * Licensed according to the LICENSE file in this repository. + */ + +package org.whispersystems.signalservice.api.push; + +import java.io.InputStream; + +/** + * A class that represents a Java {@link java.security.KeyStore} and + * its associated password. + */ +public interface TrustStore { + public InputStream getKeyStoreInputStream(); + public String getKeyStorePassword(); +} + diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/push/exceptions/AuthorizationFailedException.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/push/exceptions/AuthorizationFailedException.java new file mode 100644 index 000000000..d1daad144 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/push/exceptions/AuthorizationFailedException.java @@ -0,0 +1,13 @@ +/** + * Copyright (C) 2014-2016 Open Whisper Systems + * + * Licensed according to the LICENSE file in this repository. + */ + +package org.whispersystems.signalservice.api.push.exceptions; + +public class AuthorizationFailedException extends NonSuccessfulResponseCodeException { + public AuthorizationFailedException(String s) { + super(s); + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/push/exceptions/CaptchaRequiredException.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/push/exceptions/CaptchaRequiredException.java new file mode 100644 index 000000000..999ca3bce --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/push/exceptions/CaptchaRequiredException.java @@ -0,0 +1,4 @@ +package org.whispersystems.signalservice.api.push.exceptions; + +public class CaptchaRequiredException extends NonSuccessfulResponseCodeException { +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/push/exceptions/EncapsulatedExceptions.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/push/exceptions/EncapsulatedExceptions.java new file mode 100644 index 000000000..7e76a03ec --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/push/exceptions/EncapsulatedExceptions.java @@ -0,0 +1,47 @@ +/** + * Copyright (C) 2014-2016 Open Whisper Systems + * + * Licensed according to the LICENSE file in this repository. + */ +package org.whispersystems.signalservice.api.push.exceptions; + +import org.whispersystems.signalservice.api.crypto.UntrustedIdentityException; + +import java.util.LinkedList; +import java.util.List; + +public class EncapsulatedExceptions extends Throwable { + + private final List untrustedIdentityExceptions; + private final List unregisteredUserExceptions; + private final List networkExceptions; + + public EncapsulatedExceptions(List untrustedIdentities, + List unregisteredUsers, + List networkExceptions) + { + this.untrustedIdentityExceptions = untrustedIdentities; + this.unregisteredUserExceptions = unregisteredUsers; + this.networkExceptions = networkExceptions; + } + + public EncapsulatedExceptions(UntrustedIdentityException e) { + this.untrustedIdentityExceptions = new LinkedList<>(); + this.unregisteredUserExceptions = new LinkedList<>(); + this.networkExceptions = new LinkedList<>(); + + this.untrustedIdentityExceptions.add(e); + } + + public List getUntrustedIdentityExceptions() { + return untrustedIdentityExceptions; + } + + public List getUnregisteredUserExceptions() { + return unregisteredUserExceptions; + } + + public List getNetworkExceptions() { + return networkExceptions; + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/push/exceptions/ExpectationFailedException.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/push/exceptions/ExpectationFailedException.java new file mode 100644 index 000000000..1b3b76d7b --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/push/exceptions/ExpectationFailedException.java @@ -0,0 +1,9 @@ +/** + * Copyright (C) 2014-2016 Open Whisper Systems + * + * Licensed according to the LICENSE file in this repository. + */ +package org.whispersystems.signalservice.api.push.exceptions; + +public class ExpectationFailedException extends NonSuccessfulResponseCodeException { +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/push/exceptions/NetworkFailureException.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/push/exceptions/NetworkFailureException.java new file mode 100644 index 000000000..a35072909 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/push/exceptions/NetworkFailureException.java @@ -0,0 +1,21 @@ +/** + * Copyright (C) 2014-2016 Open Whisper Systems + * + * Licensed according to the LICENSE file in this repository. + */ + +package org.whispersystems.signalservice.api.push.exceptions; + +public class NetworkFailureException extends Exception { + + private final String e164number; + + public NetworkFailureException(String e164number, Exception nested) { + super(nested); + this.e164number = e164number; + } + + public String getE164number() { + return e164number; + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/push/exceptions/NonSuccessfulResponseCodeException.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/push/exceptions/NonSuccessfulResponseCodeException.java new file mode 100644 index 000000000..47bfde75c --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/push/exceptions/NonSuccessfulResponseCodeException.java @@ -0,0 +1,20 @@ +/** + * Copyright (C) 2014-2016 Open Whisper Systems + * + * Licensed according to the LICENSE file in this repository. + */ + +package org.whispersystems.signalservice.api.push.exceptions; + +import java.io.IOException; + +public class NonSuccessfulResponseCodeException extends IOException { + + public NonSuccessfulResponseCodeException() { + super(); + } + + public NonSuccessfulResponseCodeException(String s) { + super(s); + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/push/exceptions/NotFoundException.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/push/exceptions/NotFoundException.java new file mode 100644 index 000000000..97cd6bd17 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/push/exceptions/NotFoundException.java @@ -0,0 +1,13 @@ +/** + * Copyright (C) 2014-2016 Open Whisper Systems + * + * Licensed according to the LICENSE file in this repository. + */ + +package org.whispersystems.signalservice.api.push.exceptions; + +public class NotFoundException extends NonSuccessfulResponseCodeException { + public NotFoundException(String s) { + super(s); + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/push/exceptions/PushNetworkException.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/push/exceptions/PushNetworkException.java new file mode 100644 index 000000000..22f9a6589 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/push/exceptions/PushNetworkException.java @@ -0,0 +1,21 @@ +/** + * Copyright (C) 2014-2016 Open Whisper Systems + * + * Licensed according to the LICENSE file in this repository. + */ + +package org.whispersystems.signalservice.api.push.exceptions; + +import java.io.IOException; + +public class PushNetworkException extends IOException { + + public PushNetworkException(Exception exception) { + super(exception); + } + + public PushNetworkException(String s) { + super(s); + } + +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/push/exceptions/RateLimitException.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/push/exceptions/RateLimitException.java new file mode 100644 index 000000000..d5888a039 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/push/exceptions/RateLimitException.java @@ -0,0 +1,13 @@ +/** + * Copyright (C) 2014-2016 Open Whisper Systems + * + * Licensed according to the LICENSE file in this repository. + */ + +package org.whispersystems.signalservice.api.push.exceptions; + +public class RateLimitException extends NonSuccessfulResponseCodeException { + public RateLimitException(String s) { + super(s); + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/push/exceptions/RemoteAttestationResponseExpiredException.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/push/exceptions/RemoteAttestationResponseExpiredException.java new file mode 100644 index 000000000..a51dbec22 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/push/exceptions/RemoteAttestationResponseExpiredException.java @@ -0,0 +1,13 @@ +/* + * Copyright (C) 2019 Open Whisper Systems + * + * Licensed according to the LICENSE file in this repository. + */ + +package org.whispersystems.signalservice.api.push.exceptions; + +public class RemoteAttestationResponseExpiredException extends NonSuccessfulResponseCodeException { + public RemoteAttestationResponseExpiredException(String message) { + super(message); + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/push/exceptions/UnregisteredUserException.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/push/exceptions/UnregisteredUserException.java new file mode 100644 index 000000000..433365de8 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/push/exceptions/UnregisteredUserException.java @@ -0,0 +1,23 @@ +/** + * Copyright (C) 2014-2016 Open Whisper Systems + * + * Licensed according to the LICENSE file in this repository. + */ + +package org.whispersystems.signalservice.api.push.exceptions; + +import java.io.IOException; + +public class UnregisteredUserException extends IOException { + + private final String e164number; + + public UnregisteredUserException(String e164number, Exception exception) { + super(exception); + this.e164number = e164number; + } + + public String getE164Number() { + return e164number; + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/util/CredentialsProvider.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/util/CredentialsProvider.java new file mode 100644 index 000000000..e09c97742 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/util/CredentialsProvider.java @@ -0,0 +1,16 @@ +/** + * Copyright (C) 2014-2016 Open Whisper Systems + * + * Licensed according to the LICENSE file in this repository. + */ + +package org.whispersystems.signalservice.api.util; + +import java.util.UUID; + +public interface CredentialsProvider { + public UUID getUuid(); + public String getE164(); + public String getPassword(); + public String getSignalingKey(); +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/util/InvalidNumberException.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/util/InvalidNumberException.java new file mode 100644 index 000000000..a2a4cbb67 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/util/InvalidNumberException.java @@ -0,0 +1,13 @@ +/** + * Copyright (C) 2014-2016 Open Whisper Systems + * + * Licensed according to the LICENSE file in this repository. + */ + +package org.whispersystems.signalservice.api.util; + +public class InvalidNumberException extends Throwable { + public InvalidNumberException(String s) { + super(s); + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/util/PhoneNumberFormatter.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/util/PhoneNumberFormatter.java new file mode 100644 index 000000000..a775b0a3e --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/util/PhoneNumberFormatter.java @@ -0,0 +1,148 @@ +/** + * Copyright (C) 2014-2016 Open Whisper Systems + * + * Licensed according to the LICENSE file in this repository. + */ + +package org.whispersystems.signalservice.api.util; + +import com.google.i18n.phonenumbers.NumberParseException; +import com.google.i18n.phonenumbers.PhoneNumberUtil; +import com.google.i18n.phonenumbers.PhoneNumberUtil.PhoneNumberFormat; +import com.google.i18n.phonenumbers.Phonenumber.PhoneNumber; + +import org.whispersystems.libsignal.logging.Log; + +import java.util.Locale; +import java.util.regex.Pattern; + +/** + * Phone number formats are a pain. + * + * @author Moxie Marlinspike + * + */ +public class PhoneNumberFormatter { + + private static final String TAG = PhoneNumberFormatter.class.getSimpleName(); + + private static final String COUNTRY_CODE_BR = "55"; + private static final String COUNTRY_CODE_US = "1"; + + public static boolean isValidNumber(String e164Number, String countryCode) { + if (!PhoneNumberUtil.getInstance().isPossibleNumber(e164Number, countryCode)) { + Log.w(TAG, "Failed isPossibleNumber()"); + return false; + } + + if (COUNTRY_CODE_US.equals(countryCode) && !Pattern.matches("^\\+1[0-9]{10}$", e164Number)) { + Log.w(TAG, "Failed US number format check"); + return false; + } + + if (COUNTRY_CODE_BR.equals(countryCode) && !Pattern.matches("^\\+55[0-9]{2}9?[0-9]{8}$", e164Number)) { + Log.w(TAG, "Failed Brazil number format check"); + return false; + } + + return e164Number.matches("^\\+[1-9][0-9]{6,14}$"); + } + + private static String impreciseFormatNumber(String number, String localNumber) + throws InvalidNumberException + { + number = number.replaceAll("[^0-9+]", ""); + + if (number.charAt(0) == '+') + return number; + + if (localNumber.charAt(0) == '+') + localNumber = localNumber.substring(1); + + if (localNumber.length() == number.length() || number.length() > localNumber.length()) + return "+" + number; + + int difference = localNumber.length() - number.length(); + + return "+" + localNumber.substring(0, difference) + number; + } + + public static String formatNumberInternational(String number) { + try { + PhoneNumberUtil util = PhoneNumberUtil.getInstance(); + PhoneNumber parsedNumber = util.parse(number, null); + return util.format(parsedNumber, PhoneNumberFormat.INTERNATIONAL); + } catch (NumberParseException e) { + Log.w(TAG, e); + return number; + } + } + + public static String formatNumber(String number, String localNumber) + throws InvalidNumberException + { + if (number == null) { + throw new InvalidNumberException("Null String passed as number."); + } + + if (number.contains("@")) { + throw new InvalidNumberException("Possible attempt to use email address."); + } + + number = number.replaceAll("[^0-9+]", ""); + + if (number.length() == 0) { + throw new InvalidNumberException("No valid characters found."); + } + +// if (number.charAt(0) == '+') +// return number; + + try { + PhoneNumberUtil util = PhoneNumberUtil.getInstance(); + PhoneNumber localNumberObject = util.parse(localNumber, null); + + String localCountryCode = util.getRegionCodeForNumber(localNumberObject); + Log.w(TAG, "Got local CC: " + localCountryCode); + + PhoneNumber numberObject = util.parse(number, localCountryCode); + return util.format(numberObject, PhoneNumberFormat.E164); + } catch (NumberParseException e) { + Log.w(TAG, e); + return impreciseFormatNumber(number, localNumber); + } + } + + public static String getRegionDisplayName(String regionCode) { + return (regionCode == null || regionCode.equals("ZZ") || regionCode.equals(PhoneNumberUtil.REGION_CODE_FOR_NON_GEO_ENTITY)) + ? "Unknown country" : new Locale("", regionCode).getDisplayCountry(Locale.getDefault()); + } + + public static String formatE164(String countryCode, String number) { + try { + PhoneNumberUtil util = PhoneNumberUtil.getInstance(); + int parsedCountryCode = Integer.parseInt(countryCode); + PhoneNumber parsedNumber = util.parse(number, + util.getRegionCodeForCountryCode(parsedCountryCode)); + + return util.format(parsedNumber, PhoneNumberUtil.PhoneNumberFormat.E164); + } catch (NumberParseException | NumberFormatException npe) { + Log.w(TAG, npe); + } + + return "+" + + countryCode.replaceAll("[^0-9]", "").replaceAll("^0*", "") + + number.replaceAll("[^0-9]", ""); + } + + public static String getInternationalFormatFromE164(String e164number) { + try { + PhoneNumberUtil util = PhoneNumberUtil.getInstance(); + PhoneNumber parsedNumber = util.parse(e164number, null); + return util.format(parsedNumber, PhoneNumberFormat.INTERNATIONAL); + } catch (NumberParseException e) { + Log.w(TAG, e); + return e164number; + } + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/util/SleepTimer.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/util/SleepTimer.java new file mode 100644 index 000000000..e8a6ab8dc --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/util/SleepTimer.java @@ -0,0 +1,5 @@ +package org.whispersystems.signalservice.api.util; + +public interface SleepTimer { + public void sleep(long millis) throws InterruptedException; +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/util/StreamDetails.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/util/StreamDetails.java new file mode 100644 index 000000000..89f16d83b --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/util/StreamDetails.java @@ -0,0 +1,29 @@ +package org.whispersystems.signalservice.api.util; + + +import java.io.InputStream; + +public class StreamDetails { + + private final InputStream stream; + private final String contentType; + private final long length; + + public StreamDetails(InputStream stream, String contentType, long length) { + this.stream = stream; + this.contentType = contentType; + this.length = length; + } + + public InputStream getStream() { + return stream; + } + + public String getContentType() { + return contentType; + } + + public long getLength() { + return length; + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/util/Tls12SocketFactory.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/util/Tls12SocketFactory.java new file mode 100644 index 000000000..1ec1f326e --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/util/Tls12SocketFactory.java @@ -0,0 +1,69 @@ +package org.whispersystems.signalservice.api.util; + +import java.io.IOException; +import java.net.InetAddress; +import java.net.Socket; +import java.net.UnknownHostException; + +import javax.net.ssl.SSLSocket; +import javax.net.ssl.SSLSocketFactory; + +/** + * Enables TLS v1.2 when creating SSLSockets. + *

+ * For some reason, android supports TLS v1.2 from API 16, but enables it by + * default only from API 20. + * @link https://developer.android.com/reference/javax/net/ssl/SSLSocket.html + * @see SSLSocketFactory + */ +public class Tls12SocketFactory extends SSLSocketFactory { + private static final String[] TLS_V12_V13_ONLY = {"TLSv1.3", "TLSv1.2"}; + + final SSLSocketFactory delegate; + + public Tls12SocketFactory(SSLSocketFactory base) { + this.delegate = base; + } + + @Override + public String[] getDefaultCipherSuites() { + return delegate.getDefaultCipherSuites(); + } + + @Override + public String[] getSupportedCipherSuites() { + return delegate.getSupportedCipherSuites(); + } + + @Override + public Socket createSocket(Socket s, String host, int port, boolean autoClose) throws IOException { + return patch(delegate.createSocket(s, host, port, autoClose)); + } + + @Override + public Socket createSocket(String host, int port) throws IOException, UnknownHostException { + return patch(delegate.createSocket(host, port)); + } + + @Override + public Socket createSocket(String host, int port, InetAddress localHost, int localPort) throws IOException, UnknownHostException { + return patch(delegate.createSocket(host, port, localHost, localPort)); + } + + @Override + public Socket createSocket(InetAddress host, int port) throws IOException { + return patch(delegate.createSocket(host, port)); + } + + @Override + public Socket createSocket(InetAddress address, int port, InetAddress localAddress, int localPort) throws IOException { + return patch(delegate.createSocket(address, port, localAddress, localPort)); + } + + private Socket patch(Socket s) { + if (s instanceof SSLSocket) { + ((SSLSocket) s).setEnabledProtocols(TLS_V12_V13_ONLY); + } + return s; + } +} \ No newline at end of file diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/util/UptimeSleepTimer.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/util/UptimeSleepTimer.java new file mode 100644 index 000000000..3988db7e0 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/util/UptimeSleepTimer.java @@ -0,0 +1,16 @@ +package org.whispersystems.signalservice.api.util; + +import org.whispersystems.signalservice.api.util.SleepTimer; + +/** + * A simle sleep timer. Since Thread.sleep is based on uptime + * this will not work properly in low-power sleep modes, when + * the CPU is suspended and uptime does not elapse. + * + */ +public class UptimeSleepTimer implements SleepTimer { + @Override + public void sleep(long millis) throws InterruptedException { + Thread.sleep(millis); + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/util/UuidUtil.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/util/UuidUtil.java new file mode 100644 index 000000000..812401f9f --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/util/UuidUtil.java @@ -0,0 +1,46 @@ +package org.whispersystems.signalservice.api.util; + +import org.whispersystems.libsignal.util.guava.Optional; + +import java.nio.ByteBuffer; +import java.util.UUID; +import java.util.regex.Pattern; + +public final class UuidUtil { + + private static final Pattern UUID_PATTERN = Pattern.compile("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", Pattern.CASE_INSENSITIVE); + + private UuidUtil() { } + + public static Optional parse(String uuid) { + return Optional.fromNullable(parseOrNull(uuid)); + } + + public static UUID parseOrNull(String uuid) { + return isUuid(uuid) ? parseOrThrow(uuid) : null; + } + + public static UUID parseOrThrow(String uuid) { + return UUID.fromString(uuid); + } + + public static UUID parseOrThrow(byte[] bytes) { + ByteBuffer byteBuffer = ByteBuffer.wrap(bytes); + long high = byteBuffer.getLong(); + long low = byteBuffer.getLong(); + + return new UUID(high, low); + } + + public static boolean isUuid(String uuid) { + return uuid != null && UUID_PATTERN.matcher(uuid).matches(); + } + + public static byte[] toByteArray(UUID uuid) { + ByteBuffer buffer = ByteBuffer.wrap(new byte[16]); + buffer.putLong(uuid.getMostSignificantBits()); + buffer.putLong(uuid.getLeastSignificantBits()); + + return buffer.array(); + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/websocket/ConnectivityListener.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/websocket/ConnectivityListener.java new file mode 100644 index 000000000..0f06de8b0 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/websocket/ConnectivityListener.java @@ -0,0 +1,9 @@ +package org.whispersystems.signalservice.api.websocket; + + +public interface ConnectivityListener { + void onConnected(); + void onConnecting(); + void onDisconnected(); + void onAuthenticationFailure(); +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/configuration/SignalCdnUrl.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/configuration/SignalCdnUrl.java new file mode 100644 index 000000000..4d30ec40d --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/configuration/SignalCdnUrl.java @@ -0,0 +1,16 @@ +package org.whispersystems.signalservice.internal.configuration; + + +import org.whispersystems.signalservice.api.push.TrustStore; + +import okhttp3.ConnectionSpec; + +public class SignalCdnUrl extends SignalUrl { + public SignalCdnUrl(String url, TrustStore trustStore) { + super(url, trustStore); + } + + public SignalCdnUrl(String url, String hostHeader, TrustStore trustStore, ConnectionSpec connectionSpec) { + super(url, hostHeader, trustStore, connectionSpec); + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/configuration/SignalContactDiscoveryUrl.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/configuration/SignalContactDiscoveryUrl.java new file mode 100644 index 000000000..8851c8cd0 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/configuration/SignalContactDiscoveryUrl.java @@ -0,0 +1,17 @@ +package org.whispersystems.signalservice.internal.configuration; + + +import org.whispersystems.signalservice.api.push.TrustStore; + +import okhttp3.ConnectionSpec; + +public class SignalContactDiscoveryUrl extends SignalUrl { + + public SignalContactDiscoveryUrl(String url, TrustStore trustStore) { + super(url, trustStore); + } + + public SignalContactDiscoveryUrl(String url, String hostHeader, TrustStore trustStore, ConnectionSpec connectionSpec) { + super(url, hostHeader, trustStore, connectionSpec); + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/configuration/SignalServiceConfiguration.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/configuration/SignalServiceConfiguration.java new file mode 100644 index 000000000..86806cd0c --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/configuration/SignalServiceConfiguration.java @@ -0,0 +1,27 @@ +package org.whispersystems.signalservice.internal.configuration; + + +public class SignalServiceConfiguration { + + private final SignalServiceUrl[] signalServiceUrls; + private final SignalCdnUrl[] signalCdnUrls; + private final SignalContactDiscoveryUrl[] signalContactDiscoveryUrls; + + public SignalServiceConfiguration(SignalServiceUrl[] signalServiceUrls, SignalCdnUrl[] signalCdnUrls, SignalContactDiscoveryUrl[] signalContactDiscoveryUrls) { + this.signalServiceUrls = signalServiceUrls; + this.signalCdnUrls = signalCdnUrls; + this.signalContactDiscoveryUrls = signalContactDiscoveryUrls; + } + + public SignalServiceUrl[] getSignalServiceUrls() { + return signalServiceUrls; + } + + public SignalCdnUrl[] getSignalCdnUrls() { + return signalCdnUrls; + } + + public SignalContactDiscoveryUrl[] getSignalContactDiscoveryUrls() { + return signalContactDiscoveryUrls; + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/configuration/SignalServiceUrl.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/configuration/SignalServiceUrl.java new file mode 100644 index 000000000..ac96bc6c0 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/configuration/SignalServiceUrl.java @@ -0,0 +1,17 @@ +package org.whispersystems.signalservice.internal.configuration; + + +import org.whispersystems.signalservice.api.push.TrustStore; + +import okhttp3.ConnectionSpec; + +public class SignalServiceUrl extends SignalUrl { + + public SignalServiceUrl(String url, TrustStore trustStore) { + super(url, trustStore); + } + + public SignalServiceUrl(String url, String hostHeader, TrustStore trustStore, ConnectionSpec connectionSpec) { + super(url, hostHeader, trustStore, connectionSpec); + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/configuration/SignalUrl.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/configuration/SignalUrl.java new file mode 100644 index 000000000..86434d9e0 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/configuration/SignalUrl.java @@ -0,0 +1,53 @@ +package org.whispersystems.signalservice.internal.configuration; + + +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.push.TrustStore; +import org.whispersystems.signalservice.internal.util.BlacklistingTrustManager; + +import java.util.Collections; +import java.util.List; + +import javax.net.ssl.TrustManager; + +import okhttp3.ConnectionSpec; + +public class SignalUrl { + + private final String url; + private final Optional hostHeader; + private final Optional connectionSpec; + private TrustStore trustStore; + + public SignalUrl(String url, TrustStore trustStore) { + this(url, null, trustStore, null); + } + + public SignalUrl(String url, String hostHeader, + TrustStore trustStore, + ConnectionSpec connectionSpec) + { + this.url = url; + this.hostHeader = Optional.fromNullable(hostHeader); + this.trustStore = trustStore; + this.connectionSpec = Optional.fromNullable(connectionSpec); + } + + + public Optional getHostHeader() { + return hostHeader; + } + + public String getUrl() { + return url; + } + + public TrustStore getTrustStore() { + return trustStore; + } + + public Optional> getConnectionSpecs() { + return connectionSpec.isPresent() ? Optional.of(Collections.singletonList(connectionSpec.get())) : Optional.>absent(); + } + +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/contacts/crypto/ContactDiscoveryCipher.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/contacts/crypto/ContactDiscoveryCipher.java new file mode 100644 index 000000000..1e3e46efc --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/contacts/crypto/ContactDiscoveryCipher.java @@ -0,0 +1,148 @@ +package org.whispersystems.signalservice.internal.contacts.crypto; + + +import org.threeten.bp.Instant; +import org.threeten.bp.LocalDateTime; +import org.threeten.bp.Period; +import org.threeten.bp.ZoneId; +import org.threeten.bp.ZonedDateTime; +import org.threeten.bp.format.DateTimeFormatter; +import org.whispersystems.libsignal.util.ByteUtil; +import org.whispersystems.signalservice.api.crypto.InvalidCiphertextException; +import org.whispersystems.signalservice.internal.contacts.entities.DiscoveryRequest; +import org.whispersystems.signalservice.internal.contacts.entities.DiscoveryResponse; +import org.whispersystems.signalservice.internal.contacts.entities.RemoteAttestationResponse; +import org.whispersystems.signalservice.internal.util.Hex; +import org.whispersystems.signalservice.internal.util.JsonUtil; +import org.whispersystems.signalservice.internal.util.Util; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.security.InvalidAlgorithmParameterException; +import java.security.InvalidKeyException; +import java.security.KeyStore; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SignatureException; +import java.security.cert.CertPathValidatorException; +import java.security.cert.CertificateException; +import java.util.List; + +import javax.crypto.BadPaddingException; +import javax.crypto.Cipher; +import javax.crypto.IllegalBlockSizeException; +import javax.crypto.NoSuchPaddingException; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; + +public class ContactDiscoveryCipher { + + private static final int TAG_LENGTH_BYTES = 16; + private static final int TAG_LENGTH_BITS = TAG_LENGTH_BYTES * 8; + private static final long SIGNATURE_BODY_VERSION = 3L; + + public DiscoveryRequest createDiscoveryRequest(List addressBook, RemoteAttestation remoteAttestation) { + try { + ByteArrayOutputStream requestDataStream = new ByteArrayOutputStream(); + + for (String address : addressBook) { + requestDataStream.write(ByteUtil.longToByteArray(Long.parseLong(address))); + } + + byte[] requestData = requestDataStream.toByteArray(); + byte[] nonce = Util.getSecretBytes(12); + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); + + cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(remoteAttestation.getKeys().getClientKey(), "AES"), new GCMParameterSpec(TAG_LENGTH_BITS, nonce)); + cipher.updateAAD(remoteAttestation.getRequestId()); + + byte[] cipherText = cipher.doFinal(requestData); + byte[][] parts = ByteUtil.split(cipherText, cipherText.length - TAG_LENGTH_BYTES, TAG_LENGTH_BYTES); + + return new DiscoveryRequest(addressBook.size(), remoteAttestation.getRequestId(), nonce, parts[0], parts[1]); + } catch (IOException | NoSuchAlgorithmException | InvalidKeyException | NoSuchPaddingException | InvalidAlgorithmParameterException | IllegalBlockSizeException | BadPaddingException e) { + throw new AssertionError(e); + } + } + + public byte[] getDiscoveryResponseData(DiscoveryResponse response, RemoteAttestation remoteAttestation) throws InvalidCiphertextException { + return decrypt(remoteAttestation.getKeys().getServerKey(), response.getIv(), response.getData(), response.getMac()); + } + + public byte[] getRequestId(RemoteAttestationKeys keys, RemoteAttestationResponse response) throws InvalidCiphertextException { + return decrypt(keys.getServerKey(), response.getIv(), response.getCiphertext(), response.getTag()); + } + + public void verifyServerQuote(Quote quote, byte[] serverPublicStatic, String mrenclave) + throws UnauthenticatedQuoteException + { + try { + byte[] theirServerPublicStatic = new byte[serverPublicStatic.length]; + System.arraycopy(quote.getReportData(), 0, theirServerPublicStatic, 0, theirServerPublicStatic.length); + + if (!MessageDigest.isEqual(theirServerPublicStatic, serverPublicStatic)) { + throw new UnauthenticatedQuoteException("Response quote has unauthenticated report data!"); + } + + if (!MessageDigest.isEqual(Hex.fromStringCondensed(mrenclave), quote.getMrenclave())) { + throw new UnauthenticatedQuoteException("The response quote has the wrong mrenclave value in it: " + Hex.toStringCondensed(quote.getMrenclave())); + } + + if (quote.isDebugQuote()) { + throw new UnauthenticatedQuoteException("Received quote for debuggable enclave"); + } + } catch (IOException e) { + throw new UnauthenticatedQuoteException(e); + } + } + + public void verifyIasSignature(KeyStore trustStore, String certificates, String signatureBody, String signature, Quote quote) + throws SignatureException + { + if (certificates == null || certificates.isEmpty()) { + throw new SignatureException("No certificates."); + } + + try { + SigningCertificate signingCertificate = new SigningCertificate(certificates, trustStore); + signingCertificate.verifySignature(signatureBody, signature); + + SignatureBodyEntity signatureBodyEntity = JsonUtil.fromJson(signatureBody, SignatureBodyEntity.class); + + if (signatureBodyEntity.getVersion() != SIGNATURE_BODY_VERSION) { + throw new SignatureException("Unexpected signed quote version " + signatureBodyEntity.getVersion()); + } + + if (!MessageDigest.isEqual(ByteUtil.trim(signatureBodyEntity.getIsvEnclaveQuoteBody(), 432), ByteUtil.trim(quote.getQuoteBytes(), 432))) { + throw new SignatureException("Signed quote is not the same as RA quote: " + Hex.toStringCondensed(signatureBodyEntity.getIsvEnclaveQuoteBody()) + " vs " + Hex.toStringCondensed(quote.getQuoteBytes())); + } + + if (!"OK".equals(signatureBodyEntity.getIsvEnclaveQuoteStatus())) { + throw new SignatureException("Quote status is: " + signatureBodyEntity.getIsvEnclaveQuoteStatus()); + } + + if (Instant.from(ZonedDateTime.of(LocalDateTime.from(DateTimeFormatter.ofPattern("yyy-MM-dd'T'HH:mm:ss.SSSSSS").parse(signatureBodyEntity.getTimestamp())), ZoneId.of("UTC"))) + .plus(Period.ofDays(1)) + .isBefore(Instant.now())) + { + throw new SignatureException("Signature is expired"); + } + + } catch (CertificateException | CertPathValidatorException | IOException e) { + throw new SignatureException(e); + } + } + + private byte[] decrypt(byte[] key, byte[] iv, byte[] ciphertext, byte[] tag) throws InvalidCiphertextException { + try { + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); + cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(key, "AES"), new GCMParameterSpec(128, iv)); + + return cipher.doFinal(ByteUtil.combine(ciphertext, tag)); + } catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidAlgorithmParameterException | IllegalBlockSizeException e) { + throw new AssertionError(e); + } catch (InvalidKeyException | BadPaddingException e) { + throw new InvalidCiphertextException(e); + } + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/contacts/crypto/Quote.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/contacts/crypto/Quote.java new file mode 100644 index 000000000..7337ff2fc --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/contacts/crypto/Quote.java @@ -0,0 +1,136 @@ +package org.whispersystems.signalservice.internal.contacts.crypto; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +public class Quote { + + private static final long SGX_FLAGS_INITTED = 0x0000_0000_0000_0001L; + private static final long SGX_FLAGS_DEBUG = 0x0000_0000_0000_0002L; + private static final long SGX_FLAGS_MODE64BIT = 0x0000_0000_0000_0004L; + private static final long SGX_FLAGS_PROVISION_KEY = 0x0000_0000_0000_0004L; + private static final long SGX_FLAGS_EINITTOKEN_KEY = 0x0000_0000_0000_0004L; + private static final long SGX_FLAGS_RESERVED = 0xFFFF_FFFF_FFFF_FFC8L; + private static final long SGX_XFRM_LEGACY = 0x0000_0000_0000_0003L; + private static final long SGX_XFRM_AVX = 0x0000_0000_0000_0006L; + private static final long SGX_XFRM_RESERVED = 0xFFFF_FFFF_FFFF_FFF8L; + + private final int version; + private final boolean isSigLinkable; + private final long gid; + private final int qeSvn; + private final int pceSvn; + private final byte[] basename = new byte[32]; + private final byte[] cpuSvn = new byte[16]; + private final long flags; + private final long xfrm; + private final byte[] mrenclave = new byte[32]; + private final byte[] mrsigner = new byte[32]; + private final int isvProdId; + private final int isvSvn; + private final byte[] reportData = new byte[64]; + private final byte[] signature; + private final byte[] quoteBytes; + + public Quote(byte[] quoteBytes) throws InvalidQuoteFormatException { + this.quoteBytes = quoteBytes; + + ByteBuffer quoteBuf = ByteBuffer.wrap(quoteBytes); + quoteBuf.order(ByteOrder.LITTLE_ENDIAN); + + this.version = quoteBuf.getShort(0) & 0xFFFF; + if (!(version >= 1 && version <= 2)) { + throw new InvalidQuoteFormatException("unknown_quote_version "+version); + } + + int sign_type = quoteBuf.getShort(2) & 0xFFFF; + if ((sign_type & ~1) != 0) { + throw new InvalidQuoteFormatException("unknown_quote_sign_type "+sign_type); + } + + this.isSigLinkable = sign_type == 1; + this.gid = quoteBuf.getInt(4) & 0xFFFF_FFFF; + this.qeSvn = quoteBuf.getShort(8) & 0xFFFF; + + if (version > 1) { + this.pceSvn = quoteBuf.getShort(10) & 0xFFFF; + } else { + readZero(quoteBuf, 10, 2); + this.pceSvn = 0; + } + + readZero(quoteBuf, 12, 4); // xeid (reserved) + read(quoteBuf, 16, basename); + + // + // report_body + // + + read(quoteBuf, 48, cpuSvn); + readZero(quoteBuf, 64, 4); // misc_select (reserved) + readZero(quoteBuf, 68, 28); // reserved1 + this.flags = quoteBuf.getLong(96); + if ((flags & SGX_FLAGS_RESERVED ) != 0 || + (flags & SGX_FLAGS_INITTED ) == 0 || + (flags & SGX_FLAGS_MODE64BIT) == 0) { + throw new InvalidQuoteFormatException("bad_quote_flags "+flags); + } + this.xfrm = quoteBuf.getLong(104); + if ((xfrm & SGX_XFRM_RESERVED) != 0) { + throw new InvalidQuoteFormatException("bad_quote_xfrm "+xfrm); + } + read(quoteBuf, 112, mrenclave); + readZero(quoteBuf, 144, 32); // reserved2 + read(quoteBuf, 176, mrsigner); + readZero(quoteBuf, 208, 96); // reserved3 + this.isvProdId = quoteBuf.getShort(304) & 0xFFFF; + this.isvSvn = quoteBuf.getShort(306) & 0xFFFF; + readZero(quoteBuf, 308, 60); // reserved4 + read(quoteBuf, 368, reportData); + + // quote signature + int sig_len = quoteBuf.getInt(432) & 0xFFFF_FFFF; + if (sig_len != quoteBytes.length - 436) { + throw new InvalidQuoteFormatException("bad_quote_sig_len "+sig_len); + } + this.signature = new byte[sig_len]; + read(quoteBuf, 436, signature); + } + + public byte[] getReportData() { + return reportData; + } + + private void read(ByteBuffer quoteBuf, int pos, byte[] buf) { + quoteBuf.position(pos); + quoteBuf.get(buf); + } + + private void readZero(ByteBuffer quoteBuf, int pos, int count) { + byte[] zeroBuf = new byte[count]; + read(quoteBuf, pos, zeroBuf); + for (int zeroBufIdx = 0; zeroBufIdx < count; zeroBufIdx++) { + if (zeroBuf[zeroBufIdx] != 0) { + throw new IllegalArgumentException("quote_reserved_mismatch "+pos); + } + } + } + + public byte[] getQuoteBytes() { + return quoteBytes; + } + + public byte[] getMrenclave() { + return mrenclave; + } + + public boolean isDebugQuote() { + return (flags & SGX_FLAGS_DEBUG) != 0; + } + + public static class InvalidQuoteFormatException extends Exception { + public InvalidQuoteFormatException(String value) { + super(value); + } + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/contacts/crypto/RemoteAttestation.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/contacts/crypto/RemoteAttestation.java new file mode 100644 index 000000000..7e0c7e953 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/contacts/crypto/RemoteAttestation.java @@ -0,0 +1,20 @@ +package org.whispersystems.signalservice.internal.contacts.crypto; + +public class RemoteAttestation { + + private final byte[] requestId; + private final RemoteAttestationKeys keys; + + public RemoteAttestation(byte[] requestId, RemoteAttestationKeys keys) { + this.requestId = requestId; + this.keys = keys; + } + + public byte[] getRequestId() { + return requestId; + } + + public RemoteAttestationKeys getKeys() { + return keys; + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/contacts/crypto/RemoteAttestationKeys.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/contacts/crypto/RemoteAttestationKeys.java new file mode 100644 index 000000000..a9d2a7d30 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/contacts/crypto/RemoteAttestationKeys.java @@ -0,0 +1,35 @@ +package org.whispersystems.signalservice.internal.contacts.crypto; + + +import org.whispersystems.curve25519.Curve25519; +import org.whispersystems.curve25519.Curve25519KeyPair; +import org.whispersystems.libsignal.kdf.HKDFv3; +import org.whispersystems.libsignal.util.ByteUtil; + +public class RemoteAttestationKeys { + + private final byte[] clientKey = new byte[32]; + private final byte[] serverKey = new byte[32]; + + public RemoteAttestationKeys(Curve25519KeyPair keyPair, byte[] serverPublicEphemeral, byte[] serverPublicStatic) { + byte[] ephemeralToEphemeral = Curve25519.getInstance(Curve25519.BEST).calculateAgreement(serverPublicEphemeral, keyPair.getPrivateKey()); + byte[] ephemeralToStatic = Curve25519.getInstance(Curve25519.BEST).calculateAgreement(serverPublicStatic, keyPair.getPrivateKey()); + + byte[] masterSecret = ByteUtil.combine(ephemeralToEphemeral, ephemeralToStatic ); + byte[] publicKeys = ByteUtil.combine(keyPair.getPublicKey(), serverPublicEphemeral, serverPublicStatic); + + HKDFv3 generator = new HKDFv3(); + byte[] keys = generator.deriveSecrets(masterSecret, publicKeys, null, clientKey.length + serverKey.length); + + System.arraycopy(keys, 0, clientKey, 0, clientKey.length); + System.arraycopy(keys, clientKey.length, serverKey, 0, serverKey.length); + } + + public byte[] getClientKey() { + return clientKey; + } + + public byte[] getServerKey() { + return serverKey; + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/contacts/crypto/SignatureBodyEntity.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/contacts/crypto/SignatureBodyEntity.java new file mode 100644 index 000000000..2a1424391 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/contacts/crypto/SignatureBodyEntity.java @@ -0,0 +1,34 @@ +package org.whispersystems.signalservice.internal.contacts.crypto; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public class SignatureBodyEntity { + + @JsonProperty + private byte[] isvEnclaveQuoteBody; + + @JsonProperty + private String isvEnclaveQuoteStatus; + + @JsonProperty + private Long version; + + @JsonProperty + private String timestamp; + + public byte[] getIsvEnclaveQuoteBody() { + return isvEnclaveQuoteBody; + } + + public String getIsvEnclaveQuoteStatus() { + return isvEnclaveQuoteStatus; + } + + public Long getVersion() { + return version; + } + + public String getTimestamp() { + return timestamp; + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/contacts/crypto/SigningCertificate.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/contacts/crypto/SigningCertificate.java new file mode 100644 index 000000000..f26fd7605 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/contacts/crypto/SigningCertificate.java @@ -0,0 +1,73 @@ +package org.whispersystems.signalservice.internal.contacts.crypto; + +import org.whispersystems.util.Base64; + +import java.io.ByteArrayInputStream; +import java.net.URLDecoder; +import java.security.InvalidAlgorithmParameterException; +import java.security.InvalidKeyException; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.Signature; +import java.security.SignatureException; +import java.security.cert.CertPath; +import java.security.cert.CertPathValidator; +import java.security.cert.CertPathValidatorException; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; +import java.security.cert.PKIXParameters; +import java.security.cert.X509Certificate; +import java.util.Collection; +import java.util.LinkedList; +import java.util.List; + +public class SigningCertificate { + + private final CertPath path; + + public SigningCertificate(String certificateChain, KeyStore trustStore) + throws CertificateException, CertPathValidatorException + { + try { + CertificateFactory certificateFactory = CertificateFactory.getInstance("X.509"); + Collection certificatesCollection = (Collection) certificateFactory.generateCertificates(new ByteArrayInputStream(URLDecoder.decode(certificateChain).getBytes())); + List certificates = new LinkedList<>(certificatesCollection); + PKIXParameters pkixParameters = new PKIXParameters(trustStore); + CertPathValidator validator = CertPathValidator.getInstance("PKIX"); + + this.path = certificateFactory.generateCertPath(certificates); + + pkixParameters.setRevocationEnabled(false); + validator.validate(path, pkixParameters); + verifyDistinguishedName(path); + } catch (KeyStoreException | InvalidAlgorithmParameterException | NoSuchAlgorithmException e) { + throw new AssertionError(e); + } + } + + public void verifySignature(String body, String encodedSignature) + throws SignatureException + { + try { + Signature signature = Signature.getInstance("SHA256withRSA"); + signature.initVerify(path.getCertificates().get(0)); + signature.update(body.getBytes()); + if (!signature.verify(Base64.decode(encodedSignature.getBytes()))) { + throw new SignatureException("Signature verification failed."); + } + } catch (NoSuchAlgorithmException | InvalidKeyException e) { + throw new AssertionError(e); + } + } + + private void verifyDistinguishedName(CertPath path) throws CertificateException { + X509Certificate leaf = (X509Certificate) path.getCertificates().get(0); + String distinguishedName = leaf.getSubjectX500Principal().getName(); + + if (!"CN=Intel SGX Attestation Report Signing,O=Intel Corporation,L=Santa Clara,ST=CA,C=US".equals(distinguishedName)) { + throw new CertificateException("Bad DN: " + distinguishedName); + } + } + +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/contacts/crypto/UnauthenticatedQuoteException.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/contacts/crypto/UnauthenticatedQuoteException.java new file mode 100644 index 000000000..f0aceb643 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/contacts/crypto/UnauthenticatedQuoteException.java @@ -0,0 +1,12 @@ +package org.whispersystems.signalservice.internal.contacts.crypto; + + +public class UnauthenticatedQuoteException extends Exception { + public UnauthenticatedQuoteException(String s) { + super(s); + } + + public UnauthenticatedQuoteException(Exception nested) { + super(nested); + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/contacts/crypto/UnauthenticatedResponseException.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/contacts/crypto/UnauthenticatedResponseException.java new file mode 100644 index 000000000..ca36b460b --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/contacts/crypto/UnauthenticatedResponseException.java @@ -0,0 +1,11 @@ +package org.whispersystems.signalservice.internal.contacts.crypto; + + +public class UnauthenticatedResponseException extends Exception { + public UnauthenticatedResponseException(Exception e) { + super(e); + } + public UnauthenticatedResponseException(String s) { + super(s); + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/contacts/entities/DiscoveryRequest.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/contacts/entities/DiscoveryRequest.java new file mode 100644 index 000000000..e10cb9be2 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/contacts/entities/DiscoveryRequest.java @@ -0,0 +1,77 @@ +/* + * Copyright (C) 2017 Open Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package org.whispersystems.signalservice.internal.contacts.entities; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import org.whispersystems.signalservice.internal.util.Hex; + + +public class DiscoveryRequest { + + @JsonProperty + private int addressCount; + + @JsonProperty + private byte[] requestId; + + @JsonProperty + private byte[] iv; + + @JsonProperty + private byte[] data; + + @JsonProperty + private byte[] mac; + + public DiscoveryRequest() { + + } + + public DiscoveryRequest(int addressCount, byte[] requestId, byte[] iv, byte[] data, byte[] mac) { + this.addressCount = addressCount; + this.requestId = requestId; + this.iv = iv; + this.data = data; + this. mac = mac; + } + + public byte[] getRequestId() { + return requestId; + } + + public byte[] getIv() { + return iv; + } + + public byte[] getData() { + return data; + } + + public byte[] getMac() { + return mac; + } + + public int getAddressCount() { + return addressCount; + } + + public String toString() { + return "{ addressCount: " + addressCount + ", ticket: " + Hex.toString(requestId) + ", iv: " + Hex.toString(iv) + ", data: " + Hex.toString(data) + ", mac: " + Hex.toString(mac) + "}"; + } + +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/contacts/entities/DiscoveryResponse.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/contacts/entities/DiscoveryResponse.java new file mode 100644 index 000000000..e3ff43b2e --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/contacts/entities/DiscoveryResponse.java @@ -0,0 +1,57 @@ +/* + * Copyright (C) 2017 Open Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package org.whispersystems.signalservice.internal.contacts.entities; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import org.whispersystems.signalservice.internal.util.Hex; + +public class DiscoveryResponse { + + @JsonProperty + private byte[] iv; + + @JsonProperty + private byte[] data; + + @JsonProperty + private byte[] mac; + + public DiscoveryResponse() {} + + public DiscoveryResponse(byte[] iv, byte[] data, byte[] mac) { + this.iv = iv; + this.data = data; + this.mac = mac; + } + + public byte[] getIv() { + return iv; + } + + public byte[] getData() { + return data; + } + + public byte[] getMac() { + return mac; + } + + public String toString() { + return "{iv: " + (iv == null ? null : Hex.toString(iv)) + ", data: " + (data == null ? null: Hex.toString(data)) + ", mac: " + (mac == null ? null : Hex.toString(mac)) + "}"; + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/contacts/entities/RemoteAttestationRequest.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/contacts/entities/RemoteAttestationRequest.java new file mode 100644 index 000000000..7b84d1d09 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/contacts/entities/RemoteAttestationRequest.java @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2017 Open Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package org.whispersystems.signalservice.internal.contacts.entities; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public class RemoteAttestationRequest { + + @JsonProperty + private byte[] clientPublic; + + public RemoteAttestationRequest() {} + + public RemoteAttestationRequest(byte[] clientPublic) { + this.clientPublic = clientPublic; + } + + public byte[] getClientPublic() { + return clientPublic; + } + +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/contacts/entities/RemoteAttestationResponse.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/contacts/entities/RemoteAttestationResponse.java new file mode 100644 index 000000000..229a549d3 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/contacts/entities/RemoteAttestationResponse.java @@ -0,0 +1,103 @@ +/* + * Copyright (C) 2017 Open Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package org.whispersystems.signalservice.internal.contacts.entities; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public class RemoteAttestationResponse { + + @JsonProperty + private byte[] serverEphemeralPublic; + + @JsonProperty + private byte[] serverStaticPublic; + + @JsonProperty + private byte[] quote; + + @JsonProperty + private byte[] iv; + + @JsonProperty + private byte[] ciphertext; + + @JsonProperty + private byte[] tag; + + @JsonProperty + private String signature; + + @JsonProperty + private String certificates; + + @JsonProperty + private String signatureBody; + + public RemoteAttestationResponse(byte[] serverEphemeralPublic, byte[] serverStaticPublic, + byte[] iv, byte[] ciphertext, byte[] tag, + byte[] quote, String signature, String certificates, String signatureBody) + { + this.serverEphemeralPublic = serverEphemeralPublic; + this.serverStaticPublic = serverStaticPublic; + this.iv = iv; + this.ciphertext = ciphertext; + this.tag = tag; + this.quote = quote; + this.signature = signature; + this.certificates = certificates; + this.signatureBody = signatureBody; + } + + public RemoteAttestationResponse() {} + + public byte[] getServerEphemeralPublic() { + return serverEphemeralPublic; + } + + public byte[] getServerStaticPublic() { + return serverStaticPublic; + } + + public byte[] getQuote() { + return quote; + } + + public byte[] getIv() { + return iv; + } + + public byte[] getCiphertext() { + return ciphertext; + } + + public byte[] getTag() { + return tag; + } + + public String getSignature() { + return signature; + } + + public String getCertificates() { + return certificates; + } + + public String getSignatureBody() { + return signatureBody; + } + +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/crypto/PaddingInputStream.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/crypto/PaddingInputStream.java new file mode 100644 index 000000000..2833bca3e --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/crypto/PaddingInputStream.java @@ -0,0 +1,59 @@ +package org.whispersystems.signalservice.internal.crypto; + + +import org.whispersystems.signalservice.internal.util.Util; + +import java.io.FilterInputStream; +import java.io.IOException; +import java.io.InputStream; + +public class PaddingInputStream extends FilterInputStream { + + private long paddingRemaining; + + public PaddingInputStream(InputStream inputStream, long plaintextLength) { + super(inputStream); + this.paddingRemaining = getPaddedSize(plaintextLength) - plaintextLength; + } + + @Override + public int read() throws IOException { + int result = super.read(); + if (result != -1) return result; + + if (paddingRemaining > 0) { + paddingRemaining--; + return 0x00; + } + + return -1; + } + + @Override + public int read(byte[] buffer, int offset, int length) throws IOException { + int result = super.read(buffer, offset, length); + if (result != -1) return result; + + if (paddingRemaining > 0) { + length = Math.min(length, Util.toIntExact(paddingRemaining)); + paddingRemaining -= length; + return length; + } + + return -1; + } + + @Override + public int read(byte[] buffer) throws IOException { + return read(buffer, 0, buffer.length); + } + + @Override + public int available() throws IOException { + return super.available() + Util.toIntExact(paddingRemaining); + } + + public static long getPaddedSize(long size) { + return (int) Math.max(541, Math.floor(Math.pow(1.05, Math.ceil(Math.log(size) / Math.log(1.05))))); + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/crypto/ProvisioningCipher.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/crypto/ProvisioningCipher.java new file mode 100644 index 000000000..f25c7dd57 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/crypto/ProvisioningCipher.java @@ -0,0 +1,81 @@ +/** + * Copyright (C) 2014-2016 Open Whisper Systems + * + * Licensed according to the LICENSE file in this repository. + */ + +package org.whispersystems.signalservice.internal.crypto; + +import com.google.protobuf.ByteString; + +import org.whispersystems.libsignal.InvalidKeyException; +import org.whispersystems.libsignal.ecc.Curve; +import org.whispersystems.libsignal.ecc.ECKeyPair; +import org.whispersystems.libsignal.ecc.ECPublicKey; +import org.whispersystems.libsignal.kdf.HKDFv3; +import org.whispersystems.signalservice.internal.util.Util; + +import java.security.NoSuchAlgorithmException; + +import javax.crypto.BadPaddingException; +import javax.crypto.Cipher; +import javax.crypto.IllegalBlockSizeException; +import javax.crypto.Mac; +import javax.crypto.NoSuchPaddingException; +import javax.crypto.spec.SecretKeySpec; + +import static org.whispersystems.signalservice.internal.push.ProvisioningProtos.ProvisionEnvelope; +import static org.whispersystems.signalservice.internal.push.ProvisioningProtos.ProvisionMessage; + + +public class ProvisioningCipher { + + private static final String TAG = ProvisioningCipher.class.getSimpleName(); + + private final ECPublicKey theirPublicKey; + + public ProvisioningCipher(ECPublicKey theirPublicKey) { + this.theirPublicKey = theirPublicKey; + } + + public byte[] encrypt(ProvisionMessage message) throws InvalidKeyException { + ECKeyPair ourKeyPair = Curve.generateKeyPair(); + byte[] sharedSecret = Curve.calculateAgreement(theirPublicKey, ourKeyPair.getPrivateKey()); + byte[] derivedSecret = new HKDFv3().deriveSecrets(sharedSecret, "TextSecure Provisioning Message".getBytes(), 64); + byte[][] parts = Util.split(derivedSecret, 32, 32); + + byte[] version = {0x01}; + byte[] ciphertext = getCiphertext(parts[0], message.toByteArray()); + byte[] mac = getMac(parts[1], Util.join(version, ciphertext)); + byte[] body = Util.join(version, ciphertext, mac); + + return ProvisionEnvelope.newBuilder() + .setPublicKey(ByteString.copyFrom(ourKeyPair.getPublicKey().serialize())) + .setBody(ByteString.copyFrom(body)) + .build() + .toByteArray(); + } + + private byte[] getCiphertext(byte[] key, byte[] message) { + try { + Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding"); + cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(key, "AES")); + + return Util.join(cipher.getIV(), cipher.doFinal(message)); + } catch (NoSuchAlgorithmException | NoSuchPaddingException | java.security.InvalidKeyException | IllegalBlockSizeException | BadPaddingException e) { + throw new AssertionError(e); + } + } + + private byte[] getMac(byte[] key, byte[] message) { + try { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(key, "HmacSHA256")); + + return mac.doFinal(message); + } catch (NoSuchAlgorithmException | java.security.InvalidKeyException e) { + throw new AssertionError(e); + } + } + +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/AccountAttributes.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/AccountAttributes.java new file mode 100644 index 000000000..c46141c99 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/AccountAttributes.java @@ -0,0 +1,81 @@ +/** + * Copyright (C) 2014-2016 Open Whisper Systems + * + * Licensed according to the LICENSE file in this repository. + */ + +package org.whispersystems.signalservice.internal.push; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public class AccountAttributes { + + @JsonProperty + private String signalingKey; + + @JsonProperty + private int registrationId; + + @JsonProperty + private boolean voice; + + @JsonProperty + private boolean video; + + @JsonProperty + private boolean fetchesMessages; + + @JsonProperty + private String pin; + + @JsonProperty + private byte[] unidentifiedAccessKey; + + @JsonProperty + private boolean unrestrictedUnidentifiedAccess; + + public AccountAttributes(String signalingKey, int registrationId, boolean fetchesMessages, String pin, byte[] unidentifiedAccessKey, boolean unrestrictedUnidentifiedAccess) { + this.signalingKey = signalingKey; + this.registrationId = registrationId; + this.voice = true; + this.video = true; + this.fetchesMessages = fetchesMessages; + this.pin = pin; + this.unidentifiedAccessKey = unidentifiedAccessKey; + this.unrestrictedUnidentifiedAccess = unrestrictedUnidentifiedAccess; + } + + public AccountAttributes() {} + + public String getSignalingKey() { + return signalingKey; + } + + public int getRegistrationId() { + return registrationId; + } + + public boolean isVoice() { + return voice; + } + + public boolean isVideo() { + return video; + } + + public boolean isFetchesMessages() { + return fetchesMessages; + } + + public String getPin() { + return pin; + } + + public byte[] getUnidentifiedAccessKey() { + return unidentifiedAccessKey; + } + + public boolean isUnrestrictedUnidentifiedAccess() { + return unrestrictedUnidentifiedAccess; + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/AttachmentUploadAttributes.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/AttachmentUploadAttributes.java new file mode 100644 index 000000000..46eadd264 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/AttachmentUploadAttributes.java @@ -0,0 +1,78 @@ +package org.whispersystems.signalservice.internal.push; + + +import com.fasterxml.jackson.annotation.JsonProperty; + +public class AttachmentUploadAttributes { + @JsonProperty + private String url; + + @JsonProperty + private String key; + + @JsonProperty + private String credential; + + @JsonProperty + private String acl; + + @JsonProperty + private String algorithm; + + @JsonProperty + private String date; + + @JsonProperty + private String policy; + + @JsonProperty + private String signature; + + @JsonProperty + private String attachmentId; + + @JsonProperty + private String attachmentIdString; + + public AttachmentUploadAttributes() {} + + public String getUrl() { + return url; + } + + public String getKey() { + return key; + } + + public String getCredential() { + return credential; + } + + public String getAcl() { + return acl; + } + + public String getAlgorithm() { + return algorithm; + } + + public String getDate() { + return date; + } + + public String getPolicy() { + return policy; + } + + public String getSignature() { + return signature; + } + + public String getAttachmentId() { + return attachmentId; + } + + public String getAttachmentIdString() { + return attachmentIdString; + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/ContactDiscoveryCredentials.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/ContactDiscoveryCredentials.java new file mode 100644 index 000000000..35e0d9902 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/ContactDiscoveryCredentials.java @@ -0,0 +1,28 @@ +package org.whispersystems.signalservice.internal.push; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public class ContactDiscoveryCredentials { + + @JsonProperty + private String username; + + @JsonProperty + private String password; + + public void setUsername(String username) { + this.username = username; + } + + public void setPassword(String password) { + this.password = password; + } + + public String getUsername() { + return username; + } + + public String getPassword() { + return password; + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/ContactDiscoveryFailureReason.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/ContactDiscoveryFailureReason.java new file mode 100644 index 000000000..05dfb02ba --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/ContactDiscoveryFailureReason.java @@ -0,0 +1,13 @@ +package org.whispersystems.signalservice.internal.push; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public class ContactDiscoveryFailureReason { + + @JsonProperty + private final String reason; + + public ContactDiscoveryFailureReason(String reason) { + this.reason = reason == null ? "" : reason; + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/ContactTokenDetailsList.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/ContactTokenDetailsList.java new file mode 100644 index 000000000..a9f314e12 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/ContactTokenDetailsList.java @@ -0,0 +1,25 @@ +/** + * Copyright (C) 2014-2016 Open Whisper Systems + * + * Licensed according to the LICENSE file in this repository. + */ + +package org.whispersystems.signalservice.internal.push; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import org.whispersystems.signalservice.api.push.ContactTokenDetails; + +import java.util.List; + +public class ContactTokenDetailsList { + + @JsonProperty + private List contacts; + + public ContactTokenDetailsList() {} + + public List getContacts() { + return contacts; + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/ContactTokenList.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/ContactTokenList.java new file mode 100644 index 000000000..8a50c49dc --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/ContactTokenList.java @@ -0,0 +1,24 @@ +/** + * Copyright (C) 2014-2016 Open Whisper Systems + * + * Licensed according to the LICENSE file in this repository. + */ + +package org.whispersystems.signalservice.internal.push; + +import java.util.List; + +public class ContactTokenList { + + private List contacts; + + public ContactTokenList(List contacts) { + this.contacts = contacts; + } + + public ContactTokenList() {} + + public List getContacts() { + return contacts; + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/DeviceCode.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/DeviceCode.java new file mode 100644 index 000000000..6f1f06482 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/DeviceCode.java @@ -0,0 +1,13 @@ +package org.whispersystems.signalservice.internal.push; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public class DeviceCode { + + @JsonProperty + private String verificationCode; + + public String getVerificationCode() { + return verificationCode; + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/DeviceInfoList.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/DeviceInfoList.java new file mode 100644 index 000000000..2ee522d84 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/DeviceInfoList.java @@ -0,0 +1,19 @@ +package org.whispersystems.signalservice.internal.push; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import org.whispersystems.signalservice.api.messages.multidevice.DeviceInfo; + +import java.util.List; + +public class DeviceInfoList { + + @JsonProperty + private List devices; + + public DeviceInfoList() {} + + public List getDevices() { + return devices; + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/DeviceLimit.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/DeviceLimit.java new file mode 100644 index 000000000..233eed58b --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/DeviceLimit.java @@ -0,0 +1,20 @@ +package org.whispersystems.signalservice.internal.push; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public class DeviceLimit { + + @JsonProperty + private int current; + + @JsonProperty + private int max; + + public int getCurrent() { + return current; + } + + public int getMax() { + return max; + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/DeviceLimitExceededException.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/DeviceLimitExceededException.java new file mode 100644 index 000000000..ccb03bca6 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/DeviceLimitExceededException.java @@ -0,0 +1,20 @@ +package org.whispersystems.signalservice.internal.push; + +import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException; + +public class DeviceLimitExceededException extends NonSuccessfulResponseCodeException { + + private final DeviceLimit deviceLimit; + + public DeviceLimitExceededException(DeviceLimit deviceLimit) { + this.deviceLimit = deviceLimit; + } + + public int getCurrent() { + return deviceLimit.getCurrent(); + } + + public int getMax() { + return deviceLimit.getMax(); + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/LockedException.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/LockedException.java new file mode 100644 index 000000000..bc4850942 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/LockedException.java @@ -0,0 +1,23 @@ +package org.whispersystems.signalservice.internal.push; + + +import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException; + +public class LockedException extends NonSuccessfulResponseCodeException { + + private int length; + private long timeRemaining; + + LockedException(int length, long timeRemaining) { + this.length = length; + this.timeRemaining = timeRemaining; + } + + public int getLength() { + return length; + } + + public long getTimeRemaining() { + return timeRemaining; + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/MismatchedDevices.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/MismatchedDevices.java new file mode 100644 index 000000000..3d4dfefa0 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/MismatchedDevices.java @@ -0,0 +1,27 @@ +/** + * Copyright (C) 2014-2016 Open Whisper Systems + * + * Licensed according to the LICENSE file in this repository. + */ + +package org.whispersystems.signalservice.internal.push; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; + +public class MismatchedDevices { + @JsonProperty + private List missingDevices; + + @JsonProperty + private List extraDevices; + + public List getMissingDevices() { + return missingDevices; + } + + public List getExtraDevices() { + return extraDevices; + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/OutgoingPushMessage.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/OutgoingPushMessage.java new file mode 100644 index 000000000..eb8e0c8bb --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/OutgoingPushMessage.java @@ -0,0 +1,33 @@ +/** + * Copyright (C) 2014-2016 Open Whisper Systems + * + * Licensed according to the LICENSE file in this repository. + */ + +package org.whispersystems.signalservice.internal.push; + + +import com.fasterxml.jackson.annotation.JsonProperty; + +public class OutgoingPushMessage { + + @JsonProperty + private int type; + @JsonProperty + private int destinationDeviceId; + @JsonProperty + private int destinationRegistrationId; + @JsonProperty + private String content; + + public OutgoingPushMessage(int type, + int destinationDeviceId, + int destinationRegistrationId, + String content) + { + this.type = type; + this.destinationDeviceId = destinationDeviceId; + this.destinationRegistrationId = destinationRegistrationId; + this.content = content; + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/OutgoingPushMessageList.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/OutgoingPushMessageList.java new file mode 100644 index 000000000..50965212c --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/OutgoingPushMessageList.java @@ -0,0 +1,53 @@ +/* + * Copyright (C) 2014-2016 Open Whisper Systems + * + * Licensed according to the LICENSE file in this repository. + */ + +package org.whispersystems.signalservice.internal.push; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; + +public class OutgoingPushMessageList { + + @JsonProperty + private String destination; + + @JsonProperty + private long timestamp; + + @JsonProperty + private List messages; + + @JsonProperty + private boolean online; + + public OutgoingPushMessageList(String destination, + long timestamp, + List messages, + boolean online) + { + this.timestamp = timestamp; + this.destination = destination; + this.messages = messages; + this.online = online; + } + + public String getDestination() { + return destination; + } + + public List getMessages() { + return messages; + } + + public long getTimestamp() { + return timestamp; + } + + public boolean isOnline() { + return online; + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PreKeyEntity.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PreKeyEntity.java new file mode 100644 index 000000000..8f60db17a --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PreKeyEntity.java @@ -0,0 +1,68 @@ +/** + * Copyright (C) 2014-2016 Open Whisper Systems + * + * Licensed according to the LICENSE file in this repository. + */ + +package org.whispersystems.signalservice.internal.push; + +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.JsonSerializer; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; + +import org.whispersystems.libsignal.InvalidKeyException; +import org.whispersystems.libsignal.ecc.Curve; +import org.whispersystems.libsignal.ecc.ECPublicKey; +import org.whispersystems.util.Base64; + +import java.io.IOException; + +public class PreKeyEntity { + + @JsonProperty + private int keyId; + + @JsonProperty + @JsonSerialize(using = ECPublicKeySerializer.class) + @JsonDeserialize(using = ECPublicKeyDeserializer.class) + private ECPublicKey publicKey; + + public PreKeyEntity() {} + + public PreKeyEntity(int keyId, ECPublicKey publicKey) { + this.keyId = keyId; + this.publicKey = publicKey; + } + + public int getKeyId() { + return keyId; + } + + public ECPublicKey getPublicKey() { + return publicKey; + } + + private static class ECPublicKeySerializer extends JsonSerializer { + @Override + public void serialize(ECPublicKey value, JsonGenerator gen, SerializerProvider serializers) throws IOException { + gen.writeString(Base64.encodeBytesWithoutPadding(value.serialize())); + } + } + + private static class ECPublicKeyDeserializer extends JsonDeserializer { + @Override + public ECPublicKey deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + try { + return Curve.decodePoint(Base64.decodeWithoutPadding(p.getValueAsString()), 0); + } catch (InvalidKeyException e) { + throw new IOException(e); + } + } + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PreKeyResponse.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PreKeyResponse.java new file mode 100644 index 000000000..f6de2947e --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PreKeyResponse.java @@ -0,0 +1,37 @@ +/** + * Copyright (C) 2014-2016 Open Whisper Systems + * + * Licensed according to the LICENSE file in this repository. + */ + +package org.whispersystems.signalservice.internal.push; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; + +import org.whispersystems.libsignal.IdentityKey; +import org.whispersystems.signalservice.internal.util.JsonUtil; + +import java.util.List; + +public class PreKeyResponse { + + @JsonProperty + @JsonSerialize(using = JsonUtil.IdentityKeySerializer.class) + @JsonDeserialize(using = JsonUtil.IdentityKeyDeserializer.class) + private IdentityKey identityKey; + + @JsonProperty + private List devices; + + public IdentityKey getIdentityKey() { + return identityKey; + } + + public List getDevices() { + return devices; + } + + +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PreKeyResponseItem.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PreKeyResponseItem.java new file mode 100644 index 000000000..2f2641519 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PreKeyResponseItem.java @@ -0,0 +1,43 @@ +/** + * Copyright (C) 2014-2016 Open Whisper Systems + * + * Licensed according to the LICENSE file in this repository. + */ + +package org.whispersystems.signalservice.internal.push; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import org.whispersystems.signalservice.api.push.SignedPreKeyEntity; + +public class PreKeyResponseItem { + + @JsonProperty + private int deviceId; + + @JsonProperty + private int registrationId; + + @JsonProperty + private SignedPreKeyEntity signedPreKey; + + @JsonProperty + private PreKeyEntity preKey; + + public int getDeviceId() { + return deviceId; + } + + public int getRegistrationId() { + return registrationId; + } + + public SignedPreKeyEntity getSignedPreKey() { + return signedPreKey; + } + + public PreKeyEntity getPreKey() { + return preKey; + } + +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PreKeyState.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PreKeyState.java new file mode 100644 index 000000000..2e710b804 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PreKeyState.java @@ -0,0 +1,33 @@ +package org.whispersystems.signalservice.internal.push; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; + +import org.whispersystems.libsignal.IdentityKey; +import org.whispersystems.signalservice.api.push.SignedPreKeyEntity; +import org.whispersystems.signalservice.internal.util.JsonUtil; + +import java.util.List; + +public class PreKeyState { + + @JsonProperty + @JsonSerialize(using = JsonUtil.IdentityKeySerializer.class) + @JsonDeserialize(using = JsonUtil.IdentityKeyDeserializer.class) + private IdentityKey identityKey; + + @JsonProperty + private List preKeys; + + @JsonProperty + private SignedPreKeyEntity signedPreKey; + + + public PreKeyState(List preKeys, SignedPreKeyEntity signedPreKey, IdentityKey identityKey) { + this.preKeys = preKeys; + this.signedPreKey = signedPreKey; + this.identityKey = identityKey; + } + +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PreKeyStatus.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PreKeyStatus.java new file mode 100644 index 000000000..d4723448d --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PreKeyStatus.java @@ -0,0 +1,21 @@ +/** + * Copyright (C) 2014-2016 Open Whisper Systems + * + * Licensed according to the LICENSE file in this repository. + */ + +package org.whispersystems.signalservice.internal.push; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public class PreKeyStatus { + + @JsonProperty + private int count; + + public PreKeyStatus() {} + + public int getCount() { + return count; + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/ProfileAvatarData.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/ProfileAvatarData.java new file mode 100644 index 000000000..1044135ba --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/ProfileAvatarData.java @@ -0,0 +1,37 @@ +package org.whispersystems.signalservice.internal.push; + + +import org.whispersystems.signalservice.internal.push.http.OutputStreamFactory; + +import java.io.InputStream; + +public class ProfileAvatarData { + + private final InputStream data; + private final long dataLength; + private final String contentType; + private final OutputStreamFactory outputStreamFactory; + + public ProfileAvatarData(InputStream data, long dataLength, String contentType, OutputStreamFactory outputStreamFactory) { + this.data = data; + this.dataLength = dataLength; + this.contentType = contentType; + this.outputStreamFactory = outputStreamFactory; + } + + public InputStream getData() { + return data; + } + + public long getDataLength() { + return dataLength; + } + + public OutputStreamFactory getOutputStreamFactory() { + return outputStreamFactory; + } + + public String getContentType() { + return contentType; + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/ProfileAvatarUploadAttributes.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/ProfileAvatarUploadAttributes.java new file mode 100644 index 000000000..b5f52f831 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/ProfileAvatarUploadAttributes.java @@ -0,0 +1,64 @@ +package org.whispersystems.signalservice.internal.push; + + +import com.fasterxml.jackson.annotation.JsonProperty; + +public class ProfileAvatarUploadAttributes { + @JsonProperty + private String url; + + @JsonProperty + private String key; + + @JsonProperty + private String credential; + + @JsonProperty + private String acl; + + @JsonProperty + private String algorithm; + + @JsonProperty + private String date; + + @JsonProperty + private String policy; + + @JsonProperty + private String signature; + + public ProfileAvatarUploadAttributes() {} + + public String getUrl() { + return url; + } + + public String getKey() { + return key; + } + + public String getCredential() { + return credential; + } + + public String getAcl() { + return acl; + } + + public String getAlgorithm() { + return algorithm; + } + + public String getDate() { + return date; + } + + public String getPolicy() { + return policy; + } + + public String getSignature() { + return signature; + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/ProvisioningMessage.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/ProvisioningMessage.java new file mode 100644 index 000000000..2475bdbe9 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/ProvisioningMessage.java @@ -0,0 +1,14 @@ +package org.whispersystems.signalservice.internal.push; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public class ProvisioningMessage { + + @JsonProperty + private String body; + + public ProvisioningMessage(String body) { + this.body = body; + } + +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PushAttachmentData.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PushAttachmentData.java new file mode 100644 index 000000000..d4368f536 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PushAttachmentData.java @@ -0,0 +1,51 @@ +/** + * Copyright (C) 2014-2016 Open Whisper Systems + * + * Licensed according to the LICENSE file in this repository. + */ + +package org.whispersystems.signalservice.internal.push; + +import org.whispersystems.signalservice.api.messages.SignalServiceAttachment.ProgressListener; +import org.whispersystems.signalservice.internal.push.http.OutputStreamFactory; + +import java.io.InputStream; + +public class PushAttachmentData { + + private final String contentType; + private final InputStream data; + private final long dataSize; + private final OutputStreamFactory outputStreamFactory; + private final ProgressListener listener; + + public PushAttachmentData(String contentType, InputStream data, long dataSize, + OutputStreamFactory outputStreamFactory, ProgressListener listener) + { + this.contentType = contentType; + this.data = data; + this.dataSize = dataSize; + this.outputStreamFactory = outputStreamFactory; + this.listener = listener; + } + + public String getContentType() { + return contentType; + } + + public InputStream getData() { + return data; + } + + public long getDataSize() { + return dataSize; + } + + public OutputStreamFactory getOutputStreamFactory() { + return outputStreamFactory; + } + + public ProgressListener getListener() { + return listener; + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java new file mode 100644 index 000000000..a9d4454b1 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java @@ -0,0 +1,1193 @@ +/* + * Copyright (C) 2014-2017 Open Whisper Systems + * + * Licensed according to the LICENSE file in this repository. + */ + +package org.whispersystems.signalservice.internal.push; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.core.JsonProcessingException; + +import org.whispersystems.libsignal.IdentityKey; +import org.whispersystems.libsignal.ecc.ECPublicKey; +import org.whispersystems.libsignal.logging.Log; +import org.whispersystems.libsignal.state.PreKeyBundle; +import org.whispersystems.libsignal.state.PreKeyRecord; +import org.whispersystems.libsignal.state.SignedPreKeyRecord; +import org.whispersystems.libsignal.util.Pair; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.crypto.UnidentifiedAccess; +import org.whispersystems.signalservice.api.messages.SignalServiceAttachment.ProgressListener; +import org.whispersystems.signalservice.api.messages.calls.TurnServerInfo; +import org.whispersystems.signalservice.api.messages.multidevice.DeviceInfo; +import org.whispersystems.signalservice.api.profiles.SignalServiceProfile; +import org.whispersystems.signalservice.api.push.ContactTokenDetails; +import org.whispersystems.signalservice.api.push.SignalServiceAddress; +import org.whispersystems.signalservice.api.push.SignedPreKeyEntity; +import org.whispersystems.signalservice.api.push.exceptions.AuthorizationFailedException; +import org.whispersystems.signalservice.api.push.exceptions.CaptchaRequiredException; +import org.whispersystems.signalservice.api.push.exceptions.ExpectationFailedException; +import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException; +import org.whispersystems.signalservice.api.push.exceptions.NotFoundException; +import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException; +import org.whispersystems.signalservice.api.push.exceptions.RateLimitException; +import org.whispersystems.signalservice.api.push.exceptions.RemoteAttestationResponseExpiredException; +import org.whispersystems.signalservice.api.push.exceptions.UnregisteredUserException; +import org.whispersystems.signalservice.api.util.CredentialsProvider; +import org.whispersystems.signalservice.api.util.Tls12SocketFactory; +import org.whispersystems.signalservice.api.util.UuidUtil; +import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration; +import org.whispersystems.signalservice.internal.configuration.SignalUrl; +import org.whispersystems.signalservice.internal.contacts.entities.DiscoveryRequest; +import org.whispersystems.signalservice.internal.contacts.entities.DiscoveryResponse; +import org.whispersystems.signalservice.internal.contacts.entities.RemoteAttestationRequest; +import org.whispersystems.signalservice.internal.contacts.entities.RemoteAttestationResponse; +import org.whispersystems.signalservice.internal.push.exceptions.MismatchedDevicesException; +import org.whispersystems.signalservice.internal.push.exceptions.StaleDevicesException; +import org.whispersystems.signalservice.internal.push.http.DigestingRequestBody; +import org.whispersystems.signalservice.internal.push.http.OutputStreamFactory; +import org.whispersystems.signalservice.internal.util.BlacklistingTrustManager; +import org.whispersystems.signalservice.internal.util.Hex; +import org.whispersystems.signalservice.internal.util.JsonUtil; +import org.whispersystems.signalservice.internal.util.Util; +import org.whispersystems.util.Base64; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.UnsupportedEncodingException; +import java.net.URLEncoder; +import java.security.KeyManagementException; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.util.Collections; +import java.util.HashSet; +import java.util.LinkedList; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManager; +import javax.net.ssl.TrustManagerFactory; +import javax.net.ssl.X509TrustManager; + +import okhttp3.Call; +import okhttp3.ConnectionSpec; +import okhttp3.Credentials; +import okhttp3.MediaType; +import okhttp3.MultipartBody; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; +import okhttp3.ResponseBody; + +/** + * @author Moxie Marlinspike + */ +public class PushServiceSocket { + + private static final String TAG = PushServiceSocket.class.getSimpleName(); + + private static final String CREATE_ACCOUNT_SMS_PATH = "/v1/accounts/sms/code/%s?client=%s"; + private static final String CREATE_ACCOUNT_VOICE_PATH = "/v1/accounts/voice/code/%s"; + private static final String VERIFY_ACCOUNT_CODE_PATH = "/v1/accounts/code/%s"; + private static final String REGISTER_GCM_PATH = "/v1/accounts/gcm/"; + private static final String TURN_SERVER_INFO = "/v1/accounts/turn"; + private static final String SET_ACCOUNT_ATTRIBUTES = "/v1/accounts/attributes/"; + private static final String PIN_PATH = "/v1/accounts/pin/"; + private static final String REQUEST_PUSH_CHALLENGE = "/v1/accounts/fcm/preauth/%s/%s"; + private static final String WHO_AM_I = "/v1/accounts/whoami"; + + private static final String PREKEY_METADATA_PATH = "/v2/keys/"; + private static final String PREKEY_PATH = "/v2/keys/%s"; + private static final String PREKEY_DEVICE_PATH = "/v2/keys/%s/%s"; + private static final String SIGNED_PREKEY_PATH = "/v2/keys/signed"; + + private static final String PROVISIONING_CODE_PATH = "/v1/devices/provisioning/code"; + private static final String PROVISIONING_MESSAGE_PATH = "/v1/provisioning/%s"; + private static final String DEVICE_PATH = "/v1/devices/%s"; + + private static final String DIRECTORY_TOKENS_PATH = "/v1/directory/tokens"; + private static final String DIRECTORY_VERIFY_PATH = "/v1/directory/%s"; + private static final String DIRECTORY_AUTH_PATH = "/v1/directory/auth"; + private static final String DIRECTORY_FEEDBACK_PATH = "/v1/directory/feedback-v3/%s"; + private static final String MESSAGE_PATH = "/v1/messages/%s"; + private static final String SENDER_ACK_MESSAGE_PATH = "/v1/messages/%s/%d"; + private static final String UUID_ACK_MESSAGE_PATH = "/v1/messages/uuid/%s"; + private static final String ATTACHMENT_PATH = "/v2/attachments/form/upload"; + + private static final String PROFILE_PATH = "/v1/profile/%s"; + + private static final String SENDER_CERTIFICATE_LEGACY_PATH = "/v1/certificate/delivery"; + private static final String SENDER_CERTIFICATE_PATH = "/v1/certificate/delivery?includeUuid=true"; + + private static final String ATTACHMENT_DOWNLOAD_PATH = "attachments/%d"; + private static final String ATTACHMENT_UPLOAD_PATH = "attachments/"; + + private static final String STICKER_MANIFEST_PATH = "stickers/%s/manifest.proto"; + private static final String STICKER_PATH = "stickers/%s/full/%d"; + + private static final Map NO_HEADERS = Collections.emptyMap(); + private static final ResponseCodeHandler NO_HANDLER = new EmptyResponseCodeHandler(); + + private long soTimeoutMillis = TimeUnit.SECONDS.toMillis(30); + private final Set connections = new HashSet<>(); + + private final ServiceConnectionHolder[] serviceClients; + private final ConnectionHolder[] cdnClients; + private final ConnectionHolder[] contactDiscoveryClients; + private final OkHttpClient attachmentClient; + + private final CredentialsProvider credentialsProvider; + private final String userAgent; + private final SecureRandom random; + + public PushServiceSocket(SignalServiceConfiguration signalServiceConfiguration, CredentialsProvider credentialsProvider, String userAgent) { + this.credentialsProvider = credentialsProvider; + this.userAgent = userAgent; + this.serviceClients = createServiceConnectionHolders(signalServiceConfiguration.getSignalServiceUrls()); + this.cdnClients = createConnectionHolders(signalServiceConfiguration.getSignalCdnUrls()); + this.contactDiscoveryClients = createConnectionHolders(signalServiceConfiguration.getSignalContactDiscoveryUrls()); + this.attachmentClient = createAttachmentClient(); + this.random = new SecureRandom(); + } + + public void requestSmsVerificationCode(boolean androidSmsRetriever, Optional captchaToken, Optional challenge) throws IOException { + String path = String.format(CREATE_ACCOUNT_SMS_PATH, credentialsProvider.getE164(), androidSmsRetriever ? "android-ng" : "android"); + + if (captchaToken.isPresent()) { + path += "&captcha=" + captchaToken.get(); + } else if (challenge.isPresent()) { + path += "&challenge=" + challenge.get(); + } + + makeServiceRequest(path, "GET", null, NO_HEADERS, new ResponseCodeHandler() { + @Override + public void handle(int responseCode) throws NonSuccessfulResponseCodeException { + if (responseCode == 402) { + throw new CaptchaRequiredException(); + } + } + }); + } + + public void requestVoiceVerificationCode(Locale locale, Optional captchaToken, Optional challenge) throws IOException { + Map headers = locale != null ? Collections.singletonMap("Accept-Language", locale.getLanguage() + "-" + locale.getCountry()) : NO_HEADERS; + String path = String.format(CREATE_ACCOUNT_VOICE_PATH, credentialsProvider.getE164()); + + if (captchaToken.isPresent()) { + path += "?captcha=" + captchaToken.get(); + } else if (challenge.isPresent()) { + path += "?challenge=" + challenge.get(); + } + + makeServiceRequest(path, "GET", null, headers, new ResponseCodeHandler() { + @Override + public void handle(int responseCode) throws NonSuccessfulResponseCodeException { + if (responseCode == 402) { + throw new CaptchaRequiredException(); + } + } + }); + } + + public UUID getOwnUuid() throws IOException { + String body = makeServiceRequest(WHO_AM_I, "GET", null); + WhoAmIResponse response = JsonUtil.fromJson(body, WhoAmIResponse.class); + Optional uuid = UuidUtil.parse(response.getUuid()); + + if (uuid.isPresent()) { + return uuid.get(); + } else { + throw new IOException("Invalid UUID!"); + } + } + + public UUID verifyAccountCode(String verificationCode, String signalingKey, int registrationId, boolean fetchesMessages, String pin, + byte[] unidentifiedAccessKey, boolean unrestrictedUnidentifiedAccess) + throws IOException + { + AccountAttributes signalingKeyEntity = new AccountAttributes(signalingKey, registrationId, fetchesMessages, pin, unidentifiedAccessKey, unrestrictedUnidentifiedAccess); + String requestBody = JsonUtil.toJson(signalingKeyEntity); + String responseBody = makeServiceRequest(String.format(VERIFY_ACCOUNT_CODE_PATH, verificationCode), "PUT", requestBody); + VerifyAccountResponse response = JsonUtil.fromJson(responseBody, VerifyAccountResponse.class); + Optional uuid = UuidUtil.parse(response.getUuid()); + + if (uuid.isPresent()) { + return uuid.get(); + } else { + throw new IOException("Invalid UUID!"); + } + } + + public void setAccountAttributes(String signalingKey, int registrationId, boolean fetchesMessages, String pin, + byte[] unidentifiedAccessKey, boolean unrestrictedUnidentifiedAccess) + throws IOException + { + AccountAttributes accountAttributes = new AccountAttributes(signalingKey, registrationId, fetchesMessages, pin, + unidentifiedAccessKey, unrestrictedUnidentifiedAccess); + makeServiceRequest(SET_ACCOUNT_ATTRIBUTES, "PUT", JsonUtil.toJson(accountAttributes)); + } + + public String getNewDeviceVerificationCode() throws IOException { + String responseText = makeServiceRequest(PROVISIONING_CODE_PATH, "GET", null); + return JsonUtil.fromJson(responseText, DeviceCode.class).getVerificationCode(); + } + + public List getDevices() throws IOException { + String responseText = makeServiceRequest(String.format(DEVICE_PATH, ""), "GET", null); + return JsonUtil.fromJson(responseText, DeviceInfoList.class).getDevices(); + } + + public void removeDevice(long deviceId) throws IOException { + makeServiceRequest(String.format(DEVICE_PATH, String.valueOf(deviceId)), "DELETE", null); + } + + public void sendProvisioningMessage(String destination, byte[] body) throws IOException { + makeServiceRequest(String.format(PROVISIONING_MESSAGE_PATH, destination), "PUT", + JsonUtil.toJson(new ProvisioningMessage(Base64.encodeBytes(body)))); + } + + public void registerGcmId(String gcmRegistrationId) throws IOException { + GcmRegistrationId registration = new GcmRegistrationId(gcmRegistrationId, true); + makeServiceRequest(REGISTER_GCM_PATH, "PUT", JsonUtil.toJson(registration)); + } + + public void unregisterGcmId() throws IOException { + makeServiceRequest(REGISTER_GCM_PATH, "DELETE", null); + } + + public void requestPushChallenge(String gcmRegistrationId, String e164number) throws IOException { + makeServiceRequest(String.format(Locale.US, REQUEST_PUSH_CHALLENGE, gcmRegistrationId, e164number), "GET", null); + } + + public void setPin(String pin) throws IOException { + RegistrationLock accountLock = new RegistrationLock(pin); + makeServiceRequest(PIN_PATH, "PUT", JsonUtil.toJson(accountLock)); + } + + public void removePin() throws IOException { + makeServiceRequest(PIN_PATH, "DELETE", null); + } + + public byte[] getSenderCertificateLegacy() throws IOException { + String responseText = makeServiceRequest(SENDER_CERTIFICATE_LEGACY_PATH, "GET", null); + return JsonUtil.fromJson(responseText, SenderCertificate.class).getCertificate(); + } + + public byte[] getSenderCertificate() throws IOException { + String responseText = makeServiceRequest(SENDER_CERTIFICATE_PATH, "GET", null); + return JsonUtil.fromJson(responseText, SenderCertificate.class).getCertificate(); + } + + public SendMessageResponse sendMessage(OutgoingPushMessageList bundle, Optional unidentifiedAccess) + throws IOException + { + try { + String responseText = makeServiceRequest(String.format(MESSAGE_PATH, bundle.getDestination()), "PUT", JsonUtil.toJson(bundle), NO_HEADERS, unidentifiedAccess); + + if (responseText == null) return new SendMessageResponse(false); + else return JsonUtil.fromJson(responseText, SendMessageResponse.class); + } catch (NotFoundException nfe) { + throw new UnregisteredUserException(bundle.getDestination(), nfe); + } + } + + public List getMessages() throws IOException { + String responseText = makeServiceRequest(String.format(MESSAGE_PATH, ""), "GET", null); + return JsonUtil.fromJson(responseText, SignalServiceEnvelopeEntityList.class).getMessages(); + } + + public void acknowledgeMessage(String sender, long timestamp) throws IOException { + makeServiceRequest(String.format(Locale.US, SENDER_ACK_MESSAGE_PATH, sender, timestamp), "DELETE", null); + } + + public void acknowledgeMessage(String uuid) throws IOException { + makeServiceRequest(String.format(UUID_ACK_MESSAGE_PATH, uuid), "DELETE", null); + } + + public void registerPreKeys(IdentityKey identityKey, + SignedPreKeyRecord signedPreKey, + List records) + throws IOException + { + List entities = new LinkedList<>(); + + for (PreKeyRecord record : records) { + PreKeyEntity entity = new PreKeyEntity(record.getId(), + record.getKeyPair().getPublicKey()); + + entities.add(entity); + } + + SignedPreKeyEntity signedPreKeyEntity = new SignedPreKeyEntity(signedPreKey.getId(), + signedPreKey.getKeyPair().getPublicKey(), + signedPreKey.getSignature()); + + makeServiceRequest(String.format(PREKEY_PATH, ""), "PUT", + JsonUtil.toJson(new PreKeyState(entities, signedPreKeyEntity, identityKey))); + } + + public int getAvailablePreKeys() throws IOException { + String responseText = makeServiceRequest(PREKEY_METADATA_PATH, "GET", null); + PreKeyStatus preKeyStatus = JsonUtil.fromJson(responseText, PreKeyStatus.class); + + return preKeyStatus.getCount(); + } + + public List getPreKeys(SignalServiceAddress destination, + Optional unidentifiedAccess, + int deviceIdInteger) + throws IOException + { + try { + String deviceId = String.valueOf(deviceIdInteger); + + if (deviceId.equals("1")) + deviceId = "*"; + + String path = String.format(PREKEY_DEVICE_PATH, destination.getIdentifier(), deviceId); + + if (destination.getRelay().isPresent()) { + path = path + "?relay=" + destination.getRelay().get(); + } + + String responseText = makeServiceRequest(path, "GET", null, NO_HEADERS, unidentifiedAccess); + PreKeyResponse response = JsonUtil.fromJson(responseText, PreKeyResponse.class); + List bundles = new LinkedList<>(); + + for (PreKeyResponseItem device : response.getDevices()) { + ECPublicKey preKey = null; + ECPublicKey signedPreKey = null; + byte[] signedPreKeySignature = null; + int preKeyId = -1; + int signedPreKeyId = -1; + + if (device.getSignedPreKey() != null) { + signedPreKey = device.getSignedPreKey().getPublicKey(); + signedPreKeyId = device.getSignedPreKey().getKeyId(); + signedPreKeySignature = device.getSignedPreKey().getSignature(); + } + + if (device.getPreKey() != null) { + preKeyId = device.getPreKey().getKeyId(); + preKey = device.getPreKey().getPublicKey(); + } + + bundles.add(new PreKeyBundle(device.getRegistrationId(), device.getDeviceId(), preKeyId, + preKey, signedPreKeyId, signedPreKey, signedPreKeySignature, + response.getIdentityKey())); + } + + return bundles; + } catch (NotFoundException nfe) { + throw new UnregisteredUserException(destination.getIdentifier(), nfe); + } + } + + public PreKeyBundle getPreKey(SignalServiceAddress destination, int deviceId) throws IOException { + try { + String path = String.format(PREKEY_DEVICE_PATH, destination.getIdentifier(), + String.valueOf(deviceId)); + + if (destination.getRelay().isPresent()) { + path = path + "?relay=" + destination.getRelay().get(); + } + + String responseText = makeServiceRequest(path, "GET", null); + PreKeyResponse response = JsonUtil.fromJson(responseText, PreKeyResponse.class); + + if (response.getDevices() == null || response.getDevices().size() < 1) + throw new IOException("Empty prekey list"); + + PreKeyResponseItem device = response.getDevices().get(0); + ECPublicKey preKey = null; + ECPublicKey signedPreKey = null; + byte[] signedPreKeySignature = null; + int preKeyId = -1; + int signedPreKeyId = -1; + + if (device.getPreKey() != null) { + preKeyId = device.getPreKey().getKeyId(); + preKey = device.getPreKey().getPublicKey(); + } + + if (device.getSignedPreKey() != null) { + signedPreKeyId = device.getSignedPreKey().getKeyId(); + signedPreKey = device.getSignedPreKey().getPublicKey(); + signedPreKeySignature = device.getSignedPreKey().getSignature(); + } + + return new PreKeyBundle(device.getRegistrationId(), device.getDeviceId(), preKeyId, preKey, + signedPreKeyId, signedPreKey, signedPreKeySignature, response.getIdentityKey()); + } catch (NotFoundException nfe) { + throw new UnregisteredUserException(destination.getIdentifier(), nfe); + } + } + + public SignedPreKeyEntity getCurrentSignedPreKey() throws IOException { + try { + String responseText = makeServiceRequest(SIGNED_PREKEY_PATH, "GET", null); + return JsonUtil.fromJson(responseText, SignedPreKeyEntity.class); + } catch (NotFoundException e) { + Log.w(TAG, e); + return null; + } + } + + public void setCurrentSignedPreKey(SignedPreKeyRecord signedPreKey) throws IOException { + SignedPreKeyEntity signedPreKeyEntity = new SignedPreKeyEntity(signedPreKey.getId(), + signedPreKey.getKeyPair().getPublicKey(), + signedPreKey.getSignature()); + makeServiceRequest(SIGNED_PREKEY_PATH, "PUT", JsonUtil.toJson(signedPreKeyEntity)); + } + + public void retrieveAttachment(long attachmentId, File destination, int maxSizeBytes, ProgressListener listener) + throws NonSuccessfulResponseCodeException, PushNetworkException + { + downloadFromCdn(destination, String.format(Locale.US, ATTACHMENT_DOWNLOAD_PATH, attachmentId), maxSizeBytes, listener); + } + + public void retrieveSticker(File destination, byte[] packId, int stickerId) + throws NonSuccessfulResponseCodeException, PushNetworkException + { + String hexPackId = Hex.toStringCondensed(packId); + downloadFromCdn(destination, String.format(Locale.US, STICKER_PATH, hexPackId, stickerId), 1024 * 1024, null); + } + + public byte[] retrieveSticker(byte[] packId, int stickerId) + throws NonSuccessfulResponseCodeException, PushNetworkException + { + String hexPackId = Hex.toStringCondensed(packId); + ByteArrayOutputStream output = new ByteArrayOutputStream(); + + downloadFromCdn(output, String.format(Locale.US, STICKER_PATH, hexPackId, stickerId), 1024 * 1024, null); + + return output.toByteArray(); + } + + public byte[] retrieveStickerManifest(byte[] packId) + throws NonSuccessfulResponseCodeException, PushNetworkException + { + String hexPackId = Hex.toStringCondensed(packId); + ByteArrayOutputStream output = new ByteArrayOutputStream(); + + downloadFromCdn(output, String.format(STICKER_MANIFEST_PATH, hexPackId), 1024 * 1024, null); + + return output.toByteArray(); + } + + public SignalServiceProfile retrieveProfile(SignalServiceAddress target, Optional unidentifiedAccess) + throws NonSuccessfulResponseCodeException, PushNetworkException + { + try { + String response = makeServiceRequest(String.format(PROFILE_PATH, target.getIdentifier()), "GET", null, NO_HEADERS, unidentifiedAccess); + return JsonUtil.fromJson(response, SignalServiceProfile.class); + } catch (IOException e) { + Log.w(TAG, e); + throw new NonSuccessfulResponseCodeException("Unable to parse entity"); + } + } + + public void retrieveProfileAvatar(String path, File destination, int maxSizeBytes) + throws NonSuccessfulResponseCodeException, PushNetworkException + { + downloadFromCdn(destination, path, maxSizeBytes, null); + } + + public void setProfileName(String name) throws NonSuccessfulResponseCodeException, PushNetworkException { + makeServiceRequest(String.format(PROFILE_PATH, "name/" + (name == null ? "" : URLEncoder.encode(name))), "PUT", ""); + } + + public void setProfileAvatar(ProfileAvatarData profileAvatar) + throws NonSuccessfulResponseCodeException, PushNetworkException + { + String response = makeServiceRequest(String.format(PROFILE_PATH, "form/avatar"), "GET", null); + ProfileAvatarUploadAttributes formAttributes; + + try { + formAttributes = JsonUtil.fromJson(response, ProfileAvatarUploadAttributes.class); + } catch (IOException e) { + Log.w(TAG, e); + throw new NonSuccessfulResponseCodeException("Unable to parse entity"); + } + + if (profileAvatar != null) { + uploadToCdn("", formAttributes.getAcl(), formAttributes.getKey(), + formAttributes.getPolicy(), formAttributes.getAlgorithm(), + formAttributes.getCredential(), formAttributes.getDate(), + formAttributes.getSignature(), profileAvatar.getData(), + profileAvatar.getContentType(), profileAvatar.getDataLength(), + profileAvatar.getOutputStreamFactory(), null); + } + } + + public List retrieveDirectory(Set contactTokens) + throws NonSuccessfulResponseCodeException, PushNetworkException + { + try { + ContactTokenList contactTokenList = new ContactTokenList(new LinkedList<>(contactTokens)); + String response = makeServiceRequest(DIRECTORY_TOKENS_PATH, "PUT", JsonUtil.toJson(contactTokenList)); + ContactTokenDetailsList activeTokens = JsonUtil.fromJson(response, ContactTokenDetailsList.class); + + return activeTokens.getContacts(); + } catch (IOException e) { + Log.w(TAG, e); + throw new NonSuccessfulResponseCodeException("Unable to parse entity"); + } + } + + public ContactTokenDetails getContactTokenDetails(String contactToken) throws IOException { + try { + String response = makeServiceRequest(String.format(DIRECTORY_VERIFY_PATH, contactToken), "GET", null); + return JsonUtil.fromJson(response, ContactTokenDetails.class); + } catch (NotFoundException nfe) { + return null; + } + } + + public String getContactDiscoveryAuthorization() throws IOException { + String response = makeServiceRequest(DIRECTORY_AUTH_PATH, "GET", null); + ContactDiscoveryCredentials token = JsonUtil.fromJson(response, ContactDiscoveryCredentials.class); + return Credentials.basic(token.getUsername(), token.getPassword()); + } + + public Pair> getContactDiscoveryRemoteAttestation(String authorization, RemoteAttestationRequest request, String mrenclave) + throws IOException + { + Response response = makeContactDiscoveryRequest(authorization, new LinkedList(), "/v1/attestation/" + mrenclave, "PUT", JsonUtil.toJson(request)); + ResponseBody body = response.body(); + List rawCookies = response.headers("Set-Cookie"); + List cookies = new LinkedList<>(); + + for (String cookie : rawCookies) { + cookies.add(cookie.split(";")[0]); + } + + if (body != null) { + return new Pair<>(JsonUtil.fromJson(body.string(), RemoteAttestationResponse.class), cookies); + } else { + throw new NonSuccessfulResponseCodeException("Empty response!"); + } + } + + public DiscoveryResponse getContactDiscoveryRegisteredUsers(String authorizationToken, DiscoveryRequest request, List cookies, String mrenclave) + throws IOException + { + ResponseBody body = makeContactDiscoveryRequest(authorizationToken, cookies, "/v1/discovery/" + mrenclave, "PUT", JsonUtil.toJson(request)).body(); + + if (body != null) { + return JsonUtil.fromJson(body.string(), DiscoveryResponse.class); + } else { + throw new NonSuccessfulResponseCodeException("Empty response!"); + } + } + + public void reportContactDiscoveryServiceMatch() throws IOException { + makeServiceRequest(String.format(DIRECTORY_FEEDBACK_PATH, "ok"), "PUT", ""); + } + + public void reportContactDiscoveryServiceMismatch() throws IOException { + makeServiceRequest(String.format(DIRECTORY_FEEDBACK_PATH, "mismatch"), "PUT", ""); + } + + public void reportContactDiscoveryServiceAttestationError(String reason) throws IOException { + ContactDiscoveryFailureReason failureReason = new ContactDiscoveryFailureReason(reason); + makeServiceRequest(String.format(DIRECTORY_FEEDBACK_PATH, "attestation-error"), "PUT", JsonUtil.toJson(failureReason)); + } + + public void reportContactDiscoveryServiceUnexpectedError(String reason) throws IOException { + ContactDiscoveryFailureReason failureReason = new ContactDiscoveryFailureReason(reason); + makeServiceRequest(String.format(DIRECTORY_FEEDBACK_PATH, "unexpected-error"), "PUT", JsonUtil.toJson(failureReason)); + } + + public TurnServerInfo getTurnServerInfo() throws IOException { + String response = makeServiceRequest(TURN_SERVER_INFO, "GET", null); + return JsonUtil.fromJson(response, TurnServerInfo.class); + } + + public void setSoTimeoutMillis(long soTimeoutMillis) { + this.soTimeoutMillis = soTimeoutMillis; + } + + public void cancelInFlightRequests() { + synchronized (connections) { + Log.w(TAG, "Canceling: " + connections.size()); + for (Call connection : connections) { + Log.w(TAG, "Canceling: " + connection); + connection.cancel(); + } + } + } + + public AttachmentUploadAttributes getAttachmentUploadAttributes() throws NonSuccessfulResponseCodeException, PushNetworkException { + String response = makeServiceRequest(ATTACHMENT_PATH, "GET", null); + try { + return JsonUtil.fromJson(response, AttachmentUploadAttributes.class); + } catch (IOException e) { + Log.w(TAG, e); + throw new NonSuccessfulResponseCodeException("Unable to parse entity"); + } + } + + public Pair uploadAttachment(PushAttachmentData attachment, AttachmentUploadAttributes uploadAttributes) + throws PushNetworkException, NonSuccessfulResponseCodeException + { + long id = Long.parseLong(uploadAttributes.getAttachmentId()); + byte[] digest = uploadToCdn(ATTACHMENT_UPLOAD_PATH, uploadAttributes.getAcl(), uploadAttributes.getKey(), + uploadAttributes.getPolicy(), uploadAttributes.getAlgorithm(), + uploadAttributes.getCredential(), uploadAttributes.getDate(), + uploadAttributes.getSignature(), attachment.getData(), + "application/octet-stream", attachment.getDataSize(), + attachment.getOutputStreamFactory(), attachment.getListener()); + + return new Pair<>(id, digest); + } + + private void downloadFromCdn(File destination, String path, int maxSizeBytes, ProgressListener listener) + throws PushNetworkException, NonSuccessfulResponseCodeException + { + try (FileOutputStream outputStream = new FileOutputStream(destination)) { + downloadFromCdn(outputStream, path, maxSizeBytes, listener); + } catch (IOException e) { + throw new PushNetworkException(e); + } + } + + private void downloadFromCdn(OutputStream outputStream, String path, int maxSizeBytes, ProgressListener listener) + throws PushNetworkException, NonSuccessfulResponseCodeException + { + ConnectionHolder connectionHolder = getRandom(cdnClients, random); + OkHttpClient okHttpClient = connectionHolder.getClient() + .newBuilder() + .connectTimeout(soTimeoutMillis, TimeUnit.MILLISECONDS) + .readTimeout(soTimeoutMillis, TimeUnit.MILLISECONDS) + .build(); + + Request.Builder request = new Request.Builder().url(connectionHolder.getUrl() + "/" + path).get(); + + if (connectionHolder.getHostHeader().isPresent()) { + request.addHeader("Host", connectionHolder.getHostHeader().get()); + } + + Call call = okHttpClient.newCall(request.build()); + + synchronized (connections) { + connections.add(call); + } + + Response response; + + try { + response = call.execute(); + + if (response.isSuccessful()) { + ResponseBody body = response.body(); + + if (body == null) throw new PushNetworkException("No response body!"); + if (body.contentLength() > maxSizeBytes) throw new PushNetworkException("Response exceeds max size!"); + + InputStream in = body.byteStream(); + byte[] buffer = new byte[32768]; + + int read, totalRead = 0; + + while ((read = in.read(buffer, 0, buffer.length)) != -1) { + outputStream.write(buffer, 0, read); + if ((totalRead += read) > maxSizeBytes) throw new PushNetworkException("Response exceeded max size!"); + + if (listener != null) { + listener.onAttachmentProgress(body.contentLength(), totalRead); + } + } + + return; + } + } catch (IOException e) { + throw new PushNetworkException(e); + } finally { + synchronized (connections) { + connections.remove(call); + } + } + + throw new NonSuccessfulResponseCodeException("Response: " + response); + } + + private byte[] uploadToCdn(String path, String acl, String key, String policy, String algorithm, + String credential, String date, String signature, + InputStream data, String contentType, long length, + OutputStreamFactory outputStreamFactory, ProgressListener progressListener) + throws PushNetworkException, NonSuccessfulResponseCodeException + { + ConnectionHolder connectionHolder = getRandom(cdnClients, random); + OkHttpClient okHttpClient = connectionHolder.getClient() + .newBuilder() + .connectTimeout(soTimeoutMillis, TimeUnit.MILLISECONDS) + .readTimeout(soTimeoutMillis, TimeUnit.MILLISECONDS) + .build(); + + DigestingRequestBody file = new DigestingRequestBody(data, outputStreamFactory, contentType, length, progressListener); + + RequestBody requestBody = new MultipartBody.Builder() + .setType(MultipartBody.FORM) + .addFormDataPart("acl", acl) + .addFormDataPart("key", key) + .addFormDataPart("policy", policy) + .addFormDataPart("Content-Type", contentType) + .addFormDataPart("x-amz-algorithm", algorithm) + .addFormDataPart("x-amz-credential", credential) + .addFormDataPart("x-amz-date", date) + .addFormDataPart("x-amz-signature", signature) + .addFormDataPart("file", "file", file) + .build(); + + Request.Builder request = new Request.Builder() + .url(connectionHolder.getUrl() + "/" + path) + .post(requestBody); + + if (connectionHolder.getHostHeader().isPresent()) { + request.addHeader("Host", connectionHolder.getHostHeader().get()); + } + + Call call = okHttpClient.newCall(request.build()); + + synchronized (connections) { + connections.add(call); + } + + try { + Response response; + + try { + response = call.execute(); + } catch (IOException e) { + throw new PushNetworkException(e); + } + + if (response.isSuccessful()) return file.getTransmittedDigest(); + else throw new NonSuccessfulResponseCodeException("Response: " + response); + } finally { + synchronized (connections) { + connections.remove(call); + } + } + } + + private String makeServiceRequest(String urlFragment, String method, String body) + throws NonSuccessfulResponseCodeException, PushNetworkException + { + return makeServiceRequest(urlFragment, method, body, NO_HEADERS, NO_HANDLER, Optional.absent()); + } + + private String makeServiceRequest(String urlFragment, String method, String body, Map headers) + throws NonSuccessfulResponseCodeException, PushNetworkException + { + return makeServiceRequest(urlFragment, method, body, headers, NO_HANDLER, Optional.absent()); + } + + private String makeServiceRequest(String urlFragment, String method, String body, Map headers, ResponseCodeHandler responseCodeHandler) + throws NonSuccessfulResponseCodeException, PushNetworkException + { + return makeServiceRequest(urlFragment, method, body, headers, responseCodeHandler, Optional.absent()); + } + + private String makeServiceRequest(String urlFragment, String method, String body, Map headers, Optional unidentifiedAccessKey) + throws NonSuccessfulResponseCodeException, PushNetworkException + { + return makeServiceRequest(urlFragment, method, body, headers, NO_HANDLER, unidentifiedAccessKey); + } + + private String makeServiceRequest(String urlFragment, String method, String body, Map headers, ResponseCodeHandler responseCodeHandler, Optional unidentifiedAccessKey) + throws NonSuccessfulResponseCodeException, PushNetworkException + { + Response response = getServiceConnection(urlFragment, method, body, headers, unidentifiedAccessKey); + + int responseCode; + String responseMessage; + String responseBody; + + try { + responseCode = response.code(); + responseMessage = response.message(); + responseBody = response.body().string(); + } catch (IOException ioe) { + throw new PushNetworkException(ioe); + } + + responseCodeHandler.handle(responseCode); + + switch (responseCode) { + case 413: + throw new RateLimitException("Rate limit exceeded: " + responseCode); + case 401: + case 403: + throw new AuthorizationFailedException("Authorization failed!"); + case 404: + throw new NotFoundException("Not found"); + case 409: + MismatchedDevices mismatchedDevices; + + try { + mismatchedDevices = JsonUtil.fromJson(responseBody, MismatchedDevices.class); + } catch (JsonProcessingException e) { + Log.w(TAG, e); + throw new NonSuccessfulResponseCodeException("Bad response: " + responseCode + " " + responseMessage); + } catch (IOException e) { + throw new PushNetworkException(e); + } + + throw new MismatchedDevicesException(mismatchedDevices); + case 410: + StaleDevices staleDevices; + + try { + staleDevices = JsonUtil.fromJson(responseBody, StaleDevices.class); + } catch (JsonProcessingException e) { + throw new NonSuccessfulResponseCodeException("Bad response: " + responseCode + " " + responseMessage); + } catch (IOException e) { + throw new PushNetworkException(e); + } + + throw new StaleDevicesException(staleDevices); + case 411: + DeviceLimit deviceLimit; + + try { + deviceLimit = JsonUtil.fromJson(responseBody, DeviceLimit.class); + } catch (JsonProcessingException e) { + throw new NonSuccessfulResponseCodeException("Bad response: " + responseCode + " " + responseMessage); + } catch (IOException e) { + throw new PushNetworkException(e); + } + + throw new DeviceLimitExceededException(deviceLimit); + case 417: + throw new ExpectationFailedException(); + case 423: + RegistrationLockFailure accountLockFailure; + + try { + accountLockFailure = JsonUtil.fromJson(responseBody, RegistrationLockFailure.class); + } catch (JsonProcessingException e) { + Log.w(TAG, e); + throw new NonSuccessfulResponseCodeException("Bad response: " + responseCode + " " + responseMessage); + } catch (IOException e) { + throw new PushNetworkException(e); + } + + throw new LockedException(accountLockFailure.length, accountLockFailure.timeRemaining); + } + + if (responseCode != 200 && responseCode != 204) { + throw new NonSuccessfulResponseCodeException("Bad response: " + responseCode + " " + + responseMessage); + } + + return responseBody; + } + + private Response getServiceConnection(String urlFragment, String method, String body, Map headers, Optional unidentifiedAccess) + throws PushNetworkException + { + try { + ServiceConnectionHolder connectionHolder = (ServiceConnectionHolder) getRandom(serviceClients, random); + OkHttpClient baseClient = unidentifiedAccess.isPresent() ? connectionHolder.getUnidentifiedClient() : connectionHolder.getClient(); + OkHttpClient okHttpClient = baseClient.newBuilder() + .connectTimeout(soTimeoutMillis, TimeUnit.MILLISECONDS) + .readTimeout(soTimeoutMillis, TimeUnit.MILLISECONDS) + .build(); + + Log.w(TAG, "Push service URL: " + connectionHolder.getUrl()); + Log.w(TAG, "Opening URL: " + String.format("%s%s", connectionHolder.getUrl(), urlFragment)); + + Request.Builder request = new Request.Builder(); + request.url(String.format("%s%s", connectionHolder.getUrl(), urlFragment)); + + if (body != null) { + request.method(method, RequestBody.create(MediaType.parse("application/json"), body)); + } else { + request.method(method, null); + } + + for (Map.Entry header : headers.entrySet()) { + request.addHeader(header.getKey(), header.getValue()); + } + + if (unidentifiedAccess.isPresent()) { + request.addHeader("Unidentified-Access-Key", Base64.encodeBytes(unidentifiedAccess.get().getUnidentifiedAccessKey())); + } else if (credentialsProvider.getPassword() != null) { + request.addHeader("Authorization", getAuthorizationHeader(credentialsProvider)); + } + + if (userAgent != null) { + request.addHeader("X-Signal-Agent", userAgent); + } + + if (connectionHolder.getHostHeader().isPresent()) { + request.addHeader("Host", connectionHolder.getHostHeader().get()); + } + + Call call = okHttpClient.newCall(request.build()); + + synchronized (connections) { + connections.add(call); + } + + try { + return call.execute(); + } finally { + synchronized (connections) { + connections.remove(call); + } + } + } catch (IOException e) { + throw new PushNetworkException(e); + } + } + + private Response makeContactDiscoveryRequest(String authorization, List cookies, String path, String method, String body) + throws PushNetworkException, NonSuccessfulResponseCodeException + { + ConnectionHolder connectionHolder = getRandom(contactDiscoveryClients, random); + OkHttpClient okHttpClient = connectionHolder.getClient() + .newBuilder() + .connectTimeout(soTimeoutMillis, TimeUnit.MILLISECONDS) + .readTimeout(soTimeoutMillis, TimeUnit.MILLISECONDS) + .build(); + + Request.Builder request = new Request.Builder().url(connectionHolder.getUrl() + path); + + if (body != null) { + request.method(method, RequestBody.create(MediaType.parse("application/json"), body)); + } else { + request.method(method, null); + } + + if (connectionHolder.getHostHeader().isPresent()) { + request.addHeader("Host", connectionHolder.getHostHeader().get()); + } + + if (authorization != null) { + request.addHeader("Authorization", authorization); + } + + if (cookies != null && !cookies.isEmpty()) { + request.addHeader("Cookie", Util.join(cookies, "; ")); + } + + Call call = okHttpClient.newCall(request.build()); + + synchronized (connections) { + connections.add(call); + } + + Response response; + + try { + response = call.execute(); + + if (response.isSuccessful()) { + return response; + } + } catch (IOException e) { + throw new PushNetworkException(e); + } finally { + synchronized (connections) { + connections.remove(call); + } + } + + switch (response.code()) { + case 401: + case 403: + throw new AuthorizationFailedException("Authorization failed!"); + case 409: + throw new RemoteAttestationResponseExpiredException("Remote attestation response expired"); + case 429: + throw new RateLimitException("Rate limit exceeded: " + response.code()); + } + + throw new NonSuccessfulResponseCodeException("Response: " + response); + } + + private ServiceConnectionHolder[] createServiceConnectionHolders(SignalUrl[] urls) { + List serviceConnectionHolders = new LinkedList<>(); + + for (SignalUrl url : urls) { + serviceConnectionHolders.add(new ServiceConnectionHolder(createConnectionClient(url), + createConnectionClient(url), + url.getUrl(), url.getHostHeader())); + } + + return serviceConnectionHolders.toArray(new ServiceConnectionHolder[0]); + } + + private ConnectionHolder[] createConnectionHolders(SignalUrl[] urls) { + List connectionHolders = new LinkedList<>(); + + for (SignalUrl url : urls) { + connectionHolders.add(new ConnectionHolder(createConnectionClient(url), url.getUrl(), url.getHostHeader())); + } + + return connectionHolders.toArray(new ConnectionHolder[0]); + } + + private OkHttpClient createConnectionClient(SignalUrl url) { + try { + TrustManager[] trustManagers = BlacklistingTrustManager.createFor(url.getTrustStore()); + + SSLContext context = SSLContext.getInstance("TLS"); + context.init(null, trustManagers, null); + + return new OkHttpClient.Builder() + .sslSocketFactory(new Tls12SocketFactory(context.getSocketFactory()), (X509TrustManager)trustManagers[0]) + .connectionSpecs(url.getConnectionSpecs().or(Util.immutableList(ConnectionSpec.RESTRICTED_TLS))) + .build(); + } catch (NoSuchAlgorithmException | KeyManagementException e) { + throw new AssertionError(e); + } + } + + private OkHttpClient createAttachmentClient() { + try { + SSLContext context = SSLContext.getInstance("TLS"); + context.init(null, null, null); + + TrustManagerFactory trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + trustManagerFactory.init((KeyStore)null); + + return new OkHttpClient.Builder() + .sslSocketFactory(new Tls12SocketFactory(context.getSocketFactory()), + (X509TrustManager)trustManagerFactory.getTrustManagers()[0]) + .connectionSpecs(Util.immutableList(ConnectionSpec.RESTRICTED_TLS)) + .build(); + } catch (NoSuchAlgorithmException | KeyManagementException | KeyStoreException e) { + throw new AssertionError(e); + } + } + + private String getAuthorizationHeader(CredentialsProvider credentialsProvider) { + try { + String identifier = credentialsProvider.getUuid() != null ? credentialsProvider.getUuid().toString() : credentialsProvider.getE164(); + return "Basic " + Base64.encodeBytes((identifier + ":" + credentialsProvider.getPassword()).getBytes("UTF-8")); + } catch (UnsupportedEncodingException e) { + throw new AssertionError(e); + } + } + + private ConnectionHolder getRandom(ConnectionHolder[] connections, SecureRandom random) { + return connections[random.nextInt(connections.length)]; + } + + private static class GcmRegistrationId { + + @JsonProperty + private String gcmRegistrationId; + + @JsonProperty + private boolean webSocketChannel; + + public GcmRegistrationId() {} + + public GcmRegistrationId(String gcmRegistrationId, boolean webSocketChannel) { + this.gcmRegistrationId = gcmRegistrationId; + this.webSocketChannel = webSocketChannel; + } + } + + private static class RegistrationLock { + @JsonProperty + private String pin; + + public RegistrationLock() {} + + public RegistrationLock(String pin) { + this.pin = pin; + } + } + + private static class RegistrationLockFailure { + @JsonProperty + private int length; + + @JsonProperty + private long timeRemaining; + } + + private static class AttachmentDescriptor { + @JsonProperty + private long id; + + @JsonProperty + private String location; + + public long getId() { + return id; + } + + public String getLocation() { + return location; + } + } + + + private static class ConnectionHolder { + + private final OkHttpClient client; + private final String url; + private final Optional hostHeader; + + private ConnectionHolder(OkHttpClient client, String url, Optional hostHeader) { + this.client = client; + this.url = url; + this.hostHeader = hostHeader; + } + + OkHttpClient getClient() { + return client; + } + + public String getUrl() { + return url; + } + + Optional getHostHeader() { + return hostHeader; + } + } + + private static class ServiceConnectionHolder extends ConnectionHolder { + + private final OkHttpClient unidentifiedClient; + + private ServiceConnectionHolder(OkHttpClient identifiedClient, OkHttpClient unidentifiedClient, String url, Optional hostHeader) { + super(identifiedClient, url, hostHeader); + this.unidentifiedClient = unidentifiedClient; + } + + OkHttpClient getUnidentifiedClient() { + return unidentifiedClient; + } + } + + private interface ResponseCodeHandler { + void handle(int responseCode) throws NonSuccessfulResponseCodeException, PushNetworkException; + } + + private static class EmptyResponseCodeHandler implements ResponseCodeHandler { + @Override + public void handle(int responseCode) { } + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PushTransportDetails.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PushTransportDetails.java new file mode 100644 index 000000000..13cff09cd --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PushTransportDetails.java @@ -0,0 +1,68 @@ +/** + * Copyright (C) 2014-2016 Open Whisper Systems + * + * Licensed according to the LICENSE file in this repository. + */ + +package org.whispersystems.signalservice.internal.push; + + +import org.whispersystems.libsignal.logging.Log; + +public class PushTransportDetails { + + private static final String TAG = PushTransportDetails.class.getSimpleName(); + + private final int messageVersion; + + public PushTransportDetails(int messageVersion) { + this.messageVersion = messageVersion; + } + + public byte[] getStrippedPaddingMessageBody(byte[] messageWithPadding) { + if (messageVersion < 2) throw new AssertionError("Unknown version: " + messageVersion); + else if (messageVersion == 2) return messageWithPadding; + + int paddingStart = 0; + + for (int i=messageWithPadding.length-1;i>=0;i--) { + if (messageWithPadding[i] == (byte)0x80) { + paddingStart = i; + break; + } else if (messageWithPadding[i] != (byte)0x00) { + Log.w(TAG, "Padding byte is malformed, returning unstripped padding."); + return messageWithPadding; + } + } + + byte[] strippedMessage = new byte[paddingStart]; + System.arraycopy(messageWithPadding, 0, strippedMessage, 0, strippedMessage.length); + + return strippedMessage; + } + + public byte[] getPaddedMessageBody(byte[] messageBody) { + if (messageVersion < 2) throw new AssertionError("Unknown version: " + messageVersion); + else if (messageVersion == 2) return messageBody; + + // NOTE: This is dumb. We have our own padding scheme, but so does the cipher. + // The +1 -1 here is to make sure the Cipher has room to add one padding byte, + // otherwise it'll add a full 16 extra bytes. + byte[] paddedMessage = new byte[getPaddedMessageLength(messageBody.length + 1) - 1]; + System.arraycopy(messageBody, 0, paddedMessage, 0, messageBody.length); + paddedMessage[messageBody.length] = (byte)0x80; + + return paddedMessage; + } + + private int getPaddedMessageLength(int messageLength) { + int messageLengthWithTerminator = messageLength + 1; + int messagePartCount = messageLengthWithTerminator / 160; + + if (messageLengthWithTerminator % 160 != 0) { + messagePartCount++; + } + + return messagePartCount * 160; + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/SendMessageResponse.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/SendMessageResponse.java new file mode 100644 index 000000000..ac129f271 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/SendMessageResponse.java @@ -0,0 +1,16 @@ +package org.whispersystems.signalservice.internal.push; + +public class SendMessageResponse { + + private boolean needsSync; + + public SendMessageResponse() {} + + public SendMessageResponse(boolean needsSync) { + this.needsSync = needsSync; + } + + public boolean getNeedsSync() { + return needsSync; + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/SenderCertificate.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/SenderCertificate.java new file mode 100644 index 000000000..fbf7dde9b --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/SenderCertificate.java @@ -0,0 +1,45 @@ +package org.whispersystems.signalservice.internal.push; + + +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.JsonSerializer; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; + +import org.whispersystems.util.Base64; + +import java.io.IOException; + +public class SenderCertificate { + + @JsonProperty + @JsonDeserialize(using = ByteArrayDesieralizer.class) + @JsonSerialize(using = ByteArraySerializer.class) + private byte[] certificate; + + public SenderCertificate() {} + + public byte[] getCertificate() { + return certificate; + } + + public static class ByteArraySerializer extends JsonSerializer { + @Override + public void serialize(byte[] value, JsonGenerator gen, SerializerProvider serializers) throws IOException { + gen.writeString(Base64.encodeBytes(value)); + } + } + + public static class ByteArrayDesieralizer extends JsonDeserializer { + + @Override + public byte[] deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + return Base64.decode(p.getValueAsString()); + } + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/SignalServiceEnvelopeEntity.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/SignalServiceEnvelopeEntity.java new file mode 100644 index 000000000..bfe4b0a5f --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/SignalServiceEnvelopeEntity.java @@ -0,0 +1,82 @@ +package org.whispersystems.signalservice.internal.push; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public class SignalServiceEnvelopeEntity { + + @JsonProperty + private int type; + + @JsonProperty + private String relay; + + @JsonProperty + private long timestamp; + + @JsonProperty + private String source; + + @JsonProperty + private String sourceUuid; + + @JsonProperty + private int sourceDevice; + + @JsonProperty + private byte[] message; + + @JsonProperty + private byte[] content; + + @JsonProperty + private long serverTimestamp; + + @JsonProperty + private String guid; + + public SignalServiceEnvelopeEntity() {} + + public int getType() { + return type; + } + + public String getRelay() { + return relay; + } + + public long getTimestamp() { + return timestamp; + } + + public String getSourceE164() { + return source; + } + + public String getSourceUuid() { + return sourceUuid; + } + + public boolean hasSource() { + return source != null || sourceUuid != null; + } + + public int getSourceDevice() { + return sourceDevice; + } + + public byte[] getMessage() { + return message; + } + + public byte[] getContent() { + return content; + } + + public long getServerTimestamp() { + return serverTimestamp; + } + + public String getServerUuid() { + return guid; + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/SignalServiceEnvelopeEntityList.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/SignalServiceEnvelopeEntityList.java new file mode 100644 index 000000000..276591ced --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/SignalServiceEnvelopeEntityList.java @@ -0,0 +1,14 @@ +package org.whispersystems.signalservice.internal.push; + +import java.util.List; + +public class SignalServiceEnvelopeEntityList { + + private List messages; + + public SignalServiceEnvelopeEntityList() {} + + public List getMessages() { + return messages; + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/StaleDevices.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/StaleDevices.java new file mode 100644 index 000000000..a602e4c49 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/StaleDevices.java @@ -0,0 +1,21 @@ +/** + * Copyright (C) 2014-2016 Open Whisper Systems + * + * Licensed according to the LICENSE file in this repository. + */ + +package org.whispersystems.signalservice.internal.push; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; + +public class StaleDevices { + + @JsonProperty + private List staleDevices; + + public List getStaleDevices() { + return staleDevices; + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/UnsupportedDataMessageException.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/UnsupportedDataMessageException.java new file mode 100644 index 000000000..3f89c8e6c --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/UnsupportedDataMessageException.java @@ -0,0 +1,46 @@ +package org.whispersystems.signalservice.internal.push; + +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage; +import org.whispersystems.signalservice.api.messages.SignalServiceGroup; + +/** + * Exception that indicates that the data message has a higher required protocol version than the + * current client is capable of interpreting. + */ +public class UnsupportedDataMessageException extends Exception { + + private final int requiredVersion; + private final String sender; + private final int senderDevice; + private final Optional group; + + public UnsupportedDataMessageException(int currentVersion, + int requiredVersion, + String sender, + int senderDevice, + Optional group) + { + super("Required version: " + requiredVersion + ", Our version: " + currentVersion); + this.requiredVersion = requiredVersion; + this.sender = sender; + this.senderDevice = senderDevice; + this.group = group; + } + + public int getRequiredVersion() { + return requiredVersion; + } + + public String getSender() { + return sender; + } + + public int getSenderDevice() { + return senderDevice; + } + + public Optional getGroup() { + return group; + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/VerifyAccountResponse.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/VerifyAccountResponse.java new file mode 100644 index 000000000..b5cdde4b5 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/VerifyAccountResponse.java @@ -0,0 +1,12 @@ +package org.whispersystems.signalservice.internal.push; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public class VerifyAccountResponse { + @JsonProperty + private String uuid; + + public String getUuid() { + return uuid; + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/WhoAmIResponse.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/WhoAmIResponse.java new file mode 100644 index 000000000..6d50117f7 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/WhoAmIResponse.java @@ -0,0 +1,12 @@ +package org.whispersystems.signalservice.internal.push; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public class WhoAmIResponse { + @JsonProperty + private String uuid; + + public String getUuid() { + return uuid; + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/exceptions/MismatchedDevicesException.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/exceptions/MismatchedDevicesException.java new file mode 100644 index 000000000..26838d642 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/exceptions/MismatchedDevicesException.java @@ -0,0 +1,23 @@ +/** + * Copyright (C) 2014-2016 Open Whisper Systems + * + * Licensed according to the LICENSE file in this repository. + */ + +package org.whispersystems.signalservice.internal.push.exceptions; + +import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException; +import org.whispersystems.signalservice.internal.push.MismatchedDevices; + +public class MismatchedDevicesException extends NonSuccessfulResponseCodeException { + + private final MismatchedDevices mismatchedDevices; + + public MismatchedDevicesException(MismatchedDevices mismatchedDevices) { + this.mismatchedDevices = mismatchedDevices; + } + + public MismatchedDevices getMismatchedDevices() { + return mismatchedDevices; + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/exceptions/StaleDevicesException.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/exceptions/StaleDevicesException.java new file mode 100644 index 000000000..64a1f3207 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/exceptions/StaleDevicesException.java @@ -0,0 +1,23 @@ +/** + * Copyright (C) 2014-2016 Open Whisper Systems + * + * Licensed according to the LICENSE file in this repository. + */ + +package org.whispersystems.signalservice.internal.push.exceptions; + +import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException; +import org.whispersystems.signalservice.internal.push.StaleDevices; + +public class StaleDevicesException extends NonSuccessfulResponseCodeException { + + private final StaleDevices staleDevices; + + public StaleDevicesException(StaleDevices staleDevices) { + this.staleDevices = staleDevices; + } + + public StaleDevices getStaleDevices() { + return staleDevices; + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/http/AttachmentCipherOutputStreamFactory.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/http/AttachmentCipherOutputStreamFactory.java new file mode 100644 index 000000000..b807cc387 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/http/AttachmentCipherOutputStreamFactory.java @@ -0,0 +1,23 @@ +package org.whispersystems.signalservice.internal.push.http; + + +import org.whispersystems.signalservice.api.crypto.AttachmentCipherOutputStream; +import org.whispersystems.signalservice.api.crypto.DigestingOutputStream; + +import java.io.IOException; +import java.io.OutputStream; + +public class AttachmentCipherOutputStreamFactory implements OutputStreamFactory { + + private final byte[] key; + + public AttachmentCipherOutputStreamFactory(byte[] key) { + this.key = key; + } + + @Override + public DigestingOutputStream createFor(OutputStream wrap) throws IOException { + return new AttachmentCipherOutputStream(key, wrap); + } + +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/http/DigestingRequestBody.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/http/DigestingRequestBody.java new file mode 100644 index 000000000..7a9b107a9 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/http/DigestingRequestBody.java @@ -0,0 +1,71 @@ +package org.whispersystems.signalservice.internal.push.http; + + +import org.whispersystems.signalservice.api.crypto.DigestingOutputStream; +import org.whispersystems.signalservice.api.messages.SignalServiceAttachment.ProgressListener; + +import java.io.IOException; +import java.io.InputStream; + +import okhttp3.MediaType; +import okhttp3.RequestBody; +import okio.BufferedSink; + +public class DigestingRequestBody extends RequestBody { + + private final InputStream inputStream; + private final OutputStreamFactory outputStreamFactory; + private final String contentType; + private final long contentLength; + private final ProgressListener progressListener; + + private byte[] digest; + + public DigestingRequestBody(InputStream inputStream, + OutputStreamFactory outputStreamFactory, + String contentType, long contentLength, + ProgressListener progressListener) + { + this.inputStream = inputStream; + this.outputStreamFactory = outputStreamFactory; + this.contentType = contentType; + this.contentLength = contentLength; + this.progressListener = progressListener; + } + + @Override + public MediaType contentType() { + return MediaType.parse(contentType); + } + + @Override + public void writeTo(BufferedSink sink) throws IOException { + DigestingOutputStream outputStream = outputStreamFactory.createFor(sink.outputStream()); + byte[] buffer = new byte[8192]; + + int read; + long total = 0; + + while ((read = inputStream.read(buffer, 0, buffer.length)) != -1) { + outputStream.write(buffer, 0, read); + total += read; + + if (progressListener != null) { + progressListener.onAttachmentProgress(contentLength, total); + } + } + + outputStream.flush(); + digest = outputStream.getTransmittedDigest(); + } + + @Override + public long contentLength() { + if (contentLength > 0) return contentLength; + else return -1; + } + + public byte[] getTransmittedDigest() { + return digest; + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/http/OutputStreamFactory.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/http/OutputStreamFactory.java new file mode 100644 index 000000000..02ea7bd5c --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/http/OutputStreamFactory.java @@ -0,0 +1,13 @@ +package org.whispersystems.signalservice.internal.push.http; + + +import org.whispersystems.signalservice.api.crypto.DigestingOutputStream; + +import java.io.IOException; +import java.io.OutputStream; + +public interface OutputStreamFactory { + + public DigestingOutputStream createFor(OutputStream wrap) throws IOException; + +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/http/ProfileCipherOutputStreamFactory.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/http/ProfileCipherOutputStreamFactory.java new file mode 100644 index 000000000..bfc79e12d --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/http/ProfileCipherOutputStreamFactory.java @@ -0,0 +1,23 @@ +package org.whispersystems.signalservice.internal.push.http; + + +import org.whispersystems.signalservice.api.crypto.DigestingOutputStream; +import org.whispersystems.signalservice.api.crypto.ProfileCipherOutputStream; + +import java.io.IOException; +import java.io.OutputStream; + +public class ProfileCipherOutputStreamFactory implements OutputStreamFactory { + + private final byte[] key; + + public ProfileCipherOutputStreamFactory(byte[] key) { + this.key = key; + } + + @Override + public DigestingOutputStream createFor(OutputStream wrap) throws IOException { + return new ProfileCipherOutputStream(wrap, key); + } + +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/util/BlacklistingTrustManager.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/util/BlacklistingTrustManager.java new file mode 100644 index 000000000..d8941d56f --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/util/BlacklistingTrustManager.java @@ -0,0 +1,104 @@ +/** + * Copyright (C) 2014-2016 Open Whisper Systems + * + * Licensed according to the LICENSE file in this repository. + */ + +package org.whispersystems.signalservice.internal.util; + +import org.whispersystems.libsignal.util.Pair; +import org.whispersystems.signalservice.api.push.TrustStore; + +import java.io.IOException; +import java.io.InputStream; +import java.math.BigInteger; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; +import java.util.LinkedList; +import java.util.List; + +import javax.net.ssl.TrustManager; +import javax.net.ssl.TrustManagerFactory; +import javax.net.ssl.X509TrustManager; + +/** + * Trust manager that defers to a system X509 trust manager, and + * additionally rejects certificates if they have a blacklisted + * serial. + * + * @author Moxie Marlinspike + */ +public class BlacklistingTrustManager implements X509TrustManager { + + private static final List> BLACKLIST = new LinkedList>() {{ + add(new Pair<>("Open Whisper Systems", new BigInteger("4098"))); + }}; + + public static TrustManager[] createFor(TrustManager[] trustManagers) { + for (TrustManager trustManager : trustManagers) { + if (trustManager instanceof X509TrustManager) { + TrustManager[] results = new BlacklistingTrustManager[1]; + results[0] = new BlacklistingTrustManager((X509TrustManager)trustManager); + + return results; + } + } + + throw new AssertionError("No X509 Trust Managers!"); + } + + public static TrustManager[] createFor(TrustStore trustStore) { + try { + InputStream keyStoreInputStream = trustStore.getKeyStoreInputStream(); + KeyStore keyStore = KeyStore.getInstance("BKS"); + + keyStore.load(keyStoreInputStream, trustStore.getKeyStorePassword().toCharArray()); + + TrustManagerFactory trustManagerFactory = TrustManagerFactory.getInstance("X509"); + trustManagerFactory.init(keyStore); + + return BlacklistingTrustManager.createFor(trustManagerFactory.getTrustManagers()); + } catch (KeyStoreException | CertificateException | IOException | NoSuchAlgorithmException e) { + throw new AssertionError(e); + } + } + + private final X509TrustManager trustManager; + + public BlacklistingTrustManager(X509TrustManager trustManager) { + this.trustManager = trustManager; + } + + @Override + public void checkClientTrusted(X509Certificate[] chain, String authType) + throws CertificateException + { + trustManager.checkClientTrusted(chain, authType); + } + + @Override + public void checkServerTrusted(X509Certificate[] chain, String authType) + throws CertificateException + { + trustManager.checkServerTrusted(chain, authType); + + for (X509Certificate certificate : chain) { + for (Pair blacklistedSerial : BLACKLIST) { + if (certificate.getIssuerDN().getName().equals(blacklistedSerial.first()) && + certificate.getSerialNumber().equals(blacklistedSerial.second())) + { + throw new CertificateException("Blacklisted Serial: " + certificate.getSerialNumber()); + } + } + } + + } + + @Override + public X509Certificate[] getAcceptedIssuers() { + return trustManager.getAcceptedIssuers(); + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/util/ContentLengthInputStream.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/util/ContentLengthInputStream.java new file mode 100644 index 000000000..f1a428c27 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/util/ContentLengthInputStream.java @@ -0,0 +1,41 @@ +package org.whispersystems.signalservice.internal.util; + + +import java.io.FilterInputStream; +import java.io.IOException; +import java.io.InputStream; + +public class ContentLengthInputStream extends FilterInputStream { + + private long bytesRemaining; + + public ContentLengthInputStream(InputStream inputStream, long contentLength) { + super(inputStream); + this.bytesRemaining = contentLength; + } + + @Override + public int read() throws IOException { + if (bytesRemaining == 0) return -1; + int result = super.read(); + bytesRemaining--; + + return result; + } + + @Override + public int read(byte[] buffer) throws IOException { + return read(buffer, 0, buffer.length); + } + + @Override + public int read(byte[] buffer, int offset, int length) throws IOException { + if (bytesRemaining == 0) return -1; + + int result = super.read(buffer, offset, Math.min(length, Util.toIntExact(bytesRemaining))); + + bytesRemaining -= result; + return result; + } + +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/util/Hex.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/util/Hex.java new file mode 100644 index 000000000..8f0acdc7c --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/util/Hex.java @@ -0,0 +1,128 @@ +/** + * Copyright (C) 2014-2016 Open Whisper Systems + * + * Licensed according to the LICENSE file in this repository. + */ + +package org.whispersystems.signalservice.internal.util; + +import java.io.IOException; + +/** + * Utility for generating hex dumps. + */ +public class Hex { + + private final static int HEX_DIGITS_START = 10; + private final static int ASCII_TEXT_START = HEX_DIGITS_START + (16*2 + (16/2)); + + final static String EOL = System.getProperty("line.separator"); + + private final static char[] HEX_DIGITS = { + '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f' + }; + + public static String toString(byte[] bytes) { + return toString(bytes, 0, bytes.length); + } + + public static String toString(byte[] bytes, int offset, int length) { + StringBuffer buf = new StringBuffer(); + for (int i = 0; i < length; i++) { + appendHexChar(buf, bytes[offset + i]); + buf.append(' '); + } + return buf.toString(); + } + + public static String toStringCondensed(byte[] bytes) { + StringBuffer buf = new StringBuffer(); + for (int i=0;i> 1]; + + // two characters form the hex value. + for (int i = 0, j = 0; j < len; i++) { + int f = Character.digit(data[j], 16) << 4; + j++; + f = f | Character.digit(data[j], 16); + j++; + out[i] = (byte) (f & 0xFF); + } + + return out; + } + + public static String dump(byte[] bytes) { + return dump(bytes, 0, bytes.length); + } + + public static String dump(byte[] bytes, int offset, int length) { + StringBuffer buf = new StringBuffer(); + int lines = ((length - 1) / 16) + 1; + int lineOffset; + int lineLength; + + for (int i = 0; i < lines; i++) { + lineOffset = (i * 16) + offset; + lineLength = Math.min(16, (length - (i * 16))); + appendDumpLine(buf, i, bytes, lineOffset, lineLength); + buf.append(EOL); + } + + return buf.toString(); + } + + private static void appendDumpLine(StringBuffer buf, int line, byte[] bytes, int lineOffset, int lineLength) { + buf.append(HEX_DIGITS[(line >> 28) & 0xf]); + buf.append(HEX_DIGITS[(line >> 24) & 0xf]); + buf.append(HEX_DIGITS[(line >> 20) & 0xf]); + buf.append(HEX_DIGITS[(line >> 16) & 0xf]); + buf.append(HEX_DIGITS[(line >> 12) & 0xf]); + buf.append(HEX_DIGITS[(line >> 8) & 0xf]); + buf.append(HEX_DIGITS[(line >> 4) & 0xf]); + buf.append(HEX_DIGITS[(line ) & 0xf]); + buf.append(": "); + + for (int i = 0; i < 16; i++) { + int idx = i + lineOffset; + if (i < lineLength) { + int b = bytes[idx]; + appendHexChar(buf, b); + } else { + buf.append(" "); + } + if ((i % 2) == 1) { + buf.append(' '); + } + } + + for (int i = 0; i < 16 && i < lineLength; i++) { + int idx = i + lineOffset; + int b = bytes[idx]; + if (b >= 0x20 && b <= 0x7e) { + buf.append((char)b); + } else { + buf.append('.'); + } + } + } + + private static void appendHexChar(StringBuffer buf, int b) { + buf.append(HEX_DIGITS[(b >> 4) & 0xf]); + buf.append(HEX_DIGITS[b & 0xf]); + } + +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/util/JsonUtil.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/util/JsonUtil.java new file mode 100644 index 000000000..0001a0cb4 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/util/JsonUtil.java @@ -0,0 +1,73 @@ +/** + * Copyright (C) 2014-2016 Open Whisper Systems + * + * Licensed according to the LICENSE file in this repository. + */ + +package org.whispersystems.signalservice.internal.util; + + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializerProvider; + +import org.whispersystems.libsignal.IdentityKey; +import org.whispersystems.libsignal.InvalidKeyException; +import org.whispersystems.libsignal.logging.Log; +import org.whispersystems.util.Base64; + +import java.io.IOException; + +public class JsonUtil { + + private static final String TAG = JsonUtil.class.getSimpleName(); + + private static final ObjectMapper objectMapper = new ObjectMapper(); + + static { + objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + } + + public static String toJson(Object object) { + try { + return objectMapper.writeValueAsString(object); + } catch (JsonProcessingException e) { + Log.w(TAG, e); + return ""; + } + } + + public static T fromJson(String json, Class clazz) + throws IOException + { + return objectMapper.readValue(json, clazz); + } + + public static class IdentityKeySerializer extends JsonSerializer { + @Override + public void serialize(IdentityKey value, JsonGenerator gen, SerializerProvider serializers) + throws IOException + { + gen.writeString(Base64.encodeBytesWithoutPadding(value.serialize())); + } + } + + public static class IdentityKeyDeserializer extends JsonDeserializer { + @Override + public IdentityKey deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + try { + return new IdentityKey(Base64.decodeWithoutPadding(p.getValueAsString()), 0); + } catch (InvalidKeyException e) { + throw new IOException(e); + } + } + } + + +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/util/StaticCredentialsProvider.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/util/StaticCredentialsProvider.java new file mode 100644 index 000000000..4459e7d90 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/util/StaticCredentialsProvider.java @@ -0,0 +1,46 @@ +/** + * Copyright (C) 2014-2016 Open Whisper Systems + * + * Licensed according to the LICENSE file in this repository. + */ + +package org.whispersystems.signalservice.internal.util; + +import org.whispersystems.signalservice.api.util.CredentialsProvider; + +import java.util.UUID; + +public class StaticCredentialsProvider implements CredentialsProvider { + + private final UUID uuid; + private final String e164; + private final String password; + private final String signalingKey; + + public StaticCredentialsProvider(UUID uuid, String e164, String password, String signalingKey) { + this.uuid = uuid; + this.e164 = e164; + this.password = password; + this.signalingKey = signalingKey; + } + + @Override + public UUID getUuid() { + return uuid; + } + + @Override + public String getE164() { + return e164; + } + + @Override + public String getPassword() { + return password; + } + + @Override + public String getSignalingKey() { + return signalingKey; + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/util/Util.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/util/Util.java new file mode 100644 index 000000000..3de6a2442 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/util/Util.java @@ -0,0 +1,154 @@ +/** + * Copyright (C) 2014-2016 Open Whisper Systems + * + * Licensed according to the LICENSE file in this repository. + */ + +package org.whispersystems.signalservice.internal.util; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.List; + +public class Util { + + public static byte[] join(byte[]... input) { + try { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + for (byte[] part : input) { + baos.write(part); + } + + return baos.toByteArray(); + } catch (IOException e) { + throw new AssertionError(e); + } + } + + public static String join(Collection list, String delimiter) { + StringBuilder result = new StringBuilder(); + int i = 0; + + for (String item : list) { + result.append(item); + + if (++i < list.size()) + result.append(delimiter); + } + + return result.toString(); + } + + + public static byte[][] split(byte[] input, int firstLength, int secondLength) { + byte[][] parts = new byte[2][]; + + parts[0] = new byte[firstLength]; + System.arraycopy(input, 0, parts[0], 0, firstLength); + + parts[1] = new byte[secondLength]; + System.arraycopy(input, firstLength, parts[1], 0, secondLength); + + return parts; + } + + public static byte[] trim(byte[] input, int length) { + byte[] result = new byte[length]; + System.arraycopy(input, 0, result, 0, result.length); + + return result; + } + + public static boolean isEmpty(String value) { + return value == null || value.trim().length() == 0; + } + + public static byte[] getSecretBytes(int size) { + try { + byte[] secret = new byte[size]; + SecureRandom.getInstance("SHA1PRNG").nextBytes(secret); + return secret; + } catch (NoSuchAlgorithmException e) { + throw new AssertionError(e); + } + } + + public static byte[] getRandomLengthBytes(int maxSize) { + SecureRandom secureRandom = new SecureRandom(); + byte[] result = new byte[secureRandom.nextInt(maxSize) + 1]; + secureRandom.nextBytes(result); + return result; + } + + public static String readFully(InputStream in) throws IOException { + ByteArrayOutputStream bout = new ByteArrayOutputStream(); + byte[] buffer = new byte[4096]; + int read; + + while ((read = in.read(buffer)) != -1) { + bout.write(buffer, 0, read); + } + + in.close(); + + return new String(bout.toByteArray()); + } + + public static void readFully(InputStream in, byte[] buffer) throws IOException { + int offset = 0; + + for (;;) { + int read = in.read(buffer, offset, buffer.length - offset); + + if (read + offset < buffer.length) offset += read; + else return; + } + } + + public static void copy(InputStream in, OutputStream out) throws IOException { + byte[] buffer = new byte[4096]; + int read; + + while ((read = in.read(buffer)) != -1) { + out.write(buffer, 0, read); + } + + in.close(); + out.close(); + } + + public static void sleep(long millis) { + try { + Thread.sleep(millis); + } catch (InterruptedException e) { + throw new AssertionError(e); + } + } + + public static void wait(Object lock, long millis) { + try { + lock.wait(millis); + } catch (InterruptedException e) { + throw new AssertionError(e); + } + } + + public static int toIntExact(long value) { + if ((int)value != value) { + throw new ArithmeticException("integer overflow"); + } + return (int)value; + } + + public static List immutableList(T... elements) { + return Collections.unmodifiableList(Arrays.asList(elements.clone())); + } + +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/util/concurrent/ListenableFuture.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/util/concurrent/ListenableFuture.java new file mode 100644 index 000000000..45d5ae351 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/util/concurrent/ListenableFuture.java @@ -0,0 +1,13 @@ +package org.whispersystems.signalservice.internal.util.concurrent; + +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; + +public interface ListenableFuture extends Future { + void addListener(Listener listener); + + public interface Listener { + public void onSuccess(T result); + public void onFailure(ExecutionException e); + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/util/concurrent/SettableFuture.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/util/concurrent/SettableFuture.java new file mode 100644 index 000000000..7228d3dc0 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/util/concurrent/SettableFuture.java @@ -0,0 +1,115 @@ +package org.whispersystems.signalservice.internal.util.concurrent; + +import java.util.LinkedList; +import java.util.List; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +public class SettableFuture implements ListenableFuture { + + private final List> listeners = new LinkedList<>(); + + private boolean completed; + private boolean canceled; + private volatile T result; + private volatile Throwable exception; + + @Override + public synchronized boolean cancel(boolean mayInterruptIfRunning) { + if (!completed && !canceled) { + canceled = true; + return true; + } + + return false; + } + + @Override + public synchronized boolean isCancelled() { + return canceled; + } + + @Override + public synchronized boolean isDone() { + return completed; + } + + public boolean set(T result) { + synchronized (this) { + if (completed || canceled) return false; + + this.result = result; + this.completed = true; + + notifyAll(); + } + + notifyAllListeners(); + return true; + } + + public boolean setException(Throwable throwable) { + synchronized (this) { + if (completed || canceled) return false; + + this.exception = throwable; + this.completed = true; + + notifyAll(); + } + + notifyAllListeners(); + return true; + } + + @Override + public synchronized T get() throws InterruptedException, ExecutionException { + while (!completed) wait(); + + if (exception != null) throw new ExecutionException(exception); + else return result; + } + + @Override + public synchronized T get(long timeout, TimeUnit unit) + throws InterruptedException, ExecutionException, TimeoutException + { + long startTime = System.currentTimeMillis(); + + while (!completed && System.currentTimeMillis() - startTime < unit.toMillis(timeout)) { + wait(unit.toMillis(timeout)); + } + + if (!completed) throw new TimeoutException(); + else return get(); + } + + @Override + public void addListener(Listener listener) { + synchronized (this) { + listeners.add(listener); + + if (!completed) return; + } + + notifyListener(listener); + } + + private void notifyAllListeners() { + List> localListeners; + + synchronized (this) { + localListeners = new LinkedList<>(listeners); + } + + for (Listener listener : localListeners) { + notifyListener(listener); + } + } + + private void notifyListener(Listener listener) { + if (exception != null) listener.onFailure(new ExecutionException(exception)); + else listener.onSuccess(result); + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/websocket/WebSocketConnection.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/websocket/WebSocketConnection.java new file mode 100644 index 000000000..d3fa91301 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/websocket/WebSocketConnection.java @@ -0,0 +1,339 @@ +package org.whispersystems.signalservice.internal.websocket; + +import com.google.protobuf.InvalidProtocolBufferException; + +import org.whispersystems.libsignal.logging.Log; +import org.whispersystems.libsignal.util.Pair; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.push.TrustStore; +import org.whispersystems.signalservice.api.util.CredentialsProvider; +import org.whispersystems.signalservice.api.util.SleepTimer; +import org.whispersystems.signalservice.api.util.Tls12SocketFactory; +import org.whispersystems.signalservice.api.websocket.ConnectivityListener; +import org.whispersystems.signalservice.internal.util.BlacklistingTrustManager; +import org.whispersystems.signalservice.internal.util.Util; +import org.whispersystems.signalservice.internal.util.concurrent.SettableFuture; + +import java.io.IOException; +import java.security.KeyManagementException; +import java.security.NoSuchAlgorithmException; +import java.util.HashMap; +import java.util.Iterator; +import java.util.LinkedList; +import java.util.Map; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; + +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLSocketFactory; +import javax.net.ssl.TrustManager; +import javax.net.ssl.X509TrustManager; + +import okhttp3.ConnectionSpec; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; +import okhttp3.WebSocket; +import okhttp3.WebSocketListener; +import okio.ByteString; + +import static org.whispersystems.signalservice.internal.websocket.WebSocketProtos.WebSocketMessage; +import static org.whispersystems.signalservice.internal.websocket.WebSocketProtos.WebSocketRequestMessage; +import static org.whispersystems.signalservice.internal.websocket.WebSocketProtos.WebSocketResponseMessage; + +public class WebSocketConnection extends WebSocketListener { + + private static final String TAG = WebSocketConnection.class.getSimpleName(); + private static final int KEEPALIVE_TIMEOUT_SECONDS = 55; + + private final LinkedList incomingRequests = new LinkedList<>(); + private final Map>> outgoingRequests = new HashMap<>(); + + private final String wsUri; + private final TrustStore trustStore; + private final Optional credentialsProvider; + private final String userAgent; + private final ConnectivityListener listener; + private final SleepTimer sleepTimer; + + private WebSocket client; + private KeepAliveSender keepAliveSender; + private int attempts; + private boolean connected; + + public WebSocketConnection(String httpUri, + TrustStore trustStore, + Optional credentialsProvider, + String userAgent, + ConnectivityListener listener, + SleepTimer timer) + { + this.trustStore = trustStore; + this.credentialsProvider = credentialsProvider; + this.userAgent = userAgent; + this.listener = listener; + this.sleepTimer = timer; + this.attempts = 0; + this.connected = false; + + String uri = httpUri.replace("https://", "wss://").replace("http://", "ws://"); + + if (credentialsProvider.isPresent()) this.wsUri = uri + "/v1/websocket/?login=%s&password=%s"; + else this.wsUri = uri + "/v1/websocket/"; + } + + public synchronized void connect() { + Log.w(TAG, "WSC connect()..."); + + if (client == null) { + String filledUri; + + if (credentialsProvider.isPresent()) { + String identifier = credentialsProvider.get().getUuid() != null ? credentialsProvider.get().getUuid().toString() : credentialsProvider.get().getE164(); + filledUri = String.format(wsUri, identifier, credentialsProvider.get().getPassword()); + } else { + filledUri = wsUri; + } + + Pair socketFactory = createTlsSocketFactory(trustStore); + + OkHttpClient okHttpClient = new OkHttpClient.Builder() + .sslSocketFactory(new Tls12SocketFactory(socketFactory.first()), socketFactory.second()) + .connectionSpecs(Util.immutableList(ConnectionSpec.RESTRICTED_TLS)) + .readTimeout(KEEPALIVE_TIMEOUT_SECONDS + 10, TimeUnit.SECONDS) + .connectTimeout(KEEPALIVE_TIMEOUT_SECONDS + 10, TimeUnit.SECONDS) + .build(); + + Request.Builder requestBuilder = new Request.Builder().url(filledUri); + + if (userAgent != null) { + requestBuilder.addHeader("X-Signal-Agent", userAgent); + } + + if (listener != null) { + listener.onConnecting(); + } + + this.connected = false; + this.client = okHttpClient.newWebSocket(requestBuilder.build(), this); + } + } + + public synchronized void disconnect() { + Log.w(TAG, "WSC disconnect()..."); + + if (client != null) { + client.close(1000, "OK"); + client = null; + connected = false; + } + + if (keepAliveSender != null) { + keepAliveSender.shutdown(); + keepAliveSender = null; + } + } + + public synchronized WebSocketRequestMessage readRequest(long timeoutMillis) + throws TimeoutException, IOException + { + if (client == null) { + throw new IOException("Connection closed!"); + } + + long startTime = System.currentTimeMillis(); + + while (client != null && incomingRequests.isEmpty() && elapsedTime(startTime) < timeoutMillis) { + Util.wait(this, Math.max(1, timeoutMillis - elapsedTime(startTime))); + } + + if (incomingRequests.isEmpty() && client == null) throw new IOException("Connection closed!"); + else if (incomingRequests.isEmpty()) throw new TimeoutException("Timeout exceeded"); + else return incomingRequests.removeFirst(); + } + + public synchronized Future> sendRequest(WebSocketRequestMessage request) throws IOException { + if (client == null || !connected) throw new IOException("No connection!"); + + WebSocketMessage message = WebSocketMessage.newBuilder() + .setType(WebSocketMessage.Type.REQUEST) + .setRequest(request) + .build(); + + SettableFuture> future = new SettableFuture<>(); + outgoingRequests.put(request.getId(), future); + + if (!client.send(ByteString.of(message.toByteArray()))) { + throw new IOException("Write failed!"); + } + + return future; + } + + public synchronized void sendResponse(WebSocketResponseMessage response) throws IOException { + if (client == null) { + throw new IOException("Connection closed!"); + } + + WebSocketMessage message = WebSocketMessage.newBuilder() + .setType(WebSocketMessage.Type.RESPONSE) + .setResponse(response) + .build(); + + if (!client.send(ByteString.of(message.toByteArray()))) { + throw new IOException("Write failed!"); + } + } + + private synchronized void sendKeepAlive() throws IOException { + if (keepAliveSender != null && client != null) { + byte[] message = WebSocketMessage.newBuilder() + .setType(WebSocketMessage.Type.REQUEST) + .setRequest(WebSocketRequestMessage.newBuilder() + .setId(System.currentTimeMillis()) + .setPath("/v1/keepalive") + .setVerb("GET") + .build()).build() + .toByteArray(); + + if (!client.send(ByteString.of(message))) { + throw new IOException("Write failed!"); + } + } + } + + @Override + public synchronized void onOpen(WebSocket webSocket, Response response) { + if (client != null && keepAliveSender == null) { + Log.w(TAG, "onConnected()"); + attempts = 0; + connected = true; + keepAliveSender = new KeepAliveSender(); + keepAliveSender.start(); + + if (listener != null) listener.onConnected(); + } + } + + @Override + public synchronized void onMessage(WebSocket webSocket, ByteString payload) { + Log.w(TAG, "WSC onMessage()"); + try { + WebSocketMessage message = WebSocketMessage.parseFrom(payload.toByteArray()); + + Log.w(TAG, "Message Type: " + message.getType().getNumber()); + + if (message.getType().getNumber() == WebSocketMessage.Type.REQUEST_VALUE) { + incomingRequests.add(message.getRequest()); + } else if (message.getType().getNumber() == WebSocketMessage.Type.RESPONSE_VALUE) { + SettableFuture> listener = outgoingRequests.get(message.getResponse().getId()); + if (listener != null) listener.set(new Pair<>(message.getResponse().getStatus(), + new String(message.getResponse().getBody().toByteArray()))); + } + + notifyAll(); + } catch (InvalidProtocolBufferException e) { + Log.w(TAG, e); + } + } + + @Override + public synchronized void onClosed(WebSocket webSocket, int code, String reason) { + Log.w(TAG, "onClose()..."); + this.connected = false; + + Iterator>>> iterator = outgoingRequests.entrySet().iterator(); + + while (iterator.hasNext()) { + Map.Entry>> entry = iterator.next(); + entry.getValue().setException(new IOException("Closed: " + code + ", " + reason)); + iterator.remove(); + } + + if (keepAliveSender != null) { + keepAliveSender.shutdown(); + keepAliveSender = null; + } + + if (listener != null) { + listener.onDisconnected(); + } + + Util.wait(this, Math.min(++attempts * 200, TimeUnit.SECONDS.toMillis(15))); + + if (client != null) { + client.close(1000, "OK"); + client = null; + connected = false; + connect(); + } + + notifyAll(); + } + + @Override + public synchronized void onFailure(WebSocket webSocket, Throwable t, Response response) { + Log.w(TAG, "onFailure()"); + Log.w(TAG, t); + + if (response != null && (response.code() == 401 || response.code() == 403)) { + if (listener != null) listener.onAuthenticationFailure(); + } + + if (client != null) { + onClosed(webSocket, 1000, "OK"); + } + } + + @Override + public void onMessage(WebSocket webSocket, String text) { + Log.w(TAG, "onMessage(text)! " + text); + } + + @Override + public synchronized void onClosing(WebSocket webSocket, int code, String reason) { + Log.w(TAG, "onClosing()!..."); + webSocket.close(1000, "OK"); + } + + private long elapsedTime(long startTime) { + return System.currentTimeMillis() - startTime; + } + + private Pair createTlsSocketFactory(TrustStore trustStore) { + try { + SSLContext context = SSLContext.getInstance("TLS"); + TrustManager[] trustManagers = BlacklistingTrustManager.createFor(trustStore); + context.init(null, trustManagers, null); + + return new Pair<>(context.getSocketFactory(), (X509TrustManager)trustManagers[0]); + } catch (NoSuchAlgorithmException | KeyManagementException e) { + throw new AssertionError(e); + } + } + + private class KeepAliveSender extends Thread { + + private AtomicBoolean stop = new AtomicBoolean(false); + + public void run() { + while (!stop.get()) { + try { + sleepTimer.sleep(TimeUnit.SECONDS.toMillis(KEEPALIVE_TIMEOUT_SECONDS)); + + Log.w(TAG, "Sending keep alive..."); + sendKeepAlive(); + } catch (Throwable e) { + Log.w(TAG, e); + } + } + } + + public void shutdown() { + stop.set(true); + } + } + +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/websocket/WebSocketEventListener.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/websocket/WebSocketEventListener.java new file mode 100644 index 000000000..65953e32a --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/websocket/WebSocketEventListener.java @@ -0,0 +1,9 @@ +package org.whispersystems.signalservice.internal.websocket; + +public interface WebSocketEventListener { + + public void onMessage(byte[] payload); + public void onClose(); + public void onConnected(); + +} diff --git a/libsignal/service/src/main/java/org/whispersystems/util/Base64.java b/libsignal/service/src/main/java/org/whispersystems/util/Base64.java new file mode 100644 index 000000000..70daaaeb5 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/util/Base64.java @@ -0,0 +1,2098 @@ +package org.whispersystems.util; + +/** + *

Encodes and decodes to and from Base64 notation.

+ *

Homepage: http://iharder.net/base64.

+ * + *

Example:

+ * + * String encoded = Base64.encode( myByteArray ); + *
+ * byte[] myByteArray = Base64.decode( encoded ); + * + *

The options parameter, which appears in a few places, is used to pass + * several pieces of information to the encoder. In the "higher level" methods such as + * encodeBytes( bytes, options ) the options parameter can be used to indicate such + * things as first gzipping the bytes before encoding them, not inserting linefeeds, + * and encoding using the URL-safe and Ordered dialects.

+ * + *

Note, according to RFC3548, + * Section 2.1, implementations should not add line feeds unless explicitly told + * to do so. I've got Base64 set to this behavior now, although earlier versions + * broke lines by default.

+ * + *

The constants defined in Base64 can be OR-ed together to combine options, so you + * might make a call like this:

+ * + * String encoded = Base64.encodeBytes( mybytes, Base64.GZIP | Base64.DO_BREAK_LINES ); + *

to compress the data before encoding it and then making the output have newline characters.

+ *

Also...

+ * String encoded = Base64.encodeBytes( crazyString.getBytes() ); + * + * + * + *

+ * Change Log: + *

+ *
    + *
  • v2.3.4 - Fixed bug when working with gzipped streams whereby flushing + * the Base64.OutputStream closed the Base64 encoding (by padding with equals + * signs) too soon. Also added an option to suppress the automatic decoding + * of gzipped streams. Also added experimental support for specifying a + * class loader when using the + * {@link #decodeToObject(java.lang.String, int, java.lang.ClassLoader)} + * method.
  • + *
  • v2.3.3 - Changed default char encoding to US-ASCII which reduces the internal Java + * footprint with its CharEncoders and so forth. Fixed some javadocs that were + * inconsistent. Removed imports and specified things like java.io.IOException + * explicitly inline.
  • + *
  • v2.3.2 - Reduced memory footprint! Finally refined the "guessing" of how big the + * final encoded data will be so that the code doesn't have to create two output + * arrays: an oversized initial one and then a final, exact-sized one. Big win + * when using the {@link #encodeBytesToBytes(byte[])} family of methods (and not + * using the gzip options which uses a different mechanism with streams and stuff).
  • + *
  • v2.3.1 - Added {@link #encodeBytesToBytes(byte[], int, int, int)} and some + * similar helper methods to be more efficient with memory by not returning a + * String but just a byte array.
  • + *
  • v2.3 - This is not a drop-in replacement! This is two years of comments + * and bug fixes queued up and finally executed. Thanks to everyone who sent + * me stuff, and I'm sorry I wasn't able to distribute your fixes to everyone else. + * Much bad coding was cleaned up including throwing exceptions where necessary + * instead of returning null values or something similar. Here are some changes + * that may affect you: + *
      + *
    • Does not break lines, by default. This is to keep in compliance with + * RFC3548.
    • + *
    • Throws exceptions instead of returning null values. Because some operations + * (especially those that may permit the GZIP option) use IO streams, there + * is a possiblity of an java.io.IOException being thrown. After some discussion and + * thought, I've changed the behavior of the methods to throw java.io.IOExceptions + * rather than return null if ever there's an error. I think this is more + * appropriate, though it will require some changes to your code. Sorry, + * it should have been done this way to begin with.
    • + *
    • Removed all references to System.out, System.err, and the like. + * Shame on me. All I can say is sorry they were ever there.
    • + *
    • Throws NullPointerExceptions and IllegalArgumentExceptions as needed + * such as when passed arrays are null or offsets are invalid.
    • + *
    • Cleaned up as much javadoc as I could to avoid any javadoc warnings. + * This was especially annoying before for people who were thorough in their + * own projects and then had gobs of javadoc warnings on this file.
    • + *
    + *
  • v2.2.1 - Fixed bug using URL_SAFE and ORDERED encodings. Fixed bug + * when using very small files (~< 40 bytes).
  • + *
  • v2.2 - Added some helper methods for encoding/decoding directly from + * one file to the next. Also added a main() method to support command line + * encoding/decoding from one file to the next. Also added these Base64 dialects: + *
      + *
    1. The default is RFC3548 format.
    2. + *
    3. Calling Base64.setFormat(Base64.BASE64_FORMAT.URLSAFE_FORMAT) generates + * URL and file name friendly format as described in Section 4 of RFC3548. + * http://www.faqs.org/rfcs/rfc3548.html
    4. + *
    5. Calling Base64.setFormat(Base64.BASE64_FORMAT.ORDERED_FORMAT) generates + * URL and file name friendly format that preserves lexical ordering as described + * in http://www.faqs.org/qa/rfcc-1940.html
    6. + *
    + * Special thanks to Jim Kellerman at http://www.powerset.com/ + * for contributing the new Base64 dialects. + *
  • + * + *
  • v2.1 - Cleaned up javadoc comments and unused variables and methods. Added + * some convenience methods for reading and writing to and from files.
  • + *
  • v2.0.2 - Now specifies UTF-8 encoding in places where the code fails on systems + * with other encodings (like EBCDIC).
  • + *
  • v2.0.1 - Fixed an error when decoding a single byte, that is, when the + * encoded data was a single byte.
  • + *
  • v2.0 - I got rid of methods that used booleans to set options. + * Now everything is more consolidated and cleaner. The code now detects + * when data that's being decoded is gzip-compressed and will decompress it + * automatically. Generally things are cleaner. You'll probably have to + * change some method calls that you were making to support the new + * options format (ints that you "OR" together).
  • + *
  • v1.5.1 - Fixed bug when decompressing and decoding to a + * byte[] using decode( String s, boolean gzipCompressed ). + * Added the ability to "suspend" encoding in the Output Stream so + * you can turn on and off the encoding if you need to embed base64 + * data in an otherwise "normal" stream (like an XML file).
  • + *
  • v1.5 - Output stream pases on flush() command but doesn't do anything itself. + * This helps when using GZIP streams. + * Added the ability to GZip-compress objects before encoding them.
  • + *
  • v1.4 - Added helper methods to read/write files.
  • + *
  • v1.3.6 - Fixed OutputStream.flush() so that 'position' is reset.
  • + *
  • v1.3.5 - Added flag to turn on and off line breaks. Fixed bug in input stream + * where last buffer being read, if not completely full, was not returned.
  • + *
  • v1.3.4 - Fixed when "improperly padded stream" error was thrown at the wrong time.
  • + *
  • v1.3.3 - Fixed I/O streams which were totally messed up.
  • + *
+ * + *

+ * I am placing this code in the Public Domain. Do with it as you will. + * This software comes with no guarantees or warranties but with + * plenty of well-wishing instead! + * Please visit http://iharder.net/base64 + * periodically to check for updates or to contribute improvements. + *

+ * + * @author Robert Harder + * @author rob@iharder.net + * @version 2.3.3 + */ +public class Base64 +{ + +/* ******** P U B L I C F I E L D S ******** */ + + + /** No options specified. Value is zero. */ + public final static int NO_OPTIONS = 0; + + /** Specify encoding in first bit. Value is one. */ + public final static int ENCODE = 1; + + + /** Specify decoding in first bit. Value is zero. */ + public final static int DECODE = 0; + + + /** Specify that data should be gzip-compressed in second bit. Value is two. */ + public final static int GZIP = 2; + + /** Specify that gzipped data should not be automatically gunzipped. */ + public final static int DONT_GUNZIP = 4; + + + /** Do break lines when encoding. Value is 8. */ + public final static int DO_BREAK_LINES = 8; + + /** + * Encode using Base64-like encoding that is URL- and Filename-safe as described + * in Section 4 of RFC3548: + * http://www.faqs.org/rfcs/rfc3548.html. + * It is important to note that data encoded this way is not officially valid Base64, + * or at the very least should not be called Base64 without also specifying that is + * was encoded using the URL- and Filename-safe dialect. + */ + public final static int URL_SAFE = 16; + + + /** + * Encode using the special "ordered" dialect of Base64 described here: + * http://www.faqs.org/qa/rfcc-1940.html. + */ + public final static int ORDERED = 32; + + +/* ******** P R I V A T E F I E L D S ******** */ + + + /** Maximum line length (76) of Base64 output. */ + private final static int MAX_LINE_LENGTH = 76; + + + /** The equals sign (=) as a byte. */ + private final static byte EQUALS_SIGN = (byte)'='; + + + /** The new line character (\n) as a byte. */ + private final static byte NEW_LINE = (byte)'\n'; + + + /** Preferred encoding. */ + private final static String PREFERRED_ENCODING = "US-ASCII"; + + + private final static byte WHITE_SPACE_ENC = -5; // Indicates white space in encoding + private final static byte EQUALS_SIGN_ENC = -1; // Indicates equals sign in encoding + + +/* ******** S T A N D A R D B A S E 6 4 A L P H A B E T ******** */ + + /** The 64 valid Base64 values. */ + /* Host platform me be something funny like EBCDIC, so we hardcode these values. */ + private final static byte[] _STANDARD_ALPHABET = { + (byte)'A', (byte)'B', (byte)'C', (byte)'D', (byte)'E', (byte)'F', (byte)'G', + (byte)'H', (byte)'I', (byte)'J', (byte)'K', (byte)'L', (byte)'M', (byte)'N', + (byte)'O', (byte)'P', (byte)'Q', (byte)'R', (byte)'S', (byte)'T', (byte)'U', + (byte)'V', (byte)'W', (byte)'X', (byte)'Y', (byte)'Z', + (byte)'a', (byte)'b', (byte)'c', (byte)'d', (byte)'e', (byte)'f', (byte)'g', + (byte)'h', (byte)'i', (byte)'j', (byte)'k', (byte)'l', (byte)'m', (byte)'n', + (byte)'o', (byte)'p', (byte)'q', (byte)'r', (byte)'s', (byte)'t', (byte)'u', + (byte)'v', (byte)'w', (byte)'x', (byte)'y', (byte)'z', + (byte)'0', (byte)'1', (byte)'2', (byte)'3', (byte)'4', (byte)'5', + (byte)'6', (byte)'7', (byte)'8', (byte)'9', (byte)'+', (byte)'/' + }; + + + /** + * Translates a Base64 value to either its 6-bit reconstruction value + * or a negative number indicating some other meaning. + **/ + private final static byte[] _STANDARD_DECODABET = { + -9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 0 - 8 + -5,-5, // Whitespace: Tab and Linefeed + -9,-9, // Decimal 11 - 12 + -5, // Whitespace: Carriage Return + -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 14 - 26 + -9,-9,-9,-9,-9, // Decimal 27 - 31 + -5, // Whitespace: Space + -9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 33 - 42 + 62, // Plus sign at decimal 43 + -9,-9,-9, // Decimal 44 - 46 + 63, // Slash at decimal 47 + 52,53,54,55,56,57,58,59,60,61, // Numbers zero through nine + -9,-9,-9, // Decimal 58 - 60 + -1, // Equals sign at decimal 61 + -9,-9,-9, // Decimal 62 - 64 + 0,1,2,3,4,5,6,7,8,9,10,11,12,13, // Letters 'A' through 'N' + 14,15,16,17,18,19,20,21,22,23,24,25, // Letters 'O' through 'Z' + -9,-9,-9,-9,-9,-9, // Decimal 91 - 96 + 26,27,28,29,30,31,32,33,34,35,36,37,38, // Letters 'a' through 'm' + 39,40,41,42,43,44,45,46,47,48,49,50,51, // Letters 'n' through 'z' + -9,-9,-9,-9 // Decimal 123 - 126 + /*,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 127 - 139 + -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 140 - 152 + -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 153 - 165 + -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 166 - 178 + -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 179 - 191 + -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 192 - 204 + -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 205 - 217 + -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 218 - 230 + -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 231 - 243 + -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9 // Decimal 244 - 255 */ + }; + + +/* ******** U R L S A F E B A S E 6 4 A L P H A B E T ******** */ + + /** + * Used in the URL- and Filename-safe dialect described in Section 4 of RFC3548: + * http://www.faqs.org/rfcs/rfc3548.html. + * Notice that the last two bytes become "hyphen" and "underscore" instead of "plus" and "slash." + */ + private final static byte[] _URL_SAFE_ALPHABET = { + (byte)'A', (byte)'B', (byte)'C', (byte)'D', (byte)'E', (byte)'F', (byte)'G', + (byte)'H', (byte)'I', (byte)'J', (byte)'K', (byte)'L', (byte)'M', (byte)'N', + (byte)'O', (byte)'P', (byte)'Q', (byte)'R', (byte)'S', (byte)'T', (byte)'U', + (byte)'V', (byte)'W', (byte)'X', (byte)'Y', (byte)'Z', + (byte)'a', (byte)'b', (byte)'c', (byte)'d', (byte)'e', (byte)'f', (byte)'g', + (byte)'h', (byte)'i', (byte)'j', (byte)'k', (byte)'l', (byte)'m', (byte)'n', + (byte)'o', (byte)'p', (byte)'q', (byte)'r', (byte)'s', (byte)'t', (byte)'u', + (byte)'v', (byte)'w', (byte)'x', (byte)'y', (byte)'z', + (byte)'0', (byte)'1', (byte)'2', (byte)'3', (byte)'4', (byte)'5', + (byte)'6', (byte)'7', (byte)'8', (byte)'9', (byte)'-', (byte)'_' + }; + + /** + * Used in decoding URL- and Filename-safe dialects of Base64. + */ + private final static byte[] _URL_SAFE_DECODABET = { + -9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 0 - 8 + -5,-5, // Whitespace: Tab and Linefeed + -9,-9, // Decimal 11 - 12 + -5, // Whitespace: Carriage Return + -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 14 - 26 + -9,-9,-9,-9,-9, // Decimal 27 - 31 + -5, // Whitespace: Space + -9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 33 - 42 + -9, // Plus sign at decimal 43 + -9, // Decimal 44 + 62, // Minus sign at decimal 45 + -9, // Decimal 46 + -9, // Slash at decimal 47 + 52,53,54,55,56,57,58,59,60,61, // Numbers zero through nine + -9,-9,-9, // Decimal 58 - 60 + -1, // Equals sign at decimal 61 + -9,-9,-9, // Decimal 62 - 64 + 0,1,2,3,4,5,6,7,8,9,10,11,12,13, // Letters 'A' through 'N' + 14,15,16,17,18,19,20,21,22,23,24,25, // Letters 'O' through 'Z' + -9,-9,-9,-9, // Decimal 91 - 94 + 63, // Underscore at decimal 95 + -9, // Decimal 96 + 26,27,28,29,30,31,32,33,34,35,36,37,38, // Letters 'a' through 'm' + 39,40,41,42,43,44,45,46,47,48,49,50,51, // Letters 'n' through 'z' + -9,-9,-9,-9 // Decimal 123 - 126 + /*,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 127 - 139 + -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 140 - 152 + -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 153 - 165 + -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 166 - 178 + -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 179 - 191 + -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 192 - 204 + -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 205 - 217 + -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 218 - 230 + -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 231 - 243 + -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9 // Decimal 244 - 255 */ + }; + + + +/* ******** O R D E R E D B A S E 6 4 A L P H A B E T ******** */ + + /** + * I don't get the point of this technique, but someone requested it, + * and it is described here: + * http://www.faqs.org/qa/rfcc-1940.html. + */ + private final static byte[] _ORDERED_ALPHABET = { + (byte)'-', + (byte)'0', (byte)'1', (byte)'2', (byte)'3', (byte)'4', + (byte)'5', (byte)'6', (byte)'7', (byte)'8', (byte)'9', + (byte)'A', (byte)'B', (byte)'C', (byte)'D', (byte)'E', (byte)'F', (byte)'G', + (byte)'H', (byte)'I', (byte)'J', (byte)'K', (byte)'L', (byte)'M', (byte)'N', + (byte)'O', (byte)'P', (byte)'Q', (byte)'R', (byte)'S', (byte)'T', (byte)'U', + (byte)'V', (byte)'W', (byte)'X', (byte)'Y', (byte)'Z', + (byte)'_', + (byte)'a', (byte)'b', (byte)'c', (byte)'d', (byte)'e', (byte)'f', (byte)'g', + (byte)'h', (byte)'i', (byte)'j', (byte)'k', (byte)'l', (byte)'m', (byte)'n', + (byte)'o', (byte)'p', (byte)'q', (byte)'r', (byte)'s', (byte)'t', (byte)'u', + (byte)'v', (byte)'w', (byte)'x', (byte)'y', (byte)'z' + }; + + /** + * Used in decoding the "ordered" dialect of Base64. + */ + private final static byte[] _ORDERED_DECODABET = { + -9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 0 - 8 + -5,-5, // Whitespace: Tab and Linefeed + -9,-9, // Decimal 11 - 12 + -5, // Whitespace: Carriage Return + -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 14 - 26 + -9,-9,-9,-9,-9, // Decimal 27 - 31 + -5, // Whitespace: Space + -9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 33 - 42 + -9, // Plus sign at decimal 43 + -9, // Decimal 44 + 0, // Minus sign at decimal 45 + -9, // Decimal 46 + -9, // Slash at decimal 47 + 1,2,3,4,5,6,7,8,9,10, // Numbers zero through nine + -9,-9,-9, // Decimal 58 - 60 + -1, // Equals sign at decimal 61 + -9,-9,-9, // Decimal 62 - 64 + 11,12,13,14,15,16,17,18,19,20,21,22,23, // Letters 'A' through 'M' + 24,25,26,27,28,29,30,31,32,33,34,35,36, // Letters 'N' through 'Z' + -9,-9,-9,-9, // Decimal 91 - 94 + 37, // Underscore at decimal 95 + -9, // Decimal 96 + 38,39,40,41,42,43,44,45,46,47,48,49,50, // Letters 'a' through 'm' + 51,52,53,54,55,56,57,58,59,60,61,62,63, // Letters 'n' through 'z' + -9,-9,-9,-9 // Decimal 123 - 126 + /*,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 127 - 139 + -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 140 - 152 + -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 153 - 165 + -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 166 - 178 + -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 179 - 191 + -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 192 - 204 + -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 205 - 217 + -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 218 - 230 + -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 231 - 243 + -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9 // Decimal 244 - 255 */ + }; + + +/* ******** D E T E R M I N E W H I C H A L H A B E T ******** */ + + + /** + * Returns one of the _SOMETHING_ALPHABET byte arrays depending on + * the options specified. + * It's possible, though silly, to specify ORDERED and URLSAFE + * in which case one of them will be picked, though there is + * no guarantee as to which one will be picked. + */ + private final static byte[] getAlphabet( int options ) { + if ((options & URL_SAFE) == URL_SAFE) { + return _URL_SAFE_ALPHABET; + } else if ((options & ORDERED) == ORDERED) { + return _ORDERED_ALPHABET; + } else { + return _STANDARD_ALPHABET; + } + } // end getAlphabet + + + /** + * Returns one of the _SOMETHING_DECODABET byte arrays depending on + * the options specified. + * It's possible, though silly, to specify ORDERED and URL_SAFE + * in which case one of them will be picked, though there is + * no guarantee as to which one will be picked. + */ + private final static byte[] getDecodabet( int options ) { + if( (options & URL_SAFE) == URL_SAFE) { + return _URL_SAFE_DECODABET; + } else if ((options & ORDERED) == ORDERED) { + return _ORDERED_DECODABET; + } else { + return _STANDARD_DECODABET; + } + } // end getAlphabet + + + + /** Defeats instantiation. */ + private Base64(){} + + + + public static int getEncodedLengthWithoutPadding(int unencodedLength) { + int remainderBytes = unencodedLength % 3; + int paddingBytes = 0; + + if (remainderBytes != 0) + paddingBytes = 3 - remainderBytes; + + return (((int)((unencodedLength+2)/3))*4) - paddingBytes; + } + + public static int getEncodedBytesForTarget(int targetSize) { + return ((int)(targetSize * 3)) / 4; + } + + +/* ******** E N C O D I N G M E T H O D S ******** */ + + + /** + * Encodes up to the first three bytes of array threeBytes + * and returns a four-byte array in Base64 notation. + * The actual number of significant bytes in your array is + * given by numSigBytes. + * The array threeBytes needs only be as big as + * numSigBytes. + * Code can reuse a byte array by passing a four-byte array as b4. + * + * @param b4 A reusable byte array to reduce array instantiation + * @param threeBytes the array to convert + * @param numSigBytes the number of significant bytes in your array + * @return four byte array in Base64 notation. + * @since 1.5.1 + */ + private static byte[] encode3to4( byte[] b4, byte[] threeBytes, int numSigBytes, int options ) { + encode3to4( threeBytes, 0, numSigBytes, b4, 0, options ); + return b4; + } // end encode3to4 + + + /** + *

Encodes up to three bytes of the array source + * and writes the resulting four Base64 bytes to destination. + * The source and destination arrays can be manipulated + * anywhere along their length by specifying + * srcOffset and destOffset. + * This method does not check to make sure your arrays + * are large enough to accomodate srcOffset + 3 for + * the source array or destOffset + 4 for + * the destination array. + * The actual number of significant bytes in your array is + * given by numSigBytes.

+ *

This is the lowest level of the encoding methods with + * all possible parameters.

+ * + * @param source the array to convert + * @param srcOffset the index where conversion begins + * @param numSigBytes the number of significant bytes in your array + * @param destination the array to hold the conversion + * @param destOffset the index where output will be put + * @return the destination array + * @since 1.3 + */ + private static byte[] encode3to4( + byte[] source, int srcOffset, int numSigBytes, + byte[] destination, int destOffset, int options ) { + + byte[] ALPHABET = getAlphabet( options ); + + // 1 2 3 + // 01234567890123456789012345678901 Bit position + // --------000000001111111122222222 Array position from threeBytes + // --------| || || || | Six bit groups to index ALPHABET + // >>18 >>12 >> 6 >> 0 Right shift necessary + // 0x3f 0x3f 0x3f Additional AND + + // Create buffer with zero-padding if there are only one or two + // significant bytes passed in the array. + // We have to shift left 24 in order to flush out the 1's that appear + // when Java treats a value as negative that is cast from a byte to an int. + int inBuff = ( numSigBytes > 0 ? ((source[ srcOffset ] << 24) >>> 8) : 0 ) + | ( numSigBytes > 1 ? ((source[ srcOffset + 1 ] << 24) >>> 16) : 0 ) + | ( numSigBytes > 2 ? ((source[ srcOffset + 2 ] << 24) >>> 24) : 0 ); + + switch( numSigBytes ) + { + case 3: + destination[ destOffset ] = ALPHABET[ (inBuff >>> 18) ]; + destination[ destOffset + 1 ] = ALPHABET[ (inBuff >>> 12) & 0x3f ]; + destination[ destOffset + 2 ] = ALPHABET[ (inBuff >>> 6) & 0x3f ]; + destination[ destOffset + 3 ] = ALPHABET[ (inBuff ) & 0x3f ]; + return destination; + + case 2: + destination[ destOffset ] = ALPHABET[ (inBuff >>> 18) ]; + destination[ destOffset + 1 ] = ALPHABET[ (inBuff >>> 12) & 0x3f ]; + destination[ destOffset + 2 ] = ALPHABET[ (inBuff >>> 6) & 0x3f ]; + destination[ destOffset + 3 ] = EQUALS_SIGN; + return destination; + + case 1: + destination[ destOffset ] = ALPHABET[ (inBuff >>> 18) ]; + destination[ destOffset + 1 ] = ALPHABET[ (inBuff >>> 12) & 0x3f ]; + destination[ destOffset + 2 ] = EQUALS_SIGN; + destination[ destOffset + 3 ] = EQUALS_SIGN; + return destination; + + default: + return destination; + } // end switch + } // end encode3to4 + + + + /** + * Performs Base64 encoding on the raw ByteBuffer, + * writing it to the encoded ByteBuffer. + * This is an experimental feature. Currently it does not + * pass along any options (such as {@link #DO_BREAK_LINES} + * or {@link #GZIP}. + * + * @param raw input buffer + * @param encoded output buffer + * @since 2.3 + */ + public static void encode( java.nio.ByteBuffer raw, java.nio.ByteBuffer encoded ){ + byte[] raw3 = new byte[3]; + byte[] enc4 = new byte[4]; + + while( raw.hasRemaining() ){ + int rem = Math.min(3,raw.remaining()); + raw.get(raw3,0,rem); + Base64.encode3to4(enc4, raw3, rem, Base64.NO_OPTIONS ); + encoded.put(enc4); + } // end input remaining + } + + + /** + * Performs Base64 encoding on the raw ByteBuffer, + * writing it to the encoded CharBuffer. + * This is an experimental feature. Currently it does not + * pass along any options (such as {@link #DO_BREAK_LINES} + * or {@link #GZIP}. + * + * @param raw input buffer + * @param encoded output buffer + * @since 2.3 + */ + public static void encode( java.nio.ByteBuffer raw, java.nio.CharBuffer encoded ){ + byte[] raw3 = new byte[3]; + byte[] enc4 = new byte[4]; + + while( raw.hasRemaining() ){ + int rem = Math.min(3,raw.remaining()); + raw.get(raw3,0,rem); + Base64.encode3to4(enc4, raw3, rem, Base64.NO_OPTIONS ); + for( int i = 0; i < 4; i++ ){ + encoded.put( (char)(enc4[i] & 0xFF) ); + } + } // end input remaining + } + + + + + /** + * Serializes an object and returns the Base64-encoded + * version of that serialized object. + * + *

As of v 2.3, if the object + * cannot be serialized or there is another error, + * the method will throw an java.io.IOException. This is new to v2.3! + * In earlier versions, it just returned a null value, but + * in retrospect that's a pretty poor way to handle it.

+ * + * The object is not GZip-compressed before being encoded. + * + * @param serializableObject The object to encode + * @return The Base64-encoded object + * @throws java.io.IOException if there is an error + * @throws NullPointerException if serializedObject is null + * @since 1.4 + */ + public static String encodeObject( java.io.Serializable serializableObject ) + throws java.io.IOException { + return encodeObject( serializableObject, NO_OPTIONS ); + } // end encodeObject + + + + /** + * Serializes an object and returns the Base64-encoded + * version of that serialized object. + * + *

As of v 2.3, if the object + * cannot be serialized or there is another error, + * the method will throw an java.io.IOException. This is new to v2.3! + * In earlier versions, it just returned a null value, but + * in retrospect that's a pretty poor way to handle it.

+ * + * The object is not GZip-compressed before being encoded. + *

+ * Example options:

+     *   GZIP: gzip-compresses object before encoding it.
+     *   DO_BREAK_LINES: break lines at 76 characters
+     * 
+ *

+ * Example: encodeObject( myObj, Base64.GZIP ) or + *

+ * Example: encodeObject( myObj, Base64.GZIP | Base64.DO_BREAK_LINES ) + * + * @param serializableObject The object to encode + * @param options Specified options + * @return The Base64-encoded object + * @see Base64#GZIP + * @see Base64#DO_BREAK_LINES + * @throws java.io.IOException if there is an error + * @since 2.0 + */ + public static String encodeObject( java.io.Serializable serializableObject, int options ) + throws java.io.IOException { + + if( serializableObject == null ){ + throw new NullPointerException( "Cannot serialize a null object." ); + } // end if: null + + // Streams + java.io.ByteArrayOutputStream baos = null; + java.io.OutputStream b64os = null; + java.util.zip.GZIPOutputStream gzos = null; + java.io.ObjectOutputStream oos = null; + + + try { + // ObjectOutputStream -> (GZIP) -> Base64 -> ByteArrayOutputStream + baos = new java.io.ByteArrayOutputStream(); + b64os = new Base64.OutputStream( baos, ENCODE | options ); + if( (options & GZIP) != 0 ){ + // Gzip + gzos = new java.util.zip.GZIPOutputStream(b64os); + oos = new java.io.ObjectOutputStream( gzos ); + } else { + // Not gzipped + oos = new java.io.ObjectOutputStream( b64os ); + } + oos.writeObject( serializableObject ); + } // end try + catch( java.io.IOException e ) { + // Catch it and then throw it immediately so that + // the finally{} block is called for cleanup. + throw e; + } // end catch + finally { + try{ oos.close(); } catch( Exception e ){} + try{ gzos.close(); } catch( Exception e ){} + try{ b64os.close(); } catch( Exception e ){} + try{ baos.close(); } catch( Exception e ){} + } // end finally + + // Return value according to relevant encoding. + try { + return new String( baos.toByteArray(), PREFERRED_ENCODING ); + } // end try + catch (java.io.UnsupportedEncodingException uue){ + // Fall back to some Java default + return new String( baos.toByteArray() ); + } // end catch + + } // end encode + + + + /** + * Encodes a byte array into Base64 notation. + * Does not GZip-compress data. + * + * @param source The data to convert + * @return The data in Base64-encoded form + * @throws NullPointerException if source array is null + * @since 1.4 + */ + public static String encodeBytes( byte[] source ) { + // Since we're not going to have the GZIP encoding turned on, + // we're not going to have an java.io.IOException thrown, so + // we should not force the user to have to catch it. + String encoded = null; + try { + encoded = encodeBytes(source, 0, source.length, NO_OPTIONS); + } catch (java.io.IOException ex) { + assert false : ex.getMessage(); + } // end catch + assert encoded != null; + return encoded; + } // end encodeBytes + + + public static String encodeBytesWithoutPadding(byte[] source, int offset, int length) { + String encoded = null; + + try { + encoded = encodeBytes(source, offset, length, NO_OPTIONS); + } catch (java.io.IOException ex) { + assert false : ex.getMessage(); + } + + assert encoded != null; + + if (encoded.charAt(encoded.length()-2) == '=') return encoded.substring(0, encoded.length()-2); + else if (encoded.charAt(encoded.length()-1) == '=') return encoded.substring(0, encoded.length()-1); + else return encoded; + + } + + public static String encodeBytesWithoutPadding(byte[] source) { + return encodeBytesWithoutPadding(source, 0, source.length); + } + + + /** + * Encodes a byte array into Base64 notation. + *

+ * Example options:

+     *   GZIP: gzip-compresses object before encoding it.
+     *   DO_BREAK_LINES: break lines at 76 characters
+     *     Note: Technically, this makes your encoding non-compliant.
+     * 
+ *

+ * Example: encodeBytes( myData, Base64.GZIP ) or + *

+ * Example: encodeBytes( myData, Base64.GZIP | Base64.DO_BREAK_LINES ) + * + * + *

As of v 2.3, if there is an error with the GZIP stream, + * the method will throw an java.io.IOException. This is new to v2.3! + * In earlier versions, it just returned a null value, but + * in retrospect that's a pretty poor way to handle it.

+ * + * + * @param source The data to convert + * @param options Specified options + * @return The Base64-encoded data as a String + * @see Base64#GZIP + * @see Base64#DO_BREAK_LINES + * @throws java.io.IOException if there is an error + * @throws NullPointerException if source array is null + * @since 2.0 + */ + public static String encodeBytes( byte[] source, int options ) throws java.io.IOException { + return encodeBytes( source, 0, source.length, options ); + } // end encodeBytes + + + /** + * Encodes a byte array into Base64 notation. + * Does not GZip-compress data. + * + *

As of v 2.3, if there is an error, + * the method will throw an java.io.IOException. This is new to v2.3! + * In earlier versions, it just returned a null value, but + * in retrospect that's a pretty poor way to handle it.

+ * + * + * @param source The data to convert + * @param off Offset in array where conversion should begin + * @param len Length of data to convert + * @return The Base64-encoded data as a String + * @throws NullPointerException if source array is null + * @throws IllegalArgumentException if source array, offset, or length are invalid + * @since 1.4 + */ + public static String encodeBytes( byte[] source, int off, int len ) { + // Since we're not going to have the GZIP encoding turned on, + // we're not going to have an java.io.IOException thrown, so + // we should not force the user to have to catch it. + String encoded = null; + try { + encoded = encodeBytes( source, off, len, NO_OPTIONS ); + } catch (java.io.IOException ex) { + assert false : ex.getMessage(); + } // end catch + assert encoded != null; + return encoded; + } // end encodeBytes + + + + /** + * Encodes a byte array into Base64 notation. + *

+ * Example options:

+     *   GZIP: gzip-compresses object before encoding it.
+     *   DO_BREAK_LINES: break lines at 76 characters
+     *     Note: Technically, this makes your encoding non-compliant.
+     * 
+ *

+ * Example: encodeBytes( myData, Base64.GZIP ) or + *

+ * Example: encodeBytes( myData, Base64.GZIP | Base64.DO_BREAK_LINES ) + * + * + *

As of v 2.3, if there is an error with the GZIP stream, + * the method will throw an java.io.IOException. This is new to v2.3! + * In earlier versions, it just returned a null value, but + * in retrospect that's a pretty poor way to handle it.

+ * + * + * @param source The data to convert + * @param off Offset in array where conversion should begin + * @param len Length of data to convert + * @param options Specified options + * @return The Base64-encoded data as a String + * @see Base64#GZIP + * @see Base64#DO_BREAK_LINES + * @throws java.io.IOException if there is an error + * @throws NullPointerException if source array is null + * @throws IllegalArgumentException if source array, offset, or length are invalid + * @since 2.0 + */ + public static String encodeBytes( byte[] source, int off, int len, int options ) throws java.io.IOException { + byte[] encoded = encodeBytesToBytes( source, off, len, options ); + + // Return value according to relevant encoding. + try { + return new String( encoded, PREFERRED_ENCODING ); + } // end try + catch (java.io.UnsupportedEncodingException uue) { + return new String( encoded ); + } // end catch + + } // end encodeBytes + + + + + /** + * Similar to {@link #encodeBytes(byte[])} but returns + * a byte array instead of instantiating a String. This is more efficient + * if you're working with I/O streams and have large data sets to encode. + * + * + * @param source The data to convert + * @return The Base64-encoded data as a byte[] (of ASCII characters) + * @throws NullPointerException if source array is null + * @since 2.3.1 + */ + public static byte[] encodeBytesToBytes( byte[] source ) { + byte[] encoded = null; + try { + encoded = encodeBytesToBytes( source, 0, source.length, Base64.NO_OPTIONS ); + } catch( java.io.IOException ex ) { + assert false : "IOExceptions only come from GZipping, which is turned off: " + ex.getMessage(); + } + return encoded; + } + + + /** + * Similar to {@link #encodeBytes(byte[], int, int, int)} but returns + * a byte array instead of instantiating a String. This is more efficient + * if you're working with I/O streams and have large data sets to encode. + * + * + * @param source The data to convert + * @param off Offset in array where conversion should begin + * @param len Length of data to convert + * @param options Specified options + * @return The Base64-encoded data as a String + * @see Base64#GZIP + * @see Base64#DO_BREAK_LINES + * @throws java.io.IOException if there is an error + * @throws NullPointerException if source array is null + * @throws IllegalArgumentException if source array, offset, or length are invalid + * @since 2.3.1 + */ + public static byte[] encodeBytesToBytes( byte[] source, int off, int len, int options ) throws java.io.IOException { + + if( source == null ){ + throw new NullPointerException( "Cannot serialize a null array." ); + } // end if: null + + if( off < 0 ){ + throw new IllegalArgumentException( "Cannot have negative offset: " + off ); + } // end if: off < 0 + + if( len < 0 ){ + throw new IllegalArgumentException( "Cannot have length offset: " + len ); + } // end if: len < 0 + + if( off + len > source.length ){ + throw new IllegalArgumentException( + String.format( "Cannot have offset of %d and length of %d with array of length %d", off,len,source.length)); + } // end if: off < 0 + + + + // Compress? + if( (options & GZIP) != 0 ) { + java.io.ByteArrayOutputStream baos = null; + java.util.zip.GZIPOutputStream gzos = null; + Base64.OutputStream b64os = null; + + try { + // GZip -> Base64 -> ByteArray + baos = new java.io.ByteArrayOutputStream(); + b64os = new Base64.OutputStream( baos, ENCODE | options ); + gzos = new java.util.zip.GZIPOutputStream( b64os ); + + gzos.write( source, off, len ); + gzos.close(); + } // end try + catch( java.io.IOException e ) { + // Catch it and then throw it immediately so that + // the finally{} block is called for cleanup. + throw e; + } // end catch + finally { + try{ gzos.close(); } catch( Exception e ){} + try{ b64os.close(); } catch( Exception e ){} + try{ baos.close(); } catch( Exception e ){} + } // end finally + + return baos.toByteArray(); + } // end if: compress + + // Else, don't compress. Better not to use streams at all then. + else { + boolean breakLines = (options & DO_BREAK_LINES) > 0; + + //int len43 = len * 4 / 3; + //byte[] outBuff = new byte[ ( len43 ) // Main 4:3 + // + ( (len % 3) > 0 ? 4 : 0 ) // Account for padding + // + (breakLines ? ( len43 / MAX_LINE_LENGTH ) : 0) ]; // New lines + // Try to determine more precisely how big the array needs to be. + // If we get it right, we don't have to do an array copy, and + // we save a bunch of memory. + int encLen = ( len / 3 ) * 4 + ( len % 3 > 0 ? 4 : 0 ); // Bytes needed for actual encoding + if( breakLines ){ + encLen += encLen / MAX_LINE_LENGTH; // Plus extra newline characters + } + byte[] outBuff = new byte[ encLen ]; + + + int d = 0; + int e = 0; + int len2 = len - 2; + int lineLength = 0; + for( ; d < len2; d+=3, e+=4 ) { + encode3to4( source, d+off, 3, outBuff, e, options ); + + lineLength += 4; + if( breakLines && lineLength >= MAX_LINE_LENGTH ) + { + outBuff[e+4] = NEW_LINE; + e++; + lineLength = 0; + } // end if: end of line + } // en dfor: each piece of array + + if( d < len ) { + encode3to4( source, d+off, len - d, outBuff, e, options ); + e += 4; + } // end if: some padding needed + + + // Only resize array if we didn't guess it right. + if( e < outBuff.length - 1 ){ + byte[] finalOut = new byte[e]; + System.arraycopy(outBuff,0, finalOut,0,e); + //System.err.println("Having to resize array from " + outBuff.length + " to " + e ); + return finalOut; + } else { + //System.err.println("No need to resize array."); + return outBuff; + } + + } // end else: don't compress + + } // end encodeBytesToBytes + + + + + +/* ******** D E C O D I N G M E T H O D S ******** */ + + + /** + * Decodes four bytes from array source + * and writes the resulting bytes (up to three of them) + * to destination. + * The source and destination arrays can be manipulated + * anywhere along their length by specifying + * srcOffset and destOffset. + * This method does not check to make sure your arrays + * are large enough to accomodate srcOffset + 4 for + * the source array or destOffset + 3 for + * the destination array. + * This method returns the actual number of bytes that + * were converted from the Base64 encoding. + *

This is the lowest level of the decoding methods with + * all possible parameters.

+ * + * + * @param source the array to convert + * @param srcOffset the index where conversion begins + * @param destination the array to hold the conversion + * @param destOffset the index where output will be put + * @param options alphabet type is pulled from this (standard, url-safe, ordered) + * @return the number of decoded bytes converted + * @throws NullPointerException if source or destination arrays are null + * @throws IllegalArgumentException if srcOffset or destOffset are invalid + * or there is not enough room in the array. + * @since 1.3 + */ + private static int decode4to3( + byte[] source, int srcOffset, + byte[] destination, int destOffset, int options ) { + + // Lots of error checking and exception throwing + if( source == null ){ + throw new NullPointerException( "Source array was null." ); + } // end if + if( destination == null ){ + throw new NullPointerException( "Destination array was null." ); + } // end if + if( srcOffset < 0 || srcOffset + 3 >= source.length ){ + throw new IllegalArgumentException( String.format( + "Source array with length %d cannot have offset of %d and still process four bytes.", source.length, srcOffset ) ); + } // end if + if( destOffset < 0 || destOffset +2 >= destination.length ){ + throw new IllegalArgumentException( String.format( + "Destination array with length %d cannot have offset of %d and still store three bytes.", destination.length, destOffset ) ); + } // end if + + + byte[] DECODABET = getDecodabet( options ); + + // Example: Dk== + if( source[ srcOffset + 2] == EQUALS_SIGN ) { + // Two ways to do the same thing. Don't know which way I like best. + //int outBuff = ( ( DECODABET[ source[ srcOffset ] ] << 24 ) >>> 6 ) + // | ( ( DECODABET[ source[ srcOffset + 1] ] << 24 ) >>> 12 ); + int outBuff = ( ( DECODABET[ source[ srcOffset ] ] & 0xFF ) << 18 ) + | ( ( DECODABET[ source[ srcOffset + 1] ] & 0xFF ) << 12 ); + + destination[ destOffset ] = (byte)( outBuff >>> 16 ); + return 1; + } + + // Example: DkL= + else if( source[ srcOffset + 3 ] == EQUALS_SIGN ) { + // Two ways to do the same thing. Don't know which way I like best. + //int outBuff = ( ( DECODABET[ source[ srcOffset ] ] << 24 ) >>> 6 ) + // | ( ( DECODABET[ source[ srcOffset + 1 ] ] << 24 ) >>> 12 ) + // | ( ( DECODABET[ source[ srcOffset + 2 ] ] << 24 ) >>> 18 ); + int outBuff = ( ( DECODABET[ source[ srcOffset ] ] & 0xFF ) << 18 ) + | ( ( DECODABET[ source[ srcOffset + 1 ] ] & 0xFF ) << 12 ) + | ( ( DECODABET[ source[ srcOffset + 2 ] ] & 0xFF ) << 6 ); + + destination[ destOffset ] = (byte)( outBuff >>> 16 ); + destination[ destOffset + 1 ] = (byte)( outBuff >>> 8 ); + return 2; + } + + // Example: DkLE + else { + // Two ways to do the same thing. Don't know which way I like best. + //int outBuff = ( ( DECODABET[ source[ srcOffset ] ] << 24 ) >>> 6 ) + // | ( ( DECODABET[ source[ srcOffset + 1 ] ] << 24 ) >>> 12 ) + // | ( ( DECODABET[ source[ srcOffset + 2 ] ] << 24 ) >>> 18 ) + // | ( ( DECODABET[ source[ srcOffset + 3 ] ] << 24 ) >>> 24 ); + int outBuff = ( ( DECODABET[ source[ srcOffset ] ] & 0xFF ) << 18 ) + | ( ( DECODABET[ source[ srcOffset + 1 ] ] & 0xFF ) << 12 ) + | ( ( DECODABET[ source[ srcOffset + 2 ] ] & 0xFF ) << 6) + | ( ( DECODABET[ source[ srcOffset + 3 ] ] & 0xFF ) ); + + + destination[ destOffset ] = (byte)( outBuff >> 16 ); + destination[ destOffset + 1 ] = (byte)( outBuff >> 8 ); + destination[ destOffset + 2 ] = (byte)( outBuff ); + + return 3; + } + } // end decodeToBytes + + + + + + /** + * Low-level access to decoding ASCII characters in + * the form of a byte array. Ignores GUNZIP option, if + * it's set. This is not generally a recommended method, + * although it is used internally as part of the decoding process. + * Special case: if len = 0, an empty array is returned. Still, + * if you need more speed and reduced memory footprint (and aren't + * gzipping), consider this method. + * + * @param source The Base64 encoded data + * @return decoded data + * @since 2.3.1 + */ + public static byte[] decode( byte[] source ){ + byte[] decoded = null; + try { + decoded = decode( source, 0, source.length, Base64.NO_OPTIONS ); + } catch( java.io.IOException ex ) { + assert false : "IOExceptions only come from GZipping, which is turned off: " + ex.getMessage(); + } + return decoded; + } + + + /** + * Low-level access to decoding ASCII characters in + * the form of a byte array. Ignores GUNZIP option, if + * it's set. This is not generally a recommended method, + * although it is used internally as part of the decoding process. + * Special case: if len = 0, an empty array is returned. Still, + * if you need more speed and reduced memory footprint (and aren't + * gzipping), consider this method. + * + * @param source The Base64 encoded data + * @param off The offset of where to begin decoding + * @param len The length of characters to decode + * @param options Can specify options such as alphabet type to use + * @return decoded data + * @throws java.io.IOException If bogus characters exist in source data + * @since 1.3 + */ + public static byte[] decode( byte[] source, int off, int len, int options ) + throws java.io.IOException { + + // Lots of error checking and exception throwing + if( source == null ){ + throw new NullPointerException( "Cannot decode null source array." ); + } // end if + if( off < 0 || off + len > source.length ){ + throw new IllegalArgumentException( String.format( + "Source array with length %d cannot have offset of %d and process %d bytes.", source.length, off, len ) ); + } // end if + + if( len == 0 ){ + return new byte[0]; + }else if( len < 4 ){ + throw new IllegalArgumentException( + "Base64-encoded string must have at least four characters, but length specified was " + len ); + } // end if + + byte[] DECODABET = getDecodabet( options ); + + int len34 = len * 3 / 4; // Estimate on array size + byte[] outBuff = new byte[ len34 ]; // Upper limit on size of output + int outBuffPosn = 0; // Keep track of where we're writing + + byte[] b4 = new byte[4]; // Four byte buffer from source, eliminating white space + int b4Posn = 0; // Keep track of four byte input buffer + int i = 0; // Source array counter + byte sbiCrop = 0; // Low seven bits (ASCII) of input + byte sbiDecode = 0; // Special value from DECODABET + + for( i = off; i < off+len; i++ ) { // Loop through source + + sbiCrop = (byte)(source[i] & 0x7f); // Only the low seven bits + sbiDecode = DECODABET[ sbiCrop ]; // Special value + + // White space, Equals sign, or legit Base64 character + // Note the values such as -5 and -9 in the + // DECODABETs at the top of the file. + if( sbiDecode >= WHITE_SPACE_ENC ) { + if( sbiDecode >= EQUALS_SIGN_ENC ) { + b4[ b4Posn++ ] = sbiCrop; // Save non-whitespace + if( b4Posn > 3 ) { // Time to decode? + outBuffPosn += decode4to3( b4, 0, outBuff, outBuffPosn, options ); + b4Posn = 0; + + // If that was the equals sign, break out of 'for' loop + if( sbiCrop == EQUALS_SIGN ) { + break; + } // end if: equals sign + } // end if: quartet built + } // end if: equals sign or better + } // end if: white space, equals sign or better + else { + // There's a bad input character in the Base64 stream. + throw new java.io.IOException( String.format( + "Bad Base64 input character '%c' in array position %d", source[i], i ) ); + } // end else: + } // each input character + + byte[] out = new byte[ outBuffPosn ]; + System.arraycopy( outBuff, 0, out, 0, outBuffPosn ); + return out; + } // end decode + + + + + /** + * Signal modified: + * Decodes data from Base64 notation, it does not detect gzip-compressed data. + * + * @param s the string to decode + * @return the decoded data + * @throws java.io.IOException If there is a problem + * @since 1.4 + */ + public static byte[] decode( String s ) throws java.io.IOException { + // Signal: We never use gzip, avoid trying to unzip. + // return decode( s, NO_OPTIONS ); + return decode( s, DONT_GUNZIP ); + } + + + public static byte[] decodeWithoutPadding(String source) throws java.io.IOException { + int padding = source.length() % 4; + + if (padding == 1) source = source + "="; + else if (padding == 2) source = source + "=="; + else if (padding == 3) source = source + "="; + + return decode(source); + } + + + + /** + * Decodes data from Base64 notation, automatically + * detecting gzip-compressed data and decompressing it. + * + * @param s the string to decode + * @param options encode options such as URL_SAFE + * @return the decoded data + * @throws java.io.IOException if there is an error + * @throws NullPointerException if s is null + * @since 1.4 + */ + public static byte[] decode( String s, int options ) throws java.io.IOException { + + if( s == null ){ + throw new NullPointerException( "Input string was null." ); + } // end if + + byte[] bytes; + try { + bytes = s.getBytes( PREFERRED_ENCODING ); + } // end try + catch( java.io.UnsupportedEncodingException uee ) { + bytes = s.getBytes(); + } // end catch + // + + // Decode + bytes = decode( bytes, 0, bytes.length, options ); + + // Check to see if it's gzip-compressed + // GZIP Magic Two-Byte Number: 0x8b1f (35615) + boolean dontGunzip = (options & DONT_GUNZIP) != 0; + if( (bytes != null) && (bytes.length >= 4) && (!dontGunzip) ) { + + int head = ((int)bytes[0] & 0xff) | ((bytes[1] << 8) & 0xff00); + if( java.util.zip.GZIPInputStream.GZIP_MAGIC == head ) { + java.io.ByteArrayInputStream bais = null; + java.util.zip.GZIPInputStream gzis = null; + java.io.ByteArrayOutputStream baos = null; + byte[] buffer = new byte[2048]; + int length = 0; + + try { + baos = new java.io.ByteArrayOutputStream(); + bais = new java.io.ByteArrayInputStream( bytes ); + gzis = new java.util.zip.GZIPInputStream( bais ); + + while( ( length = gzis.read( buffer ) ) >= 0 ) { + baos.write(buffer,0,length); + } // end while: reading input + + // No error? Get new bytes. + bytes = baos.toByteArray(); + + } // end try + catch( java.io.IOException e ) { + e.printStackTrace(); + // Just return originally-decoded bytes + } // end catch + finally { + try{ baos.close(); } catch( Exception e ){} + try{ gzis.close(); } catch( Exception e ){} + try{ bais.close(); } catch( Exception e ){} + } // end finally + + } // end if: gzipped + } // end if: bytes.length >= 2 + + return bytes; + } // end decode + + + + /** + * Attempts to decode Base64 data and deserialize a Java + * Object within. Returns null if there was an error. + * + * @param encodedObject The Base64 data to decode + * @return The decoded and deserialized object + * @throws NullPointerException if encodedObject is null + * @throws java.io.IOException if there is a general error + * @throws ClassNotFoundException if the decoded object is of a + * class that cannot be found by the JVM + * @since 1.5 + */ + public static Object decodeToObject( String encodedObject ) + throws java.io.IOException, java.lang.ClassNotFoundException { + return decodeToObject(encodedObject,NO_OPTIONS,null); + } + + + /** + * Attempts to decode Base64 data and deserialize a Java + * Object within. Returns null if there was an error. + * If loader is not null, it will be the class loader + * used when deserializing. + * + * @param encodedObject The Base64 data to decode + * @param options Various parameters related to decoding + * @param loader Optional class loader to use in deserializing classes. + * @return The decoded and deserialized object + * @throws NullPointerException if encodedObject is null + * @throws java.io.IOException if there is a general error + * @throws ClassNotFoundException if the decoded object is of a + * class that cannot be found by the JVM + * @since 2.3.4 + */ + public static Object decodeToObject( + String encodedObject, int options, final ClassLoader loader ) + throws java.io.IOException, java.lang.ClassNotFoundException { + + // Decode and gunzip if necessary + byte[] objBytes = decode( encodedObject, options ); + + java.io.ByteArrayInputStream bais = null; + java.io.ObjectInputStream ois = null; + Object obj = null; + + try { + bais = new java.io.ByteArrayInputStream( objBytes ); + + // If no custom class loader is provided, use Java's builtin OIS. + if( loader == null ){ + ois = new java.io.ObjectInputStream( bais ); + } // end if: no loader provided + + // Else make a customized object input stream that uses + // the provided class loader. + else { + ois = new java.io.ObjectInputStream(bais){ + @Override + public Class resolveClass(java.io.ObjectStreamClass streamClass) + throws java.io.IOException, ClassNotFoundException { + Class c = Class.forName(streamClass.getName(), false, loader); + if( c == null ){ + return super.resolveClass(streamClass); + } else { + return c; // Class loader knows of this class. + } // end else: not null + } // end resolveClass + }; // end ois + } // end else: no custom class loader + + obj = ois.readObject(); + } // end try + catch( java.io.IOException e ) { + throw e; // Catch and throw in order to execute finally{} + } // end catch + catch( java.lang.ClassNotFoundException e ) { + throw e; // Catch and throw in order to execute finally{} + } // end catch + finally { + try{ bais.close(); } catch( Exception e ){} + try{ ois.close(); } catch( Exception e ){} + } // end finally + + return obj; + } // end decodeObject + + + + /** + * Convenience method for encoding data to a file. + * + *

As of v 2.3, if there is a error, + * the method will throw an java.io.IOException. This is new to v2.3! + * In earlier versions, it just returned false, but + * in retrospect that's a pretty poor way to handle it.

+ * + * @param dataToEncode byte array of data to encode in base64 form + * @param filename Filename for saving encoded data + * @throws java.io.IOException if there is an error + * @throws NullPointerException if dataToEncode is null + * @since 2.1 + */ + public static void encodeToFile( byte[] dataToEncode, String filename ) + throws java.io.IOException { + + if( dataToEncode == null ){ + throw new NullPointerException( "Data to encode was null." ); + } // end iff + + Base64.OutputStream bos = null; + try { + bos = new Base64.OutputStream( + new java.io.FileOutputStream( filename ), Base64.ENCODE ); + bos.write( dataToEncode ); + } // end try + catch( java.io.IOException e ) { + throw e; // Catch and throw to execute finally{} block + } // end catch: java.io.IOException + finally { + try{ bos.close(); } catch( Exception e ){} + } // end finally + + } // end encodeToFile + + + /** + * Convenience method for decoding data to a file. + * + *

As of v 2.3, if there is a error, + * the method will throw an java.io.IOException. This is new to v2.3! + * In earlier versions, it just returned false, but + * in retrospect that's a pretty poor way to handle it.

+ * + * @param dataToDecode Base64-encoded data as a string + * @param filename Filename for saving decoded data + * @throws java.io.IOException if there is an error + * @since 2.1 + */ + public static void decodeToFile( String dataToDecode, String filename ) + throws java.io.IOException { + + Base64.OutputStream bos = null; + try{ + bos = new Base64.OutputStream( + new java.io.FileOutputStream( filename ), Base64.DECODE ); + bos.write( dataToDecode.getBytes( PREFERRED_ENCODING ) ); + } // end try + catch( java.io.IOException e ) { + throw e; // Catch and throw to execute finally{} block + } // end catch: java.io.IOException + finally { + try{ bos.close(); } catch( Exception e ){} + } // end finally + + } // end decodeToFile + + + + + /** + * Convenience method for reading a base64-encoded + * file and decoding it. + * + *

As of v 2.3, if there is a error, + * the method will throw an java.io.IOException. This is new to v2.3! + * In earlier versions, it just returned false, but + * in retrospect that's a pretty poor way to handle it.

+ * + * @param filename Filename for reading encoded data + * @return decoded byte array + * @throws java.io.IOException if there is an error + * @since 2.1 + */ + public static byte[] decodeFromFile( String filename ) + throws java.io.IOException { + + byte[] decodedData = null; + Base64.InputStream bis = null; + try + { + // Set up some useful variables + java.io.File file = new java.io.File( filename ); + byte[] buffer = null; + int length = 0; + int numBytes = 0; + + // Check for size of file + if( file.length() > Integer.MAX_VALUE ) + { + throw new java.io.IOException( "File is too big for this convenience method (" + file.length() + " bytes)." ); + } // end if: file too big for int index + buffer = new byte[ (int)file.length() ]; + + // Open a stream + bis = new Base64.InputStream( + new java.io.BufferedInputStream( + new java.io.FileInputStream( file ) ), Base64.DECODE ); + + // Read until done + while( ( numBytes = bis.read( buffer, length, 4096 ) ) >= 0 ) { + length += numBytes; + } // end while + + // Save in a variable to return + decodedData = new byte[ length ]; + System.arraycopy( buffer, 0, decodedData, 0, length ); + + } // end try + catch( java.io.IOException e ) { + throw e; // Catch and release to execute finally{} + } // end catch: java.io.IOException + finally { + try{ bis.close(); } catch( Exception e) {} + } // end finally + + return decodedData; + } // end decodeFromFile + + + + /** + * Convenience method for reading a binary file + * and base64-encoding it. + * + *

As of v 2.3, if there is a error, + * the method will throw an java.io.IOException. This is new to v2.3! + * In earlier versions, it just returned false, but + * in retrospect that's a pretty poor way to handle it.

+ * + * @param filename Filename for reading binary data + * @return base64-encoded string + * @throws java.io.IOException if there is an error + * @since 2.1 + */ + public static String encodeFromFile( String filename ) + throws java.io.IOException { + + String encodedData = null; + Base64.InputStream bis = null; + try + { + // Set up some useful variables + java.io.File file = new java.io.File( filename ); + byte[] buffer = new byte[ Math.max((int)(file.length() * 1.4),40) ]; // Need max() for math on small files (v2.2.1) + int length = 0; + int numBytes = 0; + + // Open a stream + bis = new Base64.InputStream( + new java.io.BufferedInputStream( + new java.io.FileInputStream( file ) ), Base64.ENCODE ); + + // Read until done + while( ( numBytes = bis.read( buffer, length, 4096 ) ) >= 0 ) { + length += numBytes; + } // end while + + // Save in a variable to return + encodedData = new String( buffer, 0, length, Base64.PREFERRED_ENCODING ); + + } // end try + catch( java.io.IOException e ) { + throw e; // Catch and release to execute finally{} + } // end catch: java.io.IOException + finally { + try{ bis.close(); } catch( Exception e) {} + } // end finally + + return encodedData; + } // end encodeFromFile + + /** + * Reads infile and encodes it to outfile. + * + * @param infile Input file + * @param outfile Output file + * @throws java.io.IOException if there is an error + * @since 2.2 + */ + public static void encodeFileToFile( String infile, String outfile ) + throws java.io.IOException { + + String encoded = Base64.encodeFromFile( infile ); + java.io.OutputStream out = null; + try{ + out = new java.io.BufferedOutputStream( + new java.io.FileOutputStream( outfile ) ); + out.write( encoded.getBytes("US-ASCII") ); // Strict, 7-bit output. + } // end try + catch( java.io.IOException e ) { + throw e; // Catch and release to execute finally{} + } // end catch + finally { + try { out.close(); } + catch( Exception ex ){} + } // end finally + } // end encodeFileToFile + + + /** + * Reads infile and decodes it to outfile. + * + * @param infile Input file + * @param outfile Output file + * @throws java.io.IOException if there is an error + * @since 2.2 + */ + public static void decodeFileToFile( String infile, String outfile ) + throws java.io.IOException { + + byte[] decoded = Base64.decodeFromFile( infile ); + java.io.OutputStream out = null; + try{ + out = new java.io.BufferedOutputStream( + new java.io.FileOutputStream( outfile ) ); + out.write( decoded ); + } // end try + catch( java.io.IOException e ) { + throw e; // Catch and release to execute finally{} + } // end catch + finally { + try { out.close(); } + catch( Exception ex ){} + } // end finally + } // end decodeFileToFile + + + /* ******** I N N E R C L A S S I N P U T S T R E A M ******** */ + + + + /** + * A {@link Base64.InputStream} will read data from another + * java.io.InputStream, given in the constructor, + * and encode/decode to/from Base64 notation on the fly. + * + * @see Base64 + * @since 1.3 + */ + public static class InputStream extends java.io.FilterInputStream { + + private boolean encode; // Encoding or decoding + private int position; // Current position in the buffer + private byte[] buffer; // Small buffer holding converted data + private int bufferLength; // Length of buffer (3 or 4) + private int numSigBytes; // Number of meaningful bytes in the buffer + private int lineLength; + private boolean breakLines; // Break lines at less than 80 characters + private int options; // Record options used to create the stream. + private byte[] decodabet; // Local copies to avoid extra method calls + + + /** + * Constructs a {@link Base64.InputStream} in DECODE mode. + * + * @param in the java.io.InputStream from which to read data. + * @since 1.3 + */ + public InputStream( java.io.InputStream in ) { + this( in, DECODE ); + } // end constructor + + + /** + * Constructs a {@link Base64.InputStream} in + * either ENCODE or DECODE mode. + *

+ * Valid options:

+         *   ENCODE or DECODE: Encode or Decode as data is read.
+         *   DO_BREAK_LINES: break lines at 76 characters
+         *     (only meaningful when encoding)
+         * 
+ *

+ * Example: new Base64.InputStream( in, Base64.DECODE ) + * + * + * @param in the java.io.InputStream from which to read data. + * @param options Specified options + * @see Base64#ENCODE + * @see Base64#DECODE + * @see Base64#DO_BREAK_LINES + * @since 2.0 + */ + public InputStream( java.io.InputStream in, int options ) { + + super( in ); + this.options = options; // Record for later + this.breakLines = (options & DO_BREAK_LINES) > 0; + this.encode = (options & ENCODE) > 0; + this.bufferLength = encode ? 4 : 3; + this.buffer = new byte[ bufferLength ]; + this.position = -1; + this.lineLength = 0; + this.decodabet = getDecodabet(options); + } // end constructor + + /** + * Reads enough of the input stream to convert + * to/from Base64 and returns the next byte. + * + * @return next byte + * @since 1.3 + */ + @Override + public int read() throws java.io.IOException { + + // Do we need to get data? + if( position < 0 ) { + if( encode ) { + byte[] b3 = new byte[3]; + int numBinaryBytes = 0; + for( int i = 0; i < 3; i++ ) { + int b = in.read(); + + // If end of stream, b is -1. + if( b >= 0 ) { + b3[i] = (byte)b; + numBinaryBytes++; + } else { + break; // out of for loop + } // end else: end of stream + + } // end for: each needed input byte + + if( numBinaryBytes > 0 ) { + encode3to4( b3, 0, numBinaryBytes, buffer, 0, options ); + position = 0; + numSigBytes = 4; + } // end if: got data + else { + return -1; // Must be end of stream + } // end else + } // end if: encoding + + // Else decoding + else { + byte[] b4 = new byte[4]; + int i = 0; + for( i = 0; i < 4; i++ ) { + // Read four "meaningful" bytes: + int b = 0; + do{ b = in.read(); } + while( b >= 0 && decodabet[ b & 0x7f ] <= WHITE_SPACE_ENC ); + + if( b < 0 ) { + break; // Reads a -1 if end of stream + } // end if: end of stream + + b4[i] = (byte)b; + } // end for: each needed input byte + + if( i == 4 ) { + numSigBytes = decode4to3( b4, 0, buffer, 0, options ); + position = 0; + } // end if: got four characters + else if( i == 0 ){ + return -1; + } // end else if: also padded correctly + else { + // Must have broken out from above. + throw new java.io.IOException( "Improperly padded Base64 input." ); + } // end + + } // end else: decode + } // end else: get data + + // Got data? + if( position >= 0 ) { + // End of relevant data? + if( /*!encode &&*/ position >= numSigBytes ){ + return -1; + } // end if: got data + + if( encode && breakLines && lineLength >= MAX_LINE_LENGTH ) { + lineLength = 0; + return '\n'; + } // end if + else { + lineLength++; // This isn't important when decoding + // but throwing an extra "if" seems + // just as wasteful. + + int b = buffer[ position++ ]; + + if( position >= bufferLength ) { + position = -1; + } // end if: end + + return b & 0xFF; // This is how you "cast" a byte that's + // intended to be unsigned. + } // end else + } // end if: position >= 0 + + // Else error + else { + throw new java.io.IOException( "Error in Base64 code reading stream." ); + } // end else + } // end read + + + /** + * Calls {@link #read()} repeatedly until the end of stream + * is reached or len bytes are read. + * Returns number of bytes read into array or -1 if + * end of stream is encountered. + * + * @param dest array to hold values + * @param off offset for array + * @param len max number of bytes to read into array + * @return bytes read into array or -1 if end of stream is encountered. + * @since 1.3 + */ + @Override + public int read( byte[] dest, int off, int len ) + throws java.io.IOException { + int i; + int b; + for( i = 0; i < len; i++ ) { + b = read(); + + if( b >= 0 ) { + dest[off + i] = (byte) b; + } + else if( i == 0 ) { + return -1; + } + else { + break; // Out of 'for' loop + } // Out of 'for' loop + } // end for: each byte read + return i; + } // end read + + } // end inner class InputStream + + + + + + + /* ******** I N N E R C L A S S O U T P U T S T R E A M ******** */ + + + + /** + * A {@link Base64.OutputStream} will write data to another + * java.io.OutputStream, given in the constructor, + * and encode/decode to/from Base64 notation on the fly. + * + * @see Base64 + * @since 1.3 + */ + public static class OutputStream extends java.io.FilterOutputStream { + + private boolean encode; + private int position; + private byte[] buffer; + private int bufferLength; + private int lineLength; + private boolean breakLines; + private byte[] b4; // Scratch used in a few places + private boolean suspendEncoding; + private int options; // Record for later + private byte[] decodabet; // Local copies to avoid extra method calls + + /** + * Constructs a {@link Base64.OutputStream} in ENCODE mode. + * + * @param out the java.io.OutputStream to which data will be written. + * @since 1.3 + */ + public OutputStream( java.io.OutputStream out ) { + this( out, ENCODE ); + } // end constructor + + + /** + * Constructs a {@link Base64.OutputStream} in + * either ENCODE or DECODE mode. + *

+ * Valid options:

+         *   ENCODE or DECODE: Encode or Decode as data is read.
+         *   DO_BREAK_LINES: don't break lines at 76 characters
+         *     (only meaningful when encoding)
+         * 
+ *

+ * Example: new Base64.OutputStream( out, Base64.ENCODE ) + * + * @param out the java.io.OutputStream to which data will be written. + * @param options Specified options. + * @see Base64#ENCODE + * @see Base64#DECODE + * @see Base64#DO_BREAK_LINES + * @since 1.3 + */ + public OutputStream( java.io.OutputStream out, int options ) { + super( out ); + this.breakLines = (options & DO_BREAK_LINES) != 0; + this.encode = (options & ENCODE) != 0; + this.bufferLength = encode ? 3 : 4; + this.buffer = new byte[ bufferLength ]; + this.position = 0; + this.lineLength = 0; + this.suspendEncoding = false; + this.b4 = new byte[4]; + this.options = options; + this.decodabet = getDecodabet(options); + } // end constructor + + + /** + * Writes the byte to the output stream after + * converting to/from Base64 notation. + * When encoding, bytes are buffered three + * at a time before the output stream actually + * gets a write() call. + * When decoding, bytes are buffered four + * at a time. + * + * @param theByte the byte to write + * @since 1.3 + */ + @Override + public void write(int theByte) + throws java.io.IOException { + // Encoding suspended? + if( suspendEncoding ) { + this.out.write( theByte ); + return; + } // end if: supsended + + // Encode? + if( encode ) { + buffer[ position++ ] = (byte)theByte; + if( position >= bufferLength ) { // Enough to encode. + + this.out.write( encode3to4( b4, buffer, bufferLength, options ) ); + + lineLength += 4; + if( breakLines && lineLength >= MAX_LINE_LENGTH ) { + this.out.write( NEW_LINE ); + lineLength = 0; + } // end if: end of line + + position = 0; + } // end if: enough to output + } // end if: encoding + + // Else, Decoding + else { + // Meaningful Base64 character? + if( decodabet[ theByte & 0x7f ] > WHITE_SPACE_ENC ) { + buffer[ position++ ] = (byte)theByte; + if( position >= bufferLength ) { // Enough to output. + + int len = Base64.decode4to3( buffer, 0, b4, 0, options ); + out.write( b4, 0, len ); + position = 0; + } // end if: enough to output + } // end if: meaningful base64 character + else if( decodabet[ theByte & 0x7f ] != WHITE_SPACE_ENC ) { + throw new java.io.IOException( "Invalid character in Base64 data." ); + } // end else: not white space either + } // end else: decoding + } // end write + + + + /** + * Calls {@link #write(int)} repeatedly until len + * bytes are written. + * + * @param theBytes array from which to read bytes + * @param off offset for array + * @param len max number of bytes to read into array + * @since 1.3 + */ + @Override + public void write( byte[] theBytes, int off, int len ) + throws java.io.IOException { + // Encoding suspended? + if( suspendEncoding ) { + this.out.write( theBytes, off, len ); + return; + } // end if: supsended + + for( int i = 0; i < len; i++ ) { + write( theBytes[ off + i ] ); + } // end for: each byte written + + } // end write + + + + /** + * Method added by PHIL. [Thanks, PHIL. -Rob] + * This pads the buffer without closing the stream. + * @throws java.io.IOException if there's an error. + */ + public void flushBase64() throws java.io.IOException { + if( position > 0 ) { + if( encode ) { + out.write( encode3to4( b4, buffer, position, options ) ); + position = 0; + } // end if: encoding + else { + throw new java.io.IOException( "Base64 input not properly padded." ); + } // end else: decoding + } // end if: buffer partially full + + } // end flush + + + /** + * Flushes and closes (I think, in the superclass) the stream. + * + * @since 1.3 + */ + @Override + public void close() throws java.io.IOException { + // 1. Ensure that pending characters are written + flushBase64(); + + // 2. Actually close the stream + // Base class both flushes and closes. + super.close(); + + buffer = null; + out = null; + } // end close + + + + /** + * Suspends encoding of the stream. + * May be helpful if you need to embed a piece of + * base64-encoded data in a stream. + * + * @throws java.io.IOException if there's an error flushing + * @since 1.5.1 + */ + public void suspendEncoding() throws java.io.IOException { + flushBase64(); + this.suspendEncoding = true; + } // end suspendEncoding + + + /** + * Resumes encoding of the stream. + * May be helpful if you need to embed a piece of + * base64-encoded data in a stream. + * + * @since 1.5.1 + */ + public void resumeEncoding() { + this.suspendEncoding = false; + } // end resumeEncoding + + + + } // end inner class OutputStream + + +} // end class Base64 diff --git a/libsignal/service/src/test/java/org/whispersystems/signalservice/api/crypto/AttachmentCipherTest.java b/libsignal/service/src/test/java/org/whispersystems/signalservice/api/crypto/AttachmentCipherTest.java new file mode 100644 index 000000000..8c273ed76 --- /dev/null +++ b/libsignal/service/src/test/java/org/whispersystems/signalservice/api/crypto/AttachmentCipherTest.java @@ -0,0 +1,244 @@ +package org.whispersystems.signalservice.api.crypto; + +import junit.framework.TestCase; + +import org.conscrypt.Conscrypt; +import org.whispersystems.libsignal.InvalidMessageException; +import org.whispersystems.libsignal.kdf.HKDFv3; +import org.whispersystems.signalservice.internal.util.Util; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.security.Security; +import java.util.Arrays; + +public class AttachmentCipherTest extends TestCase { + + static { + Security.insertProviderAt(Conscrypt.newProvider(), 1); + } + + public void test_attachment_encryptDecrypt() throws IOException, InvalidMessageException { + byte[] key = Util.getSecretBytes(64); + byte[] plaintextInput = "Peter Parker".getBytes(); + EncryptResult encryptResult = encryptData(plaintextInput, key); + File cipherFile = writeToFile(encryptResult.ciphertext); + InputStream inputStream = AttachmentCipherInputStream.createForAttachment(cipherFile, plaintextInput.length, key, encryptResult.digest); + byte[] plaintextOutput = readInputStreamFully(inputStream); + + assertTrue(Arrays.equals(plaintextInput, plaintextOutput)); + + cipherFile.delete(); + } + + public void test_attachment_encryptDecryptEmpty() throws IOException, InvalidMessageException { + byte[] key = Util.getSecretBytes(64); + byte[] plaintextInput = "".getBytes(); + EncryptResult encryptResult = encryptData(plaintextInput, key); + File cipherFile = writeToFile(encryptResult.ciphertext); + InputStream inputStream = AttachmentCipherInputStream.createForAttachment(cipherFile, plaintextInput.length, key, encryptResult.digest); + byte[] plaintextOutput = readInputStreamFully(inputStream); + + assertTrue(Arrays.equals(plaintextInput, plaintextOutput)); + + cipherFile.delete(); + } + + public void test_attachment_decryptFailOnBadKey() throws IOException{ + File cipherFile = null; + boolean hitCorrectException = false; + + try { + byte[] key = Util.getSecretBytes(64); + byte[] plaintextInput = "Gwen Stacy".getBytes(); + EncryptResult encryptResult = encryptData(plaintextInput, key); + byte[] badKey = new byte[64]; + + cipherFile = writeToFile(encryptResult.ciphertext); + + AttachmentCipherInputStream.createForAttachment(cipherFile, plaintextInput.length, badKey, encryptResult.digest); + } catch (InvalidMessageException e) { + hitCorrectException = true; + } finally { + if (cipherFile != null) { + cipherFile.delete(); + } + } + + assertTrue(hitCorrectException); + } + + public void test_attachment_decryptFailOnBadDigest() throws IOException{ + File cipherFile = null; + boolean hitCorrectException = false; + + try { + byte[] key = Util.getSecretBytes(64); + byte[] plaintextInput = "Mary Jane Watson".getBytes(); + EncryptResult encryptResult = encryptData(plaintextInput, key); + byte[] badDigest = new byte[32]; + + cipherFile = writeToFile(encryptResult.ciphertext); + + AttachmentCipherInputStream.createForAttachment(cipherFile, plaintextInput.length, key, badDigest); + } catch (InvalidMessageException e) { + hitCorrectException = true; + } finally { + if (cipherFile != null) { + cipherFile.delete(); + } + } + + assertTrue(hitCorrectException); + } + + public void test_attachment_decryptFailOnNullDigest() throws IOException{ + File cipherFile = null; + boolean hitCorrectException = false; + + try { + byte[] key = Util.getSecretBytes(64); + byte[] plaintextInput = "Aunt May".getBytes(); + EncryptResult encryptResult = encryptData(plaintextInput, key); + + cipherFile = writeToFile(encryptResult.ciphertext); + + AttachmentCipherInputStream.createForAttachment(cipherFile, plaintextInput.length, key, null); + } catch (InvalidMessageException e) { + hitCorrectException = true; + } finally { + if (cipherFile != null) { + cipherFile.delete(); + } + } + + assertTrue(hitCorrectException); + } + + public void test_attachment_decryptFailOnBadMac() throws IOException { + File cipherFile = null; + boolean hitCorrectException = false; + + try { + byte[] key = Util.getSecretBytes(64); + byte[] plaintextInput = "Uncle Ben".getBytes(); + EncryptResult encryptResult = encryptData(plaintextInput, key); + byte[] badMacCiphertext = Arrays.copyOf(encryptResult.ciphertext, encryptResult.ciphertext.length); + + badMacCiphertext[badMacCiphertext.length - 1] = 0; + + cipherFile = writeToFile(badMacCiphertext); + + AttachmentCipherInputStream.createForAttachment(cipherFile, plaintextInput.length, key, encryptResult.digest); + } catch (InvalidMessageException e) { + hitCorrectException = true; + } finally { + if (cipherFile != null) { + cipherFile.delete(); + } + } + + assertTrue(hitCorrectException); + } + + public void test_sticker_encryptDecrypt() throws IOException, InvalidMessageException { + byte[] packKey = Util.getSecretBytes(32); + byte[] plaintextInput = "Peter Parker".getBytes(); + EncryptResult encryptResult = encryptData(plaintextInput, expandPackKey(packKey)); + InputStream inputStream = AttachmentCipherInputStream.createForStickerData(encryptResult.ciphertext, packKey); + byte[] plaintextOutput = readInputStreamFully(inputStream); + + assertTrue(Arrays.equals(plaintextInput, plaintextOutput)); + } + + public void test_sticker_encryptDecryptEmpty() throws IOException, InvalidMessageException { + byte[] packKey = Util.getSecretBytes(32); + byte[] plaintextInput = "".getBytes(); + EncryptResult encryptResult = encryptData(plaintextInput, expandPackKey(packKey)); + InputStream inputStream = AttachmentCipherInputStream.createForStickerData(encryptResult.ciphertext, packKey); + byte[] plaintextOutput = readInputStreamFully(inputStream); + + assertTrue(Arrays.equals(plaintextInput, plaintextOutput)); + } + + public void test_sticker_decryptFailOnBadKey() throws IOException{ + boolean hitCorrectException = false; + + try { + byte[] packKey = Util.getSecretBytes(32); + byte[] plaintextInput = "Gwen Stacy".getBytes(); + EncryptResult encryptResult = encryptData(plaintextInput, expandPackKey(packKey)); + byte[] badPackKey = new byte[32]; + + AttachmentCipherInputStream.createForStickerData(encryptResult.ciphertext, badPackKey); + } catch (InvalidMessageException e) { + hitCorrectException = true; + } + + assertTrue(hitCorrectException); + } + + public void test_sticker_decryptFailOnBadMac() throws IOException { + boolean hitCorrectException = false; + + try { + byte[] packKey = Util.getSecretBytes(32); + byte[] plaintextInput = "Uncle Ben".getBytes(); + EncryptResult encryptResult = encryptData(plaintextInput, expandPackKey(packKey)); + byte[] badMacCiphertext = Arrays.copyOf(encryptResult.ciphertext, encryptResult.ciphertext.length); + + badMacCiphertext[badMacCiphertext.length - 1] = 0; + + AttachmentCipherInputStream.createForStickerData(badMacCiphertext, packKey); + } catch (InvalidMessageException e) { + hitCorrectException = true; + } + + assertTrue(hitCorrectException); + } + + private static EncryptResult encryptData(byte[] data, byte[] keyMaterial) throws IOException { + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + AttachmentCipherOutputStream encryptStream = new AttachmentCipherOutputStream(keyMaterial, outputStream); + + encryptStream.write(data); + encryptStream.flush(); + encryptStream.close(); + + return new EncryptResult(outputStream.toByteArray(), encryptStream.getTransmittedDigest()); + } + + private static File writeToFile(byte[] data) throws IOException { + File file = File.createTempFile("temp", ".data"); + OutputStream outputStream = new FileOutputStream(file); + + outputStream.write(data); + outputStream.close(); + + return file; + } + + private static byte[] readInputStreamFully(InputStream inputStream) throws IOException { + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + Util.copy(inputStream, outputStream); + return outputStream.toByteArray(); + } + + private static byte[] expandPackKey(byte[] shortKey) { + return new HKDFv3().deriveSecrets(shortKey, "Sticker Pack".getBytes(), 64); + } + + private static class EncryptResult { + final byte[] ciphertext; + final byte[] digest; + + private EncryptResult(byte[] ciphertext, byte[] digest) { + this.ciphertext = ciphertext; + this.digest = digest; + } + } +} diff --git a/libsignal/service/src/test/java/org/whispersystems/signalservice/api/crypto/ProfileCipherTest.java b/libsignal/service/src/test/java/org/whispersystems/signalservice/api/crypto/ProfileCipherTest.java new file mode 100644 index 000000000..ab0b0ec50 --- /dev/null +++ b/libsignal/service/src/test/java/org/whispersystems/signalservice/api/crypto/ProfileCipherTest.java @@ -0,0 +1,60 @@ +package org.whispersystems.signalservice.api.crypto; + + +import junit.framework.TestCase; + +import org.conscrypt.Conscrypt; +import org.whispersystems.signalservice.internal.util.Util; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.security.Security; + +public class ProfileCipherTest extends TestCase { + + static { + Security.insertProviderAt(Conscrypt.newProvider(), 1); + } + + public void testEncryptDecrypt() throws InvalidCiphertextException { + byte[] key = Util.getSecretBytes(32); + ProfileCipher cipher = new ProfileCipher(key); + byte[] name = cipher.encryptName("Clement Duval".getBytes(), 26); + byte[] plaintext = cipher.decryptName(name); + assertEquals(new String(plaintext), "Clement Duval"); + } + + public void testEmpty() throws Exception { + byte[] key = Util.getSecretBytes(32); + ProfileCipher cipher = new ProfileCipher(key); + byte[] name = cipher.encryptName("".getBytes(), 26); + byte[] plaintext = cipher.decryptName(name); + + assertEquals(plaintext.length, 0); + } + + public void testStreams() throws Exception { + byte[] key = Util.getSecretBytes(32); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + ProfileCipherOutputStream out = new ProfileCipherOutputStream(baos, key); + + out.write("This is an avatar".getBytes()); + out.flush(); + out.close(); + + ByteArrayInputStream bais = new ByteArrayInputStream(baos.toByteArray()); + ProfileCipherInputStream in = new ProfileCipherInputStream(bais, key); + + ByteArrayOutputStream result = new ByteArrayOutputStream(); + byte[] buffer = new byte[2048]; + + int read; + + while ((read = in.read(buffer)) != -1) { + result.write(buffer, 0, read); + } + + assertEquals(new String(result.toByteArray()), "This is an avatar"); + } + +} diff --git a/libsignal/service/src/test/java/org/whispersystems/signalservice/api/crypto/SigningCertificateTest.java b/libsignal/service/src/test/java/org/whispersystems/signalservice/api/crypto/SigningCertificateTest.java new file mode 100644 index 000000000..9c2d69c63 --- /dev/null +++ b/libsignal/service/src/test/java/org/whispersystems/signalservice/api/crypto/SigningCertificateTest.java @@ -0,0 +1,83 @@ +package org.whispersystems.signalservice.api.crypto; + +import junit.framework.TestCase; + +import org.conscrypt.Conscrypt; +import org.whispersystems.signalservice.internal.contacts.crypto.SigningCertificate; +import org.whispersystems.util.Base64; + +import java.io.IOException; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.Security; +import java.security.SignatureException; +import java.security.cert.CertPathValidatorException; +import java.security.cert.CertificateException; + +public class SigningCertificateTest extends TestCase { + + static { + Security.insertProviderAt(Conscrypt.newProvider(), 1); + } + + public void testGoodSignature() throws CertificateException, NoSuchAlgorithmException, IOException, KeyStoreException, CertPathValidatorException, SignatureException { + String certificateChain = "-----BEGIN%20CERTIFICATE-----%0AMIIEoTCCAwmgAwIBAgIJANEHdl0yo7CWMA0GCSqGSIb3DQEBCwUAMH4xCzAJBgNV%0ABAYTAlVTMQswCQYDVQQIDAJDQTEUMBIGA1UEBwwLU2FudGEgQ2xhcmExGjAYBgNV%0ABAoMEUludGVsIENvcnBvcmF0aW9uMTAwLgYDVQQDDCdJbnRlbCBTR1ggQXR0ZXN0%0AYXRpb24gUmVwb3J0IFNpZ25pbmcgQ0EwHhcNMTYxMTIyMDkzNjU4WhcNMjYxMTIw%0AMDkzNjU4WjB7MQswCQYDVQQGEwJVUzELMAkGA1UECAwCQ0ExFDASBgNVBAcMC1Nh%0AbnRhIENsYXJhMRowGAYDVQQKDBFJbnRlbCBDb3Jwb3JhdGlvbjEtMCsGA1UEAwwk%0ASW50ZWwgU0dYIEF0dGVzdGF0aW9uIFJlcG9ydCBTaWduaW5nMIIBIjANBgkqhkiG%0A9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqXot4OZuphR8nudFrAFiaGxxkgma/Es/BA%2Bt%0AbeCTUR106AL1ENcWA4FX3K%2BE9BBL0/7X5rj5nIgX/R/1ubhkKWw9gfqPG3KeAtId%0Acv/uTO1yXv50vqaPvE1CRChvzdS/ZEBqQ5oVvLTPZ3VEicQjlytKgN9cLnxbwtuv%0ALUK7eyRPfJW/ksddOzP8VBBniolYnRCD2jrMRZ8nBM2ZWYwnXnwYeOAHV%2BW9tOhA%0AImwRwKF/95yAsVwd21ryHMJBcGH70qLagZ7Ttyt%2B%2BqO/6%2BKAXJuKwZqjRlEtSEz8%0AgZQeFfVYgcwSfo96oSMAzVr7V0L6HSDLRnpb6xxmbPdqNol4tQIDAQABo4GkMIGh%0AMB8GA1UdIwQYMBaAFHhDe3amfrzQr35CN%2Bs1fDuHAVE8MA4GA1UdDwEB/wQEAwIG%0AwDAMBgNVHRMBAf8EAjAAMGAGA1UdHwRZMFcwVaBToFGGT2h0dHA6Ly90cnVzdGVk%0Ac2VydmljZXMuaW50ZWwuY29tL2NvbnRlbnQvQ1JML1NHWC9BdHRlc3RhdGlvblJl%0AcG9ydFNpZ25pbmdDQS5jcmwwDQYJKoZIhvcNAQELBQADggGBAGcIthtcK9IVRz4r%0ARq%2BZKE%2B7k50/OxUsmW8aavOzKb0iCx07YQ9rzi5nU73tME2yGRLzhSViFs/LpFa9%0AlpQL6JL1aQwmDR74TxYGBAIi5f4I5TJoCCEqRHz91kpG6Uvyn2tLmnIdJbPE4vYv%0AWLrtXXfFBSSPD4Afn7%2B3/XUggAlc7oCTizOfbbtOFlYA4g5KcYgS1J2ZAeMQqbUd%0AZseZCcaZZZn65tdqee8UXZlDvx0%2BNdO0LR%2B5pFy%2BjuM0wWbu59MvzcmTXbjsi7HY%0A6zd53Yq5K244fwFHRQ8eOB0IWB%2B4PfM7FeAApZvlfqlKOlLcZL2uyVmzRkyR5yW7%0A2uo9mehX44CiPJ2fse9Y6eQtcfEhMPkmHXI01sN%2BKwPbpA39%2BxOsStjhP9N1Y1a2%0AtQAVo%2ByVgLgV2Hws73Fc0o3wC78qPEA%2Bv2aRs/Be3ZFDgDyghc/1fgU%2B7C%2BP6kbq%0Ad4poyb6IW8KCJbxfMJvkordNOgOUUxndPHEi/tb/U7uLjLOgPA%3D%3D%0A-----END%20CERTIFICATE-----%0A-----BEGIN%20CERTIFICATE-----%0AMIIFSzCCA7OgAwIBAgIJANEHdl0yo7CUMA0GCSqGSIb3DQEBCwUAMH4xCzAJBgNV%0ABAYTAlVTMQswCQYDVQQIDAJDQTEUMBIGA1UEBwwLU2FudGEgQ2xhcmExGjAYBgNV%0ABAoMEUludGVsIENvcnBvcmF0aW9uMTAwLgYDVQQDDCdJbnRlbCBTR1ggQXR0ZXN0%0AYXRpb24gUmVwb3J0IFNpZ25pbmcgQ0EwIBcNMTYxMTE0MTUzNzMxWhgPMjA0OTEy%0AMzEyMzU5NTlaMH4xCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJDQTEUMBIGA1UEBwwL%0AU2FudGEgQ2xhcmExGjAYBgNVBAoMEUludGVsIENvcnBvcmF0aW9uMTAwLgYDVQQD%0ADCdJbnRlbCBTR1ggQXR0ZXN0YXRpb24gUmVwb3J0IFNpZ25pbmcgQ0EwggGiMA0G%0ACSqGSIb3DQEBAQUAA4IBjwAwggGKAoIBgQCfPGR%2BtXc8u1EtJzLA10Feu1Wg%2Bp7e%0ALmSRmeaCHbkQ1TF3Nwl3RmpqXkeGzNLd69QUnWovYyVSndEMyYc3sHecGgfinEeh%0ArgBJSEdsSJ9FpaFdesjsxqzGRa20PYdnnfWcCTvFoulpbFR4VBuXnnVLVzkUvlXT%0AL/TAnd8nIZk0zZkFJ7P5LtePvykkar7LcSQO85wtcQe0R1Raf/sQ6wYKaKmFgCGe%0ANpEJUmg4ktal4qgIAxk%2BQHUxQE42sxViN5mqglB0QJdUot/o9a/V/mMeH8KvOAiQ%0AbyinkNndn%2BBgk5sSV5DFgF0DffVqmVMblt5p3jPtImzBIH0QQrXJq39AT8cRwP5H%0AafuVeLHcDsRp6hol4P%2BZFIhu8mmbI1u0hH3W/0C2BuYXB5PC%2B5izFFh/nP0lc2Lf%0A6rELO9LZdnOhpL1ExFOq9H/B8tPQ84T3Sgb4nAifDabNt/zu6MmCGo5U8lwEFtGM%0ARoOaX4AS%2B909x00lYnmtwsDVWv9vBiJCXRsCAwEAAaOByTCBxjBgBgNVHR8EWTBX%0AMFWgU6BRhk9odHRwOi8vdHJ1c3RlZHNlcnZpY2VzLmludGVsLmNvbS9jb250ZW50%0AL0NSTC9TR1gvQXR0ZXN0YXRpb25SZXBvcnRTaWduaW5nQ0EuY3JsMB0GA1UdDgQW%0ABBR4Q3t2pn680K9%2BQjfrNXw7hwFRPDAfBgNVHSMEGDAWgBR4Q3t2pn680K9%2BQjfr%0ANXw7hwFRPDAOBgNVHQ8BAf8EBAMCAQYwEgYDVR0TAQH/BAgwBgEB/wIBADANBgkq%0AhkiG9w0BAQsFAAOCAYEAeF8tYMXICvQqeXYQITkV2oLJsp6J4JAqJabHWxYJHGir%0AIEqucRiJSSx%2BHjIJEUVaj8E0QjEud6Y5lNmXlcjqRXaCPOqK0eGRz6hi%2BripMtPZ%0AsFNaBwLQVV905SDjAzDzNIDnrcnXyB4gcDFCvwDFKKgLRjOB/WAqgscDUoGq5ZVi%0AzLUzTqiQPmULAQaB9c6Oti6snEFJiCQ67JLyW/E83/frzCmO5Ru6WjU4tmsmy8Ra%0AUd4APK0wZTGtfPXU7w%2BIBdG5Ez0kE1qzxGQaL4gINJ1zMyleDnbuS8UicjJijvqA%0A152Sq049ESDz%2B1rRGc2NVEqh1KaGXmtXvqxXcTB%2BLjy5Bw2ke0v8iGngFBPqCTVB%0A3op5KBG3RjbF6RRSzwzuWfL7QErNC8WEy5yDVARzTA5%2BxmBc388v9Dm21HGfcC8O%0ADD%2BgT9sSpssq0ascmvH49MOgjt1yoysLtdCtJW/9FZpoOypaHx0R%2BmJTLwPXVMrv%0ADaVzWh5aiEx%2BidkSGMnX%0A-----END%20CERTIFICATE-----%0A"; + String signature = "Kn2Ya2T039qvEWIzIQeSksNyyCQIkcVjciClcp3a6C766dJANXxLLIn6CfyvUZddMtePrTOLpC2e5QTQxB4RwtWmFfr7nxRdFUtA3dH2DAQL5DqqlmPv46ZWSPfiiOXUsu8vNgX3Z4Znt4Q+dIPIquNPY8ZmiAcpKR7n2K3QtabgOnJ2EyngabY3LMQTtriXbZjpl53ynhVhV1rciMdvMaTz4DUYt7gKi+KeNd3CBFSev+eTgYPC3em96J/3bfVR+wC5m3JGbIBCrwAsbO05JkiNIMck3s+p4d/hwiABR75EplxaWmGgIm6VvUKtGhdJ/cNrmF0nxMX6Vi6N2WaLTA=="; + String signatureBody = "{\"id\":\"287419896494669543891634765983074535548\",\"timestamp\":\"2019-03-11T20:01:21.658293\",\"version\":3,\"isvEnclaveQuoteStatus\":\"OK\",\"isvEnclaveQuoteBody\":\"AgAAADILAAAIAAcAAAAAAPiLWcRSSA3shraxepsGV9qF4zYUPJgE42ZZZXS2G9zaBQUCBP//AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAAAAAAAAAHAAAAAAAAAM1s/DQpN7I7G907v5chqlYVrJ/1CnXFUn1EHNMnaCbJAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADrzm117Qj8NlEllyDkV4Pae4UgsPjgVXtAA5UsG90gVgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACHgz6GaO6bkxfPLBYcR5rEf9Itrt81OEanXteSMcd/BQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\"}"; + + KeyStore keyStore = KeyStore.getInstance("JKS"); + keyStore.load(getClass().getResourceAsStream("/ias.jks"), "whisper".toCharArray()); + + SigningCertificate certificate = new SigningCertificate(certificateChain, keyStore); + + certificate.verifySignature(signatureBody, signature); + } + + public void testBadSignature() throws CertificateException, NoSuchAlgorithmException, IOException, KeyStoreException, CertPathValidatorException, SignatureException { + String certificateChain = "-----BEGIN%20CERTIFICATE-----%0AMIIEoTCCAwmgAwIBAgIJANEHdl0yo7CWMA0GCSqGSIb3DQEBCwUAMH4xCzAJBgNV%0ABAYTAlVTMQswCQYDVQQIDAJDQTEUMBIGA1UEBwwLU2FudGEgQ2xhcmExGjAYBgNV%0ABAoMEUludGVsIENvcnBvcmF0aW9uMTAwLgYDVQQDDCdJbnRlbCBTR1ggQXR0ZXN0%0AYXRpb24gUmVwb3J0IFNpZ25pbmcgQ0EwHhcNMTYxMTIyMDkzNjU4WhcNMjYxMTIw%0AMDkzNjU4WjB7MQswCQYDVQQGEwJVUzELMAkGA1UECAwCQ0ExFDASBgNVBAcMC1Nh%0AbnRhIENsYXJhMRowGAYDVQQKDBFJbnRlbCBDb3Jwb3JhdGlvbjEtMCsGA1UEAwwk%0ASW50ZWwgU0dYIEF0dGVzdGF0aW9uIFJlcG9ydCBTaWduaW5nMIIBIjANBgkqhkiG%0A9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqXot4OZuphR8nudFrAFiaGxxkgma/Es/BA%2Bt%0AbeCTUR106AL1ENcWA4FX3K%2BE9BBL0/7X5rj5nIgX/R/1ubhkKWw9gfqPG3KeAtId%0Acv/uTO1yXv50vqaPvE1CRChvzdS/ZEBqQ5oVvLTPZ3VEicQjlytKgN9cLnxbwtuv%0ALUK7eyRPfJW/ksddOzP8VBBniolYnRCD2jrMRZ8nBM2ZWYwnXnwYeOAHV%2BW9tOhA%0AImwRwKF/95yAsVwd21ryHMJBcGH70qLagZ7Ttyt%2B%2BqO/6%2BKAXJuKwZqjRlEtSEz8%0AgZQeFfVYgcwSfo96oSMAzVr7V0L6HSDLRnpb6xxmbPdqNol4tQIDAQABo4GkMIGh%0AMB8GA1UdIwQYMBaAFHhDe3amfrzQr35CN%2Bs1fDuHAVE8MA4GA1UdDwEB/wQEAwIG%0AwDAMBgNVHRMBAf8EAjAAMGAGA1UdHwRZMFcwVaBToFGGT2h0dHA6Ly90cnVzdGVk%0Ac2VydmljZXMuaW50ZWwuY29tL2NvbnRlbnQvQ1JML1NHWC9BdHRlc3RhdGlvblJl%0AcG9ydFNpZ25pbmdDQS5jcmwwDQYJKoZIhvcNAQELBQADggGBAGcIthtcK9IVRz4r%0ARq%2BZKE%2B7k50/OxUsmW8aavOzKb0iCx07YQ9rzi5nU73tME2yGRLzhSViFs/LpFa9%0AlpQL6JL1aQwmDR74TxYGBAIi5f4I5TJoCCEqRHz91kpG6Uvyn2tLmnIdJbPE4vYv%0AWLrtXXfFBSSPD4Afn7%2B3/XUggAlc7oCTizOfbbtOFlYA4g5KcYgS1J2ZAeMQqbUd%0AZseZCcaZZZn65tdqee8UXZlDvx0%2BNdO0LR%2B5pFy%2BjuM0wWbu59MvzcmTXbjsi7HY%0A6zd53Yq5K244fwFHRQ8eOB0IWB%2B4PfM7FeAApZvlfqlKOlLcZL2uyVmzRkyR5yW7%0A2uo9mehX44CiPJ2fse9Y6eQtcfEhMPkmHXI01sN%2BKwPbpA39%2BxOsStjhP9N1Y1a2%0AtQAVo%2ByVgLgV2Hws73Fc0o3wC78qPEA%2Bv2aRs/Be3ZFDgDyghc/1fgU%2B7C%2BP6kbq%0Ad4poyb6IW8KCJbxfMJvkordNOgOUUxndPHEi/tb/U7uLjLOgPA%3D%3D%0A-----END%20CERTIFICATE-----%0A-----BEGIN%20CERTIFICATE-----%0AMIIFSzCCA7OgAwIBAgIJANEHdl0yo7CUMA0GCSqGSIb3DQEBCwUAMH4xCzAJBgNV%0ABAYTAlVTMQswCQYDVQQIDAJDQTEUMBIGA1UEBwwLU2FudGEgQ2xhcmExGjAYBgNV%0ABAoMEUludGVsIENvcnBvcmF0aW9uMTAwLgYDVQQDDCdJbnRlbCBTR1ggQXR0ZXN0%0AYXRpb24gUmVwb3J0IFNpZ25pbmcgQ0EwIBcNMTYxMTE0MTUzNzMxWhgPMjA0OTEy%0AMzEyMzU5NTlaMH4xCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJDQTEUMBIGA1UEBwwL%0AU2FudGEgQ2xhcmExGjAYBgNVBAoMEUludGVsIENvcnBvcmF0aW9uMTAwLgYDVQQD%0ADCdJbnRlbCBTR1ggQXR0ZXN0YXRpb24gUmVwb3J0IFNpZ25pbmcgQ0EwggGiMA0G%0ACSqGSIb3DQEBAQUAA4IBjwAwggGKAoIBgQCfPGR%2BtXc8u1EtJzLA10Feu1Wg%2Bp7e%0ALmSRmeaCHbkQ1TF3Nwl3RmpqXkeGzNLd69QUnWovYyVSndEMyYc3sHecGgfinEeh%0ArgBJSEdsSJ9FpaFdesjsxqzGRa20PYdnnfWcCTvFoulpbFR4VBuXnnVLVzkUvlXT%0AL/TAnd8nIZk0zZkFJ7P5LtePvykkar7LcSQO85wtcQe0R1Raf/sQ6wYKaKmFgCGe%0ANpEJUmg4ktal4qgIAxk%2BQHUxQE42sxViN5mqglB0QJdUot/o9a/V/mMeH8KvOAiQ%0AbyinkNndn%2BBgk5sSV5DFgF0DffVqmVMblt5p3jPtImzBIH0QQrXJq39AT8cRwP5H%0AafuVeLHcDsRp6hol4P%2BZFIhu8mmbI1u0hH3W/0C2BuYXB5PC%2B5izFFh/nP0lc2Lf%0A6rELO9LZdnOhpL1ExFOq9H/B8tPQ84T3Sgb4nAifDabNt/zu6MmCGo5U8lwEFtGM%0ARoOaX4AS%2B909x00lYnmtwsDVWv9vBiJCXRsCAwEAAaOByTCBxjBgBgNVHR8EWTBX%0AMFWgU6BRhk9odHRwOi8vdHJ1c3RlZHNlcnZpY2VzLmludGVsLmNvbS9jb250ZW50%0AL0NSTC9TR1gvQXR0ZXN0YXRpb25SZXBvcnRTaWduaW5nQ0EuY3JsMB0GA1UdDgQW%0ABBR4Q3t2pn680K9%2BQjfrNXw7hwFRPDAfBgNVHSMEGDAWgBR4Q3t2pn680K9%2BQjfr%0ANXw7hwFRPDAOBgNVHQ8BAf8EBAMCAQYwEgYDVR0TAQH/BAgwBgEB/wIBADANBgkq%0AhkiG9w0BAQsFAAOCAYEAeF8tYMXICvQqeXYQITkV2oLJsp6J4JAqJabHWxYJHGir%0AIEqucRiJSSx%2BHjIJEUVaj8E0QjEud6Y5lNmXlcjqRXaCPOqK0eGRz6hi%2BripMtPZ%0AsFNaBwLQVV905SDjAzDzNIDnrcnXyB4gcDFCvwDFKKgLRjOB/WAqgscDUoGq5ZVi%0AzLUzTqiQPmULAQaB9c6Oti6snEFJiCQ67JLyW/E83/frzCmO5Ru6WjU4tmsmy8Ra%0AUd4APK0wZTGtfPXU7w%2BIBdG5Ez0kE1qzxGQaL4gINJ1zMyleDnbuS8UicjJijvqA%0A152Sq049ESDz%2B1rRGc2NVEqh1KaGXmtXvqxXcTB%2BLjy5Bw2ke0v8iGngFBPqCTVB%0A3op5KBG3RjbF6RRSzwzuWfL7QErNC8WEy5yDVARzTA5%2BxmBc388v9Dm21HGfcC8O%0ADD%2BgT9sSpssq0ascmvH49MOgjt1yoysLtdCtJW/9FZpoOypaHx0R%2BmJTLwPXVMrv%0ADaVzWh5aiEx%2BidkSGMnX%0A-----END%20CERTIFICATE-----%0A"; + String signature = "Kn2Ya2T039qvEWIzIQeSksNyyCQIkcVjciClcp3a6C766dJANXxLLIn6CfyvUZddMtePrTOLpC2e5QTQxB4RwtWmFfr7nxRdFUtA3dH2DAQL5DqqlmPv46ZWSPfiiOXUsu8vNgX3Z4Znt4Q+dIPIquNPY8ZmiAcpKR7n2K3QtabgOnJ2EyngabY3LMQTtriXbZjpl53ynhVhV1rciMdvMaTz4DUYt7gKi+KeNd3CBFSev+eTgYPC3em96J/3bfVR+wC5m3JGbIBCrwAsbO05JkiNIMck3s+p4d/hwiABR75EplxaWmGgIm6VvUKtGhdJ/cNrmF0nxMX6Vi6N2WaLTA=="; + String signatureBody = "{\"id\":\"287419896494669543891634765983074535548\",\"timestamp\":\"2019-03-11T20:01:21.658293\",\"version\":3,\"isvEnclaveQuoteStatus\":\"OK\",\"isvEnclaveQuoteBody\":\"AgAAADILAAAIAAcAAAAAAPiLWcRSSA3shraxepsGV9qF4zYUPJgE42ZZZXS2G9zaBQUCBP//AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAAAAAAAAAHAAAAAAAAAM1s/DQpN7I7G907v5chqlYVrJ/1CnXFUn1EHNMnaCbJAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADrzm117Qj8NlEllyDkV4Pae4UgsPjgVXtAA5UsG90gVgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACHgz6GaO6bkxfPLBYcR5rEf9Itrt81OEanXteSMcd/BQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\"}"; + + KeyStore keyStore = KeyStore.getInstance("JKS"); + keyStore.load(getClass().getResourceAsStream("/ias.jks"), "whisper".toCharArray()); + + SigningCertificate certificate = new SigningCertificate(certificateChain, keyStore); + byte[] decodedSignature = Base64.decode(signature); + + for (int i=0;i