OLD | NEW |
---|---|
(Empty) | |
1 // Copyright 2017 The Chromium Authors. All rights reserved. | |
2 // Use of this source code is governed by a BSD-style license that can be | |
3 // found in the LICENSE file. | |
4 | |
5 package org.chromium.webapk.lib.client; | |
6 | |
7 import static java.nio.ByteOrder.BIG_ENDIAN; | |
8 import static java.nio.ByteOrder.LITTLE_ENDIAN; | |
9 | |
10 import android.support.annotation.IntDef; | |
11 import android.util.Log; | |
12 | |
13 import java.nio.ByteBuffer; | |
14 import java.security.PublicKey; | |
15 import java.security.Signature; | |
16 import java.util.ArrayList; | |
17 import java.util.Collections; | |
18 import java.util.regex.Matcher; | |
19 import java.util.regex.Pattern; | |
20 | |
21 /** | |
22 * WebApkVerifySignature reads in the APK file and verifies the WebApk signature . It reads the | |
23 * signature from the zip comment and verifies that it was signed by the public key passed. | |
24 */ | |
25 public class WebApkVerifySignature { | |
26 /** Errors codes. */ | |
27 @IntDef({ | |
28 ERROR_OK, ERROR_BAD_APK, ERROR_EXTRA_FIELD_TOO_LARGE, ERROR_COMMENT_ TOO_LARGE, | |
29 ERROR_INCORRECT_SIGNATURE, ERROR_SIGNATURE_NOT_FOUND, ERROR_TOO_MANY _META_INF_FILES, | |
30 }) | |
31 public @interface Error {} | |
32 public static final int ERROR_OK = 0; | |
33 public static final int ERROR_BAD_APK = 1; | |
34 public static final int ERROR_EXTRA_FIELD_TOO_LARGE = 2; | |
35 public static final int ERROR_COMMENT_TOO_LARGE = 3; | |
36 public static final int ERROR_INCORRECT_SIGNATURE = 4; | |
37 public static final int ERROR_SIGNATURE_NOT_FOUND = 5; | |
38 public static final int ERROR_TOO_MANY_META_INF_FILES = 6; | |
39 | |
40 private static final String TAG = "WebApkVerifySignature"; | |
41 | |
42 /** End Of Central Directory Signature. */ | |
43 private static final long EOCD_SIG = 0x06054b50; | |
44 | |
45 /** Central Directory Signature. */ | |
46 private static final long CD_SIG = 0x02014b50; | |
47 | |
48 /** Local File Header Signature. */ | |
49 private static final long LFH_SIG = 0x04034b50; | |
50 | |
51 /** Minimum end-of-central-directory size, including variable length file co mment. */ | |
52 private static final int MIN_EOCD_SIZE = 22; | |
53 | |
54 /** Max local file header size, including long filename. */ | |
55 private static final int MAX_HEADER_SIZE = 64 * 8192; | |
pkotwicz
2017/04/05 02:54:28
Nit: This is likely more readable as:
512 * 1024
ScottK
2017/04/05 20:14:27
Done.
pkotwicz
2017/04/06 17:58:28
What are the units?
ScottK
2017/04/07 21:57:08
Done, I've also shortened to 64k which is more in
| |
56 | |
57 /** Maximum number of META-INF/ files (allowing for dual signing). */ | |
58 private static final int MAX_META_INF_FILES = 5; | |
59 | |
60 /** The signature algorithm used (must also match with HASH). */ | |
61 private static final String SIGNING_ALGORITHM = "SHA256withECDSA"; | |
62 | |
63 /** | |
64 * The pattern we look for in the APK/zip comment for signing key. | |
65 * An example is "webapk:0000:<hexvalues>". This pattern can appear anywhere | |
66 * in the comment but must be separated from any other parts with a | |
67 * separator that doesn't look like a hex character. | |
68 */ | |
69 private static final Pattern WEBAPK_COMMENT_PATTERN = | |
70 Pattern.compile("webapk:\\d+:([a-fA-F0-9]+)"); | |
71 | |
72 /** Maximum comment length permitted. */ | |
73 private static final int MAX_COMMENT_LENGTH = 0; | |
74 | |
75 /** Maximum extra field length permitted. */ | |
76 private static final int MAX_EXTRA_LENGTH = 8; | |
77 | |
78 /** The memory buffer we are going to read the zip from. */ | |
79 private final ByteBuffer mBuffer; | |
80 | |
81 /** Number of total central directory (zip entry) records. */ | |
82 private int mRecordCount; | |
83 | |
84 /** Byte offset from the start where the central directory is found. */ | |
85 private int mCentralDirOffset; | |
86 | |
87 /** The zip archive comment as a UTF-8 string. */ | |
88 private String mComment; | |
89 | |
90 /** | |
91 * Sorted list of 'blocks' of memory we will cryptographically hash. We sort the blocks by | |
92 * filename to ensure a repeatable order. | |
93 */ | |
94 private ArrayList<Block> mBlocks; | |
95 | |
96 /** Block contains metadata about a zip entry. */ | |
97 private static class Block implements Comparable<Block> { | |
98 String mFilename; | |
99 int mPosition; | |
100 int mHeaderSize; | |
101 int mCompressedSize; | |
102 | |
103 Block(String filename, int position, int compressedSize) { | |
104 mFilename = filename; | |
105 mPosition = position; | |
106 mHeaderSize = 0; | |
107 mCompressedSize = compressedSize; | |
108 } | |
109 | |
110 /** Added for Comparable, sort lexicographically. */ | |
111 @Override | |
112 public int compareTo(Block o) { | |
113 return mFilename.compareTo(o.mFilename); | |
114 } | |
115 | |
116 @Override | |
117 public boolean equals(Object o) { | |
118 if (!(o instanceof Block)) return false; | |
119 return mFilename.equals(((Block) o).mFilename); | |
120 } | |
121 | |
122 @Override | |
123 public int hashCode() { | |
124 return mFilename.hashCode(); | |
125 } | |
126 } | |
127 | |
128 /** Constructor simply 'connects' to buffer passed. */ | |
129 public WebApkVerifySignature(ByteBuffer buffer) { | |
130 mBuffer = buffer; | |
131 mBuffer.order(LITTLE_ENDIAN); | |
132 } | |
133 | |
134 /** | |
135 * Read in the comment and directory. If there is no parseable comment we wo n't read the | |
136 * directory as there is no point (for speed). On success, all of our privat e variables will be | |
137 * set. | |
138 * @return OK on success. | |
139 */ | |
140 public int read() { | |
141 try { | |
142 int err = readEOCD(); | |
143 if (err != ERROR_OK) { | |
144 return err; | |
145 } | |
146 // Short circuit if no comment found. | |
147 if (parseCommentSignature(mComment) == null) { | |
148 return ERROR_SIGNATURE_NOT_FOUND; | |
149 } | |
150 err = readDirectory(); | |
151 if (err != ERROR_OK) { | |
152 return err; | |
153 } | |
154 } catch (Exception e) { | |
155 return ERROR_BAD_APK; | |
156 } | |
157 return ERROR_OK; | |
158 } | |
159 | |
160 /** | |
161 * verifySignature hashes all the files and then verifies the signature. | |
162 * @param pub The public key that it should be verified against. | |
163 * @return ERROR_OK if the public key signature verifies. | |
164 */ | |
165 public int verifySignature(PublicKey pub) { | |
166 byte[] sig = parseCommentSignature(mComment); | |
167 if (sig == null || sig.length == 0) { | |
168 return ERROR_SIGNATURE_NOT_FOUND; | |
169 } | |
170 try { | |
171 Signature signature = Signature.getInstance(SIGNING_ALGORITHM); | |
172 signature.initVerify(pub); | |
173 @Error | |
pkotwicz
2017/04/05 02:54:28
Please remove the annotation
ScottK
2017/04/05 20:14:27
Done.
| |
174 int err = calculateHash(signature); | |
175 if (err != ERROR_OK) { | |
176 return err; | |
177 } | |
178 return signature.verify(sig) ? ERROR_OK : ERROR_INCORRECT_SIGNATURE; | |
179 } catch (Exception e) { | |
180 Log.e(TAG, "Exception calculating signature", e); | |
181 return ERROR_INCORRECT_SIGNATURE; | |
182 } | |
183 } | |
184 | |
185 /** @return Full APK/zip comment string. */ | |
186 public String comment() { | |
pkotwicz
2017/04/05 02:54:28
This method looks unused. 💣 it
ScottK
2017/04/05 20:14:28
Done.
| |
187 return mComment; | |
188 } | |
189 | |
190 /** | |
191 * calculateHash goes through each file listed in blocks and calculates the SHA-256 | |
192 * cryptographic hash. | |
193 * @param sig Signature object you can call update on. | |
194 */ | |
195 public int calculateHash(Signature sig) throws Exception { | |
196 Collections.sort(mBlocks); | |
197 int metaInfCount = 0; | |
198 for (Block block : mBlocks) { | |
199 if (block.mFilename.indexOf("META-INF/") == 0) { | |
200 metaInfCount++; | |
201 if (metaInfCount > MAX_META_INF_FILES) { | |
202 return ERROR_TOO_MANY_META_INF_FILES; | |
203 } | |
204 | |
205 // Files that begin with META-INF/ are not part of the hash. | |
206 // This is because these signatures are added after we comment s igned the rest of | |
207 // the APK. | |
208 continue; | |
209 } | |
210 | |
211 // Hash the filename length and filename to prevent Horton principle violation. | |
212 byte[] filename = block.mFilename.getBytes(); | |
213 sig.update(toUInt32BigEndian(filename.length)); | |
214 sig.update(filename); | |
215 | |
216 // Also hash the block length for the same reason. | |
217 sig.update(toUInt32BigEndian(block.mCompressedSize)); | |
218 | |
219 seek(block.mPosition + block.mHeaderSize); | |
220 ByteBuffer slice = mBuffer.slice(); | |
221 slice.limit(block.mCompressedSize); | |
222 sig.update(slice); | |
223 } | |
224 return ERROR_OK; | |
225 } | |
226 | |
227 /** | |
228 * toUInt32BigEndian converts an integer to a big endian array of bytes. | |
229 * @param value Integer value to convert. | |
230 * @return Array of bytes. | |
231 */ | |
232 private byte[] toUInt32BigEndian(int value) { | |
pkotwicz
2017/04/05 02:54:28
Perhaps a better name for this function would be:
ScottK
2017/04/05 20:14:27
Done.
| |
233 ByteBuffer buffer = ByteBuffer.allocate(4); | |
234 buffer.order(BIG_ENDIAN); // Since the ZIP is all little endian, adding this for clarity. | |
pkotwicz
2017/04/05 02:54:29
Can we make this little endian for the sake of con
ScottK
2017/04/05 20:14:27
It matches the documentation and what the server d
pkotwicz
2017/04/06 17:58:29
It matches which documentation?
ScottK
2017/04/07 21:57:08
go/webapk-comment-sig
pkotwicz
2017/04/10 14:43:58
Implementation time (i.e. now) is the perfect time
ScottK
2017/04/13 12:04:00
Done.
| |
235 buffer.putInt(value); | |
236 return buffer.array(); | |
237 } | |
238 | |
239 /** | |
240 * Extract the bytes of the signature from the comment. We expect | |
241 * "webapk:0000:<hexvalues>" comment followed by hex values. Currently we ig nore the | |
242 * "key id" which is always "0000". | |
243 * @return the bytes of the signature. | |
244 */ | |
245 static byte[] parseCommentSignature(String comment) { | |
246 Matcher m = WEBAPK_COMMENT_PATTERN.matcher(comment); | |
247 if (!m.find()) { | |
248 return null; | |
249 } | |
250 String s = m.group(1); | |
251 return hexToBytes(s); | |
252 } | |
253 | |
254 /** | |
255 * Reads the End of Central Directory Record. | |
256 * @return ERROR_OK on success. | |
257 */ | |
258 private int readEOCD() { | |
259 int start = findEOCDStart(); | |
260 if (start < 0) { | |
261 return ERROR_BAD_APK; | |
262 } | |
263 // Signature(4), Disk Number(2), Start disk number(2), Records on this disk (2) | |
264 seek(start + 10); | |
265 mRecordCount = read2(); // Number of Central Directory records | |
266 seekDelta(4); // Size of central directory | |
267 mCentralDirOffset = read4(); // as bytes from start of file. | |
268 int commentLength = read2(); | |
269 mComment = readString(commentLength); | |
270 return ERROR_OK; | |
271 } | |
272 | |
273 /** | |
274 * Reads the central directory and populates {@link mBlocks} with data about each entry. | |
275 * @return ERROR_OK on success. | |
276 */ | |
277 @Error | |
278 int readDirectory() { | |
279 mBlocks = new ArrayList<>(mRecordCount); | |
280 seek(mCentralDirOffset); | |
281 for (int i = 0; i < mRecordCount; i++) { | |
282 int signature = read4(); | |
283 if (signature != CD_SIG) { | |
284 Log.d(TAG, "Missing Central Directory Signature"); | |
285 return ERROR_BAD_APK; | |
286 } | |
287 // CreatorVersion(2), ReaderVersion(2), Flags(2), CompressionMethod( 2) | |
288 // ModifiedTime(2), ModifiedDate(2), CRC32(4) = 16 bytes | |
289 seekDelta(16); | |
290 int compressedSize = read4(); | |
291 seekDelta(4); // uncompressed size | |
292 int fileNameLength = read2(); | |
293 int extraLen = read2(); | |
294 int fileCommentLength = read2(); | |
295 seekDelta(8); // DiskNumberStart(2), Internal Attrs(2), External Att rs(4) | |
296 int offset = read4(); | |
297 String filename = readString(fileNameLength); | |
298 seekDelta(extraLen + fileCommentLength); | |
299 if (extraLen > MAX_EXTRA_LENGTH) { | |
300 Log.w(TAG, | |
301 String.format( | |
302 "Extra field too large for file %s: %d bytes", f ilename, extraLen)); | |
pkotwicz
2017/04/05 02:54:28
Nit: You can remove this log. The log with the err
ScottK
2017/04/05 20:14:28
Done.
| |
303 return ERROR_EXTRA_FIELD_TOO_LARGE; | |
304 } | |
305 if (fileCommentLength > MAX_COMMENT_LENGTH) { | |
306 Log.w(TAG, | |
307 String.format("Unexpected comment field file %s: %d byte s", filename, | |
308 fileCommentLength)); | |
pkotwicz
2017/04/05 02:54:28
Nit: You can remove this log. The log with the err
ScottK
2017/04/05 20:14:27
Done.
| |
309 return ERROR_COMMENT_TOO_LARGE; | |
310 } | |
311 mBlocks.add(new Block(filename, offset, compressedSize)); | |
312 } | |
313 | |
314 // Read the 'local file header' block to the size of the header in bytes . | |
315 for (Block block : mBlocks) { | |
316 seek(block.mPosition); | |
317 int signature = read4(); | |
318 if (signature != LFH_SIG) { | |
319 Log.d(TAG, "LFH Signature missing"); | |
320 return ERROR_BAD_APK; | |
321 } | |
322 // ReaderVersion(2), Flags(2), Method(2), | |
pkotwicz
2017/04/05 02:54:28
Nit: Method(2) -> CompressionMethod(2)
ScottK
2017/04/05 20:14:27
Done.
| |
323 // ModifiedTime (2), ModifiedDate(2), CRC32(4), CompressedSize(4), | |
324 // UncompressedSize(4) = 22 bytes | |
325 seekDelta(22); | |
326 int fileNameLength = read2(); | |
327 int extraFieldLength = read2(); | |
328 if (extraFieldLength > MAX_EXTRA_LENGTH) { | |
329 Log.w(TAG, String.format("Extra field too large: %d bytes", extr aFieldLength)); | |
pkotwicz
2017/04/05 02:54:28
Nit: You can remove this log. The log with the err
ScottK
2017/04/05 20:14:28
Done.
| |
330 return ERROR_EXTRA_FIELD_TOO_LARGE; | |
331 } | |
332 | |
333 block.mHeaderSize = | |
334 (mBuffer.position() - block.mPosition) + fileNameLength + ex traFieldLength; | |
335 } | |
336 return ERROR_OK; | |
337 } | |
338 | |
339 /** | |
340 * We search buffer for EOCD_SIG and return the location where we found it. If the file has no | |
341 * comment it should seek only once. | |
342 * @return Offset from start of buffer or -1 if not found. | |
343 */ | |
344 private int findEOCDStart() { | |
345 int offset = mBuffer.limit() - MIN_EOCD_SIZE; | |
346 int minSearchOffset = Math.max(0, offset - MAX_HEADER_SIZE); | |
347 for (; offset >= minSearchOffset; offset--) { | |
348 if (offset < 0) { | |
349 return -1; | |
350 } | |
pkotwicz
2017/04/05 02:54:28
Nit: We can remove this if() statement now
ScottK
2017/04/05 20:14:27
Done.
| |
351 seek(offset); | |
352 if (read4() == EOCD_SIG) { | |
353 // found! | |
354 return offset; | |
355 } | |
356 } | |
357 return -1; | |
358 } | |
359 | |
360 /** | |
361 * Seek to this position. | |
362 * @param offset offset from start of file. | |
363 */ | |
364 private void seek(int offset) { | |
365 mBuffer.position(offset); | |
366 } | |
367 | |
368 /** | |
369 * Skip forward this number of bytes. | |
370 * @param delta number of bytes to seek forward. | |
371 */ | |
372 private void seekDelta(int delta) { | |
373 mBuffer.position(mBuffer.position() + delta); | |
374 } | |
375 | |
376 /** | |
377 * Reads two bytes in little endian format. | |
378 * @return short value read (as an int). | |
379 */ | |
380 private int read2() { | |
381 return mBuffer.getShort(); | |
382 } | |
383 | |
384 /** | |
385 * Reads four bytes in little endian format. | |
386 * @return value read. | |
387 */ | |
388 private int read4() { | |
389 return mBuffer.getInt(); | |
390 } | |
391 | |
392 /** Read {@link length} many bytes into a string. */ | |
393 private String readString(int length) { | |
394 if (length <= 0) { | |
395 return ""; | |
396 } | |
397 byte[] bytes = new byte[length]; | |
398 mBuffer.get(bytes); | |
399 return new String(bytes); | |
400 } | |
401 | |
402 /** | |
403 * Convert a hex string into bytes. We store hex in the signature as zip | |
404 * tools often don't like binary strings. | |
405 */ | |
406 static byte[] hexToBytes(String s) { | |
407 int len = s.length(); | |
408 if (len % 2 != 0) { | |
409 // Odd number of nibbles. | |
410 return null; | |
411 } | |
412 byte[] data = new byte[len / 2]; | |
413 for (int i = 0; i < len; i += 2) { | |
414 data[i / 2] = (byte) ((Character.digit(s.charAt(i), 16) << 4) | |
415 + Character.digit(s.charAt(i + 1), 16)); | |
416 } | |
417 return data; | |
418 } | |
419 } | |
OLD | NEW |