1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this 3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 package org.mozilla.gecko.sync; 6 7 import java.io.IOException; 8 import java.io.UnsupportedEncodingException; 9 10 import org.json.simple.JSONObject; 11 import org.mozilla.apache.commons.codec.binary.Base64; 12 import org.mozilla.gecko.sync.crypto.CryptoException; 13 import org.mozilla.gecko.sync.crypto.CryptoInfo; 14 import org.mozilla.gecko.sync.crypto.KeyBundle; 15 import org.mozilla.gecko.sync.crypto.MissingCryptoInputException; 16 import org.mozilla.gecko.sync.crypto.NoKeyBundleException; 17 import org.mozilla.gecko.sync.repositories.domain.Record; 18 import org.mozilla.gecko.sync.repositories.domain.RecordParseException; 19 import org.mozilla.gecko.util.StringUtils; 20 21 /** 22 * A Sync crypto record has: 23 * 24 * <ul> 25 * <li>a collection of fields which are not encrypted (id and collection);</il> 26 * <li>a set of metadata fields (index, modified, ttl);</il> 27 * <li>a payload, which is encrypted and decrypted on request.</il> 28 * </ul> 29 * 30 * The payload flips between being a blob of JSON with hmac/IV/ciphertext 31 * attributes and the cleartext itself. 32 * 33 * Until there's some benefit to the abstraction, we're simply going to call 34 * this <code>CryptoRecord</code>. 35 * 36 * <code>CryptoRecord</code> uses <code>CryptoInfo</code> to do the actual 37 * encryption and decryption. 38 */ 39 public class CryptoRecord extends Record { 40 41 // JSON related constants. 42 private static final String KEY_ID = "id"; 43 private static final String KEY_COLLECTION = "collection"; 44 // We need to pluck out payload for size checks during upload to a Sync Storage server. 45 public static final String KEY_PAYLOAD = "payload"; 46 private static final String KEY_MODIFIED = "modified"; 47 private static final String KEY_SORTINDEX = "sortindex"; 48 private static final String KEY_TTL = "ttl"; 49 private static final String KEY_CIPHERTEXT = "ciphertext"; 50 private static final String KEY_HMAC = "hmac"; 51 private static final String KEY_IV = "IV"; 52 53 /** 54 * Helper method for doing actual decryption. 55 * 56 * Input: JSONObject containing a valid payload (cipherText, IV, HMAC), 57 * KeyBundle with keys for decryption. Output: byte[] clearText 58 * @throws CryptoException 59 * @throws UnsupportedEncodingException 60 */ decryptPayload(ExtendedJSONObject payload, KeyBundle keybundle)61 private static byte[] decryptPayload(ExtendedJSONObject payload, KeyBundle keybundle) throws CryptoException, UnsupportedEncodingException { 62 byte[] ciphertext = Base64.decodeBase64(((String) payload.get(KEY_CIPHERTEXT)).getBytes("UTF-8")); 63 byte[] iv = Base64.decodeBase64(((String) payload.get(KEY_IV)).getBytes("UTF-8")); 64 byte[] hmac = Utils.hex2Byte((String) payload.get(KEY_HMAC)); 65 66 return CryptoInfo.decrypt(ciphertext, iv, hmac, keybundle).getMessage(); 67 } 68 69 // The encrypted JSON body object. 70 // The decrypted JSON body object. Fields are copied from `body`. 71 72 public ExtendedJSONObject payload; 73 public KeyBundle keyBundle; 74 75 /** 76 * Don't forget to set cleartext or body! 77 */ CryptoRecord()78 public CryptoRecord() { 79 super(null, null, 0, false); 80 } 81 CryptoRecord(ExtendedJSONObject payload)82 public CryptoRecord(ExtendedJSONObject payload) { 83 super(null, null, 0, false); 84 if (payload == null) { 85 throw new IllegalArgumentException( 86 "No payload provided to CryptoRecord constructor."); 87 } 88 this.payload = payload; 89 } 90 CryptoRecord(String jsonString)91 public CryptoRecord(String jsonString) throws IOException, NonObjectJSONException { 92 93 this(new ExtendedJSONObject(jsonString)); 94 } 95 96 /** 97 * Create a new CryptoRecord with the same metadata as an existing record. 98 * 99 * @param source 100 */ CryptoRecord(Record source)101 public CryptoRecord(Record source) { 102 super(source.guid, source.collection, source.lastModified, source.deleted); 103 this.ttl = source.ttl; 104 } 105 106 @Override copyWithIDs(String guid, long androidID)107 public Record copyWithIDs(String guid, long androidID) { 108 CryptoRecord out = new CryptoRecord(this); 109 out.guid = guid; 110 out.androidID = androidID; 111 out.sortIndex = this.sortIndex; 112 out.ttl = this.ttl; 113 out.payload = (this.payload == null) ? null : new ExtendedJSONObject(this.payload.object); 114 out.keyBundle = this.keyBundle; // TODO: copy me? 115 return out; 116 } 117 118 /** 119 * Take a whole record as JSON -- i.e., something like 120 * 121 * {"payload": "{...}", "id":"foobarbaz"} 122 * 123 * and turn it into a CryptoRecord object. 124 * 125 * @param jsonRecord 126 * @return 127 * A CryptoRecord that encapsulates the provided record. 128 * 129 * @throws NonObjectJSONException 130 * @throws IOException 131 */ fromJSONRecord(String jsonRecord)132 public static CryptoRecord fromJSONRecord(String jsonRecord) 133 throws NonObjectJSONException, IOException, RecordParseException { 134 byte[] bytes = jsonRecord.getBytes("UTF-8"); 135 ExtendedJSONObject object = ExtendedJSONObject.parseUTF8AsJSONObject(bytes); 136 137 return CryptoRecord.fromJSONRecord(object); 138 } 139 140 // TODO: defensive programming. fromJSONRecord(ExtendedJSONObject jsonRecord)141 public static CryptoRecord fromJSONRecord(ExtendedJSONObject jsonRecord) 142 throws IOException, NonObjectJSONException, RecordParseException { 143 String id = (String) jsonRecord.get(KEY_ID); 144 String collection = (String) jsonRecord.get(KEY_COLLECTION); 145 String jsonEncodedPayload = (String) jsonRecord.get(KEY_PAYLOAD); 146 147 ExtendedJSONObject payload = new ExtendedJSONObject(jsonEncodedPayload); 148 149 CryptoRecord record = new CryptoRecord(payload); 150 record.guid = id; 151 record.collection = collection; 152 if (jsonRecord.containsKey(KEY_MODIFIED)) { 153 Long timestamp = jsonRecord.getTimestamp(KEY_MODIFIED); 154 if (timestamp == null) { 155 throw new RecordParseException("timestamp could not be parsed"); 156 } 157 record.lastModified = timestamp; 158 } 159 if (jsonRecord.containsKey(KEY_SORTINDEX)) { 160 // getLong tries to cast to Long, and might return null. We catch all 161 // exceptions, just to be safe. 162 try { 163 record.sortIndex = jsonRecord.getLong(KEY_SORTINDEX); 164 } catch (Exception e) { 165 throw new RecordParseException("timestamp could not be parsed"); 166 } 167 } 168 if (jsonRecord.containsKey(KEY_TTL)) { 169 // TTLs are never returned by the sync server, so should never be true if 170 // the record was fetched. 171 try { 172 record.ttl = jsonRecord.getLong(KEY_TTL); 173 } catch (Exception e) { 174 throw new RecordParseException("TTL could not be parsed"); 175 } 176 } 177 // TODO: deleted? 178 return record; 179 } 180 setKeyBundle(KeyBundle bundle)181 public void setKeyBundle(KeyBundle bundle) { 182 this.keyBundle = bundle; 183 } 184 decrypt()185 public CryptoRecord decrypt() throws CryptoException, IOException, NonObjectJSONException { 186 if (keyBundle == null) { 187 throw new NoKeyBundleException(); 188 } 189 190 // Check that payload contains all pieces for crypto. 191 if (!payload.containsKey(KEY_CIPHERTEXT) || 192 !payload.containsKey(KEY_IV) || 193 !payload.containsKey(KEY_HMAC)) { 194 throw new MissingCryptoInputException(); 195 } 196 197 // There's no difference between handling the crypto/keys object and 198 // anything else; we just get this.keyBundle from a different source. 199 byte[] cleartext = decryptPayload(payload, keyBundle); 200 payload = ExtendedJSONObject.parseUTF8AsJSONObject(cleartext); 201 return this; 202 } 203 encrypt()204 public CryptoRecord encrypt() throws CryptoException, UnsupportedEncodingException { 205 if (this.keyBundle == null) { 206 throw new NoKeyBundleException(); 207 } 208 String cleartext = payload.toJSONString(); 209 byte[] cleartextBytes = cleartext.getBytes(StringUtils.UTF_8); 210 CryptoInfo info = CryptoInfo.encrypt(cleartextBytes, keyBundle); 211 String message = new String(Base64.encodeBase64(info.getMessage())); 212 String iv = new String(Base64.encodeBase64(info.getIV())); 213 String hmac = Utils.byte2Hex(info.getHMAC()); 214 ExtendedJSONObject ciphertext = new ExtendedJSONObject(); 215 ciphertext.put(KEY_CIPHERTEXT, message); 216 ciphertext.put(KEY_HMAC, hmac); 217 ciphertext.put(KEY_IV, iv); 218 this.payload = ciphertext; 219 return this; 220 } 221 222 @Override initFromEnvelope(CryptoRecord payload)223 public void initFromEnvelope(CryptoRecord payload) { 224 throw new IllegalStateException("Can't do this with a CryptoRecord."); 225 } 226 227 @Override getEnvelope()228 public CryptoRecord getEnvelope() { 229 throw new IllegalStateException("Can't do this with a CryptoRecord."); 230 } 231 232 @Override populatePayload(ExtendedJSONObject payload)233 protected void populatePayload(ExtendedJSONObject payload) { 234 throw new IllegalStateException("Can't do this with a CryptoRecord."); 235 } 236 237 @Override initFromPayload(ExtendedJSONObject payload)238 protected void initFromPayload(ExtendedJSONObject payload) { 239 throw new IllegalStateException("Can't do this with a CryptoRecord."); 240 } 241 242 // TODO: this only works with encrypted object, and has other limitations. 243 @Override toJSONObject()244 public JSONObject toJSONObject() { 245 ExtendedJSONObject o = new ExtendedJSONObject(); 246 o.put(KEY_PAYLOAD, payload.toJSONString()); 247 o.put(KEY_ID, this.guid); 248 if (this.ttl > 0) { 249 o.put(KEY_TTL, this.ttl); 250 } 251 return o.object; 252 } 253 254 @Override toJSONString()255 public String toJSONString() { 256 return toJSONObject().toJSONString(); 257 } 258 } 259