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