Skip to content

Commit ce2b79c

Browse files
committed
Add support for XEP-XXXX: OMEMO Media Sharing
1 parent 8a74aec commit ce2b79c

File tree

11 files changed

+678
-6
lines changed

11 files changed

+678
-6
lines changed

documentation/extensions/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ Unofficial XMPP Extensions
113113
| Name | XEP | Version | Description |
114114
|---------------------------------------------|--------------------------------------------------------|-----------|----------------------------------------------------------------------------------------------------------|
115115
| [Multi-User Chat Light](muclight.md) | [XEP-xxxx](https://mongooseim.readthedocs.io/en/latest/open-extensions/xeps/xep-muc-light.html) | n/a | Multi-User Chats for mobile XMPP applications and specific environment. |
116+
| OMEMO Media Sharing | [XEP-XXXX](https://xmpp.org/extensions/inbox/omemo-media-sharing.html) | 0.0.1 | Share files via HTTP File Upload in an encrypted fashion. |
116117
| Google GCM JSON payload | n/a | n/a | Semantically the same as XEP-0335: JSON Containers. |
117118

118119
Legacy Smack Extensions and currently supported XEPs of smack-legacy
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
/**
2+
*
3+
* Copyright © 2019 Paul Schaub
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
package org.jivesoftware.smack.util;
18+
19+
import java.security.SecureRandom;
20+
21+
public class RandomUtils {
22+
23+
private static final SecureRandom SECURE_RANDOM = new SecureRandom();
24+
25+
/**
26+
* Generate a securely random byte array.
27+
*
28+
* @param len length of the byte array
29+
* @return byte array
30+
*/
31+
public static byte[] secureRandomBytes(int len) {
32+
byte[] bytes = new byte[len];
33+
SECURE_RANDOM.nextBytes(bytes);
34+
return bytes;
35+
}
36+
}

smack-core/src/main/java/org/jivesoftware/smack/util/StringUtils.java

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,24 @@ public static String encodeHex(byte[] bytes) {
261261
return new String(hexChars);
262262
}
263263

264+
/**
265+
* Convert a hexadecimal String to bytes.
266+
*
267+
* Source: https://stackoverflow.com/a/140861/11150851
268+
*
269+
* @param s hex string
270+
* @return byte array
271+
*/
272+
public static byte[] hexStringToByteArray(String s) {
273+
int len = s.length();
274+
byte[] data = new byte[len / 2];
275+
for (int i = 0; i < len; i += 2) {
276+
data[i / 2] = (byte) ((Character.digit(s.charAt(i), 16) << 4)
277+
+ Character.digit(s.charAt(i + 1), 16));
278+
}
279+
return data;
280+
}
281+
264282
public static byte[] toUtf8Bytes(String string) {
265283
return string.getBytes(StandardCharsets.UTF_8);
266284
}

smack-experimental/src/main/java/org/jivesoftware/smackx/httpfileupload/HttpFileUploadManager.java

Lines changed: 86 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,16 +21,22 @@
2121
import java.io.FileInputStream;
2222
import java.io.FileNotFoundException;
2323
import java.io.IOException;
24+
import java.io.InputStream;
2425
import java.io.OutputStream;
2526
import java.net.HttpURLConnection;
2627
import java.net.URL;
28+
import java.security.InvalidAlgorithmParameterException;
29+
import java.security.InvalidKeyException;
30+
import java.security.NoSuchAlgorithmException;
2731
import java.util.List;
2832
import java.util.Map;
2933
import java.util.Map.Entry;
3034
import java.util.WeakHashMap;
3135
import java.util.logging.Level;
3236
import java.util.logging.Logger;
33-
37+
import javax.crypto.Cipher;
38+
import javax.crypto.CipherInputStream;
39+
import javax.crypto.NoSuchPaddingException;
3440
import javax.net.ssl.HttpsURLConnection;
3541
import javax.net.ssl.SSLContext;
3642
import javax.net.ssl.SSLSocketFactory;
@@ -43,24 +49,28 @@
4349
import org.jivesoftware.smack.XMPPConnection;
4450
import org.jivesoftware.smack.XMPPConnectionRegistry;
4551
import org.jivesoftware.smack.XMPPException;
46-
4752
import org.jivesoftware.smackx.disco.ServiceDiscoveryManager;
4853
import org.jivesoftware.smackx.disco.packet.DiscoverInfo;
4954
import org.jivesoftware.smackx.httpfileupload.UploadService.Version;
5055
import org.jivesoftware.smackx.httpfileupload.element.Slot;
5156
import org.jivesoftware.smackx.httpfileupload.element.SlotRequest;
5257
import org.jivesoftware.smackx.httpfileupload.element.SlotRequest_V0_2;
58+
import org.jivesoftware.smackx.omemo_media_sharing.AesgcmUrl;
59+
import org.jivesoftware.smackx.omemo_media_sharing.OmemoMediaSharingUtils;
5360
import org.jivesoftware.smackx.xdata.FormField;
5461
import org.jivesoftware.smackx.xdata.packet.DataForm;
5562

5663
import org.jxmpp.jid.DomainBareJid;
5764

5865
/**
5966
* A manager for XEP-0363: HTTP File Upload.
67+
* This manager is also capable of XEP-XXXX: OMEMO Media Sharing.
6068
*
6169
* @author Grigory Fedorov
6270
* @author Florian Schmaus
71+
* @author Paul Schaub
6372
* @see <a href="http://xmpp.org/extensions/xep-0363.html">XEP-0363: HTTP File Upload</a>
73+
* @see <a href="https://xmpp.org/extensions/inbox/omemo-media-sharing.html">XEP-XXXX: OMEMO Media Sharing</a>
6474
*/
6575
public final class HttpFileUploadManager extends Manager {
6676

@@ -245,7 +255,7 @@ public URL uploadFile(File file) throws InterruptedException, XMPPException.XMPP
245255
* Note that this is a synchronous call -- Smack must wait for the server response.
246256
*
247257
* @param file file to be uploaded
248-
* @param listener upload progress listener of null
258+
* @param listener upload progress listener or null
249259
* @return public URL for sharing uploaded file
250260
*
251261
* @throws InterruptedException
@@ -265,6 +275,74 @@ public URL uploadFile(File file, UploadProgressListener listener) throws Interru
265275
return slot.getGetUrl();
266276
}
267277

278+
/**
279+
* Upload a file encrypted using the scheme described in OMEMO Media Sharing.
280+
* The file is being encrypted using a random 256 bit AES key in Galois Counter Mode using a random 16 byte IV and
281+
* then uploaded to the server.
282+
* The URL that is returned has a modified scheme (aesgcm:// instead of https://) and has the IV and key attached
283+
* as ref part.
284+
*
285+
* Note: The URL contains the used key and IV in plain text. Keep in mind to only share this URL though a secured
286+
* channel (i.e. end-to-end encrypted message), as anybody who can read the URL can also decrypt the file.
287+
*
288+
* Note: This method uses a IV of length 16 instead of 12. Although not specified in the ProtoXEP, 16 byte IVs are
289+
* currently used by most implementations. This implementation also supports 12 byte IVs when decrypting.
290+
*
291+
* @param file file
292+
* @return AESGCM URL which contains the key and IV of the encrypted file.
293+
*
294+
* @see <a href="https://xmpp.org/extensions/inbox/omemo-media-sharing.html">XEP-XXXX: OMEMO Media Sharing</a>
295+
*/
296+
public AesgcmUrl uploadFileEncrypted(File file) throws InterruptedException, IOException,
297+
XMPPException.XMPPErrorException, SmackException, InvalidAlgorithmParameterException,
298+
NoSuchAlgorithmException, InvalidKeyException, NoSuchPaddingException {
299+
return uploadFileEncrypted(file, null);
300+
}
301+
/**
302+
* Upload a file encrypted using the scheme described in OMEMO Media Sharing.
303+
* The file is being encrypted using a random 256 bit AES key in Galois Counter Mode using a random 16 byte IV and
304+
* then uploaded to the server.
305+
* The URL that is returned has a modified scheme (aesgcm:// instead of https://) and has the IV and key attached
306+
* as ref part.
307+
*
308+
* Note: The URL contains the used key and IV in plain text. Keep in mind to only share this URL though a secured
309+
* channel (i.e. end-to-end encrypted message), as anybody who can read the URL can also decrypt the file.
310+
*
311+
* Note: This method uses a IV of length 16 instead of 12. Although not specified in the ProtoXEP, 16 byte IVs are
312+
* currently used by most implementations. This implementation also supports 12 byte IVs when decrypting.
313+
*
314+
* @param file file
315+
* @param listener progress listener or null
316+
* @return AESGCM URL which contains the key and IV of the encrypted file.
317+
*
318+
* @see <a href="https://xmpp.org/extensions/inbox/omemo-media-sharing.html">XEP-XXXX: OMEMO Media Sharing</a>
319+
*/
320+
public AesgcmUrl uploadFileEncrypted(File file, UploadProgressListener listener) throws IOException,
321+
InterruptedException, XMPPException.XMPPErrorException, SmackException, NoSuchPaddingException,
322+
NoSuchAlgorithmException, InvalidAlgorithmParameterException, InvalidKeyException {
323+
if (!file.isFile()) {
324+
throw new FileNotFoundException("The path " + file.getAbsolutePath() + " is not a file");
325+
}
326+
327+
// The encrypted file will contain an extra block with the AEAD MAC.
328+
long cipherFileLength = file.length() + 16;
329+
330+
final Slot slot = requestSlot(file.getName(), cipherFileLength, "application/octet-stream");
331+
URL slotUrl = slot.getGetUrl();
332+
333+
// fresh AES key + iv
334+
byte[] key = OmemoMediaSharingUtils.generateRandomKey();
335+
byte[] iv = OmemoMediaSharingUtils.generateRandomIV();
336+
Cipher cipher = OmemoMediaSharingUtils.encryptionCipherFrom(key, iv);
337+
338+
FileInputStream fis = new FileInputStream(file);
339+
// encrypt the file on the fly - encryption actually happens below in uploadFile()
340+
CipherInputStream cis = new CipherInputStream(fis, cipher);
341+
342+
uploadFile(cis, cipherFileLength, slot, listener);
343+
344+
return new AesgcmUrl(slotUrl, key, iv);
345+
}
268346

269347
/**
270348
* Request a new upload slot from default upload service (if discovered). When you get slot you should upload file
@@ -391,10 +469,13 @@ private void uploadFile(final File file, final Slot slot, UploadProgressListener
391469
if (fileSize >= Integer.MAX_VALUE) {
392470
throw new IllegalArgumentException("File size " + fileSize + " must be less than " + Integer.MAX_VALUE);
393471
}
394-
final int fileSizeInt = (int) fileSize;
395472

396473
// Construct the FileInputStream first to make sure we can actually read the file.
397474
final FileInputStream fis = new FileInputStream(file);
475+
uploadFile(fis, fileSize, slot, listener);
476+
}
477+
478+
private void uploadFile(final InputStream fis, long fileSize, final Slot slot, UploadProgressListener listener) throws IOException {
398479

399480
final URL putUrl = slot.getPutUrl();
400481

@@ -404,7 +485,7 @@ private void uploadFile(final File file, final Slot slot, UploadProgressListener
404485
urlConnection.setUseCaches(false);
405486
urlConnection.setDoOutput(true);
406487
// TODO Change to using fileSize once Smack's minimum Android API level is 19 or higher.
407-
urlConnection.setFixedLengthStreamingMode(fileSizeInt);
488+
urlConnection.setFixedLengthStreamingMode((int) fileSize);
408489
urlConnection.setRequestProperty("Content-Type", "application/octet-stream;");
409490
for (Entry<String, String> header : slot.getHeaders().entrySet()) {
410491
urlConnection.setRequestProperty(header.getKey(), header.getValue());
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
/**
2+
*
3+
* Copyright © 2019 Paul Schaub
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
package org.jivesoftware.smackx.omemo_media_sharing;
18+
19+
import java.net.MalformedURLException;
20+
import java.net.URL;
21+
import java.security.InvalidAlgorithmParameterException;
22+
import java.security.InvalidKeyException;
23+
import java.security.NoSuchAlgorithmException;
24+
import javax.crypto.Cipher;
25+
import javax.crypto.NoSuchPaddingException;
26+
27+
import org.jivesoftware.smack.util.Objects;
28+
import org.jivesoftware.smack.util.StringUtils;
29+
import org.jivesoftware.smackx.httpfileupload.element.Slot;
30+
31+
/**
32+
* This class represents a aesgcm URL as described in XEP-XXXX: OMEMO Media Sharing.
33+
* As the builtin {@link URL} class cannot handle the aesgcm protocol identifier, this class
34+
* is used as a utility class that bundles together a {@link URL}, key and IV.
35+
*
36+
* @see <a href="https://xmpp.org/extensions/inbox/omemo-media-sharing.html">XEP-XXXX: OMEMO Media Sharing</a>
37+
*/
38+
public class AesgcmUrl {
39+
40+
public static final String PROTOCOL = "aesgcm";
41+
42+
private final URL httpsUrl;
43+
private final byte[] keyBytes;
44+
private final byte[] ivBytes;
45+
46+
/**
47+
* Private constructor that constructs the {@link AesgcmUrl} from a normal https {@link URL}, a key and iv.
48+
*
49+
* @param httpsUrl normal https url as given by the {@link Slot}.
50+
* @param key byte array of an encoded 256 bit aes key
51+
* @param iv 16 or 12 byte initialization vector
52+
*/
53+
public AesgcmUrl(URL httpsUrl, byte[] key, byte[] iv) {
54+
this.httpsUrl = Objects.requireNonNull(httpsUrl);
55+
this.keyBytes = Objects.requireNonNull(key);
56+
this.ivBytes = Objects.requireNonNull(iv);
57+
}
58+
59+
/**
60+
* Parse a {@link AesgcmUrl} from a {@link String}.
61+
* The parsed object will provide a normal {@link URL} under which the offered file can be downloaded,
62+
* as well as a {@link Cipher} that can be used to decrypt it.
63+
*
64+
* @param aesgcmUrlString aesgcm URL as a {@link String}
65+
*/
66+
public AesgcmUrl(String aesgcmUrlString) {
67+
if (!aesgcmUrlString.startsWith(PROTOCOL)) {
68+
throw new IllegalArgumentException("Provided String does not resemble a aesgcm URL.");
69+
}
70+
71+
// Convert aesgcm Url to https URL
72+
this.httpsUrl = extractHttpsUrl(aesgcmUrlString);
73+
74+
// Extract IV and Key
75+
byte[][] ivAndKey = extractIVAndKey(aesgcmUrlString);
76+
this.ivBytes = ivAndKey[0];
77+
this.keyBytes = ivAndKey[1];
78+
}
79+
80+
/**
81+
* Return a https {@link URL} under which the file can be downloaded.
82+
*
83+
* @return https URL
84+
*/
85+
public URL getDownloadUrl() {
86+
return httpsUrl;
87+
}
88+
89+
/**
90+
* Returns the {@link String} representation of this aesgcm URL.
91+
*
92+
* @return aesgcm URL with key and IV.
93+
*/
94+
public String getAesgcmUrl() {
95+
String aesgcmUrl = httpsUrl.toString().replaceFirst(httpsUrl.getProtocol(), PROTOCOL);
96+
return aesgcmUrl + "#" + StringUtils.encodeHex(ivBytes) + StringUtils.encodeHex(keyBytes);
97+
}
98+
99+
/**
100+
* Returns a {@link Cipher} in decryption mode, which can be used to decrypt the offered file.
101+
*
102+
* @return cipher
103+
*
104+
* @throws NoSuchPaddingException if the JVM cannot provide the specified cipher mode
105+
* @throws NoSuchAlgorithmException if the JVM cannot provide the specified cipher mode
106+
* @throws InvalidAlgorithmParameterException if the JVM cannot provide the specified cipher
107+
* (eg. if no BC provider is added)
108+
* @throws InvalidKeyException if the provided key is invalid
109+
*/
110+
public Cipher getDecryptionCipher() throws NoSuchPaddingException, NoSuchAlgorithmException,
111+
InvalidAlgorithmParameterException, InvalidKeyException {
112+
return OmemoMediaSharingUtils.decryptionCipherFrom(keyBytes, ivBytes);
113+
}
114+
115+
private static URL extractHttpsUrl(String aesgcmUrlString) {
116+
// aesgcm -> https
117+
String httpsUrlString = aesgcmUrlString.replaceFirst(PROTOCOL, "https");
118+
// remove #ref
119+
httpsUrlString = httpsUrlString.substring(0, httpsUrlString.indexOf("#"));
120+
121+
try {
122+
return new URL(httpsUrlString);
123+
} catch (MalformedURLException e) {
124+
throw new AssertionError("Failed to convert aesgcm URL to https URL: '" + aesgcmUrlString + "'", e);
125+
}
126+
}
127+
128+
private static byte[][] extractIVAndKey(String aesgcmUrlString) {
129+
int startOfRef = aesgcmUrlString.lastIndexOf("#");
130+
if (startOfRef == -1) {
131+
throw new IllegalArgumentException("The provided aesgcm Url does not have a ref part which is " +
132+
"supposed to contain the encryption key for file encryption.");
133+
}
134+
135+
String ref = aesgcmUrlString.substring(startOfRef + 1);
136+
byte[] refBytes = StringUtils.hexStringToByteArray(ref);
137+
138+
byte[] key = new byte[32];
139+
byte[] iv;
140+
int ivLen;
141+
// determine the length of the initialization vector part
142+
switch (refBytes.length) {
143+
// 32 bytes key + 16 bytes IV
144+
case 48:
145+
ivLen = 16;
146+
break;
147+
148+
// 32 bytes key + 12 bytes IV
149+
case 44:
150+
ivLen = 12;
151+
break;
152+
default:
153+
throw new IllegalArgumentException("Provided URL has an invalid ref tag (" + ref.length() + "): '" + ref + "'");
154+
}
155+
iv = new byte[ivLen];
156+
System.arraycopy(refBytes, 0, iv, 0, ivLen);
157+
System.arraycopy(refBytes, ivLen, key, 0, 32);
158+
159+
return new byte[][] {iv,key};
160+
}
161+
}

0 commit comments

Comments
 (0)