|
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.json.simple.parser.ParseException; |
|
12 import org.mozilla.apache.commons.codec.binary.Base64; |
|
13 import org.mozilla.gecko.sync.crypto.CryptoException; |
|
14 import org.mozilla.gecko.sync.crypto.CryptoInfo; |
|
15 import org.mozilla.gecko.sync.crypto.KeyBundle; |
|
16 import org.mozilla.gecko.sync.crypto.MissingCryptoInputException; |
|
17 import org.mozilla.gecko.sync.crypto.NoKeyBundleException; |
|
18 import org.mozilla.gecko.sync.repositories.domain.Record; |
|
19 import org.mozilla.gecko.sync.repositories.domain.RecordParseException; |
|
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 private static final String KEY_PAYLOAD = "payload"; |
|
45 private static final String KEY_MODIFIED = "modified"; |
|
46 private static final String KEY_SORTINDEX = "sortindex"; |
|
47 private static final String KEY_TTL = "ttl"; |
|
48 private static final String KEY_CIPHERTEXT = "ciphertext"; |
|
49 private static final String KEY_HMAC = "hmac"; |
|
50 private static final String KEY_IV = "IV"; |
|
51 |
|
52 /** |
|
53 * Helper method for doing actual decryption. |
|
54 * |
|
55 * Input: JSONObject containing a valid payload (cipherText, IV, HMAC), |
|
56 * KeyBundle with keys for decryption. Output: byte[] clearText |
|
57 * @throws CryptoException |
|
58 * @throws UnsupportedEncodingException |
|
59 */ |
|
60 private static byte[] decryptPayload(ExtendedJSONObject payload, KeyBundle keybundle) throws CryptoException, UnsupportedEncodingException { |
|
61 byte[] ciphertext = Base64.decodeBase64(((String) payload.get(KEY_CIPHERTEXT)).getBytes("UTF-8")); |
|
62 byte[] iv = Base64.decodeBase64(((String) payload.get(KEY_IV)).getBytes("UTF-8")); |
|
63 byte[] hmac = Utils.hex2Byte((String) payload.get(KEY_HMAC)); |
|
64 |
|
65 return CryptoInfo.decrypt(ciphertext, iv, hmac, keybundle).getMessage(); |
|
66 } |
|
67 |
|
68 // The encrypted JSON body object. |
|
69 // The decrypted JSON body object. Fields are copied from `body`. |
|
70 |
|
71 public ExtendedJSONObject payload; |
|
72 public KeyBundle keyBundle; |
|
73 |
|
74 /** |
|
75 * Don't forget to set cleartext or body! |
|
76 */ |
|
77 public CryptoRecord() { |
|
78 super(null, null, 0, false); |
|
79 } |
|
80 |
|
81 public CryptoRecord(ExtendedJSONObject payload) { |
|
82 super(null, null, 0, false); |
|
83 if (payload == null) { |
|
84 throw new IllegalArgumentException( |
|
85 "No payload provided to CryptoRecord constructor."); |
|
86 } |
|
87 this.payload = payload; |
|
88 } |
|
89 |
|
90 public CryptoRecord(String jsonString) throws IOException, ParseException, NonObjectJSONException { |
|
91 this(ExtendedJSONObject.parseJSONObject(jsonString)); |
|
92 } |
|
93 |
|
94 /** |
|
95 * Create a new CryptoRecord with the same metadata as an existing record. |
|
96 * |
|
97 * @param source |
|
98 */ |
|
99 public CryptoRecord(Record source) { |
|
100 super(source.guid, source.collection, source.lastModified, source.deleted); |
|
101 this.ttl = source.ttl; |
|
102 } |
|
103 |
|
104 @Override |
|
105 public Record copyWithIDs(String guid, long androidID) { |
|
106 CryptoRecord out = new CryptoRecord(this); |
|
107 out.guid = guid; |
|
108 out.androidID = androidID; |
|
109 out.sortIndex = this.sortIndex; |
|
110 out.ttl = this.ttl; |
|
111 out.payload = (this.payload == null) ? null : new ExtendedJSONObject(this.payload.object); |
|
112 out.keyBundle = this.keyBundle; // TODO: copy me? |
|
113 return out; |
|
114 } |
|
115 |
|
116 /** |
|
117 * Take a whole record as JSON -- i.e., something like |
|
118 * |
|
119 * {"payload": "{...}", "id":"foobarbaz"} |
|
120 * |
|
121 * and turn it into a CryptoRecord object. |
|
122 * |
|
123 * @param jsonRecord |
|
124 * @return |
|
125 * A CryptoRecord that encapsulates the provided record. |
|
126 * |
|
127 * @throws NonObjectJSONException |
|
128 * @throws ParseException |
|
129 * @throws IOException |
|
130 */ |
|
131 public static CryptoRecord fromJSONRecord(String jsonRecord) |
|
132 throws ParseException, NonObjectJSONException, IOException, RecordParseException { |
|
133 byte[] bytes = jsonRecord.getBytes("UTF-8"); |
|
134 ExtendedJSONObject object = ExtendedJSONObject.parseUTF8AsJSONObject(bytes); |
|
135 |
|
136 return CryptoRecord.fromJSONRecord(object); |
|
137 } |
|
138 |
|
139 // TODO: defensive programming. |
|
140 public static CryptoRecord fromJSONRecord(ExtendedJSONObject jsonRecord) |
|
141 throws IOException, ParseException, NonObjectJSONException, RecordParseException { |
|
142 String id = (String) jsonRecord.get(KEY_ID); |
|
143 String collection = (String) jsonRecord.get(KEY_COLLECTION); |
|
144 String jsonEncodedPayload = (String) jsonRecord.get(KEY_PAYLOAD); |
|
145 |
|
146 ExtendedJSONObject payload = ExtendedJSONObject.parseJSONObject(jsonEncodedPayload); |
|
147 |
|
148 CryptoRecord record = new CryptoRecord(payload); |
|
149 record.guid = id; |
|
150 record.collection = collection; |
|
151 if (jsonRecord.containsKey(KEY_MODIFIED)) { |
|
152 Long timestamp = jsonRecord.getTimestamp(KEY_MODIFIED); |
|
153 if (timestamp == null) { |
|
154 throw new RecordParseException("timestamp could not be parsed"); |
|
155 } |
|
156 record.lastModified = timestamp.longValue(); |
|
157 } |
|
158 if (jsonRecord.containsKey(KEY_SORTINDEX)) { |
|
159 // getLong tries to cast to Long, and might return null. We catch all |
|
160 // exceptions, just to be safe. |
|
161 try { |
|
162 record.sortIndex = jsonRecord.getLong(KEY_SORTINDEX); |
|
163 } catch (Exception e) { |
|
164 throw new RecordParseException("timestamp could not be parsed"); |
|
165 } |
|
166 } |
|
167 if (jsonRecord.containsKey(KEY_TTL)) { |
|
168 // TTLs are never returned by the sync server, so should never be true if |
|
169 // the record was fetched. |
|
170 try { |
|
171 record.ttl = jsonRecord.getLong(KEY_TTL); |
|
172 } catch (Exception e) { |
|
173 throw new RecordParseException("TTL could not be parsed"); |
|
174 } |
|
175 } |
|
176 // TODO: deleted? |
|
177 return record; |
|
178 } |
|
179 |
|
180 public void setKeyBundle(KeyBundle bundle) { |
|
181 this.keyBundle = bundle; |
|
182 } |
|
183 |
|
184 public CryptoRecord decrypt() throws CryptoException, IOException, ParseException, |
|
185 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 |
|
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("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 |
|
223 public void initFromEnvelope(CryptoRecord payload) { |
|
224 throw new IllegalStateException("Can't do this with a CryptoRecord."); |
|
225 } |
|
226 |
|
227 @Override |
|
228 public CryptoRecord getEnvelope() { |
|
229 throw new IllegalStateException("Can't do this with a CryptoRecord."); |
|
230 } |
|
231 |
|
232 @Override |
|
233 protected void populatePayload(ExtendedJSONObject payload) { |
|
234 throw new IllegalStateException("Can't do this with a CryptoRecord."); |
|
235 } |
|
236 |
|
237 @Override |
|
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 public JSONObject toJSONObject() { |
|
244 ExtendedJSONObject o = new ExtendedJSONObject(); |
|
245 o.put(KEY_PAYLOAD, payload.toJSONString()); |
|
246 o.put(KEY_ID, this.guid); |
|
247 if (this.ttl > 0) { |
|
248 o.put(KEY_TTL, this.ttl); |
|
249 } |
|
250 return o.object; |
|
251 } |
|
252 |
|
253 @Override |
|
254 public String toJSONString() { |
|
255 return toJSONObject().toJSONString(); |
|
256 } |
|
257 } |