@capgo/capacitor-updater 8.51.21 → 8.51.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -46,7 +46,7 @@ Perfect for fixing bugs immediately, A/B testing features, and maintaining contr
46
46
  - 🔄 Auto Update: Automatically download and set the latest bundle for the app.
47
47
  - 🛟 Rollback: Reset the app to last working bundle if an incompatible bundle has been set.
48
48
  - 🔁 **Delta Updates**: Make instant updates by only downloading changed files.
49
- - 🔒 **Security**: Encrypt and sign each updates with best in class security standards.
49
+ - 🔒 Security: checksum on every update; encryption optional for high-security apps.
50
50
  - ⚔️ **Battle-Tested**: Used in more than 3000 projects.
51
51
  - 📊 View your deployment statistics
52
52
  - 🔋 Supports Android and iOS
@@ -146,7 +146,7 @@ public class CapacitorUpdaterPlugin extends Plugin {
146
146
  static final int APPLICATION_EXIT_REASON_USER_REQUESTED = 10;
147
147
  static final int APPLICATION_EXIT_REASON_DEPENDENCY_DIED = 12;
148
148
 
149
- private final String pluginVersion = "8.51.21";
149
+ private final String pluginVersion = "8.51.23";
150
150
  private static final String DELAY_CONDITION_PREFERENCES = "";
151
151
 
152
152
  private SharedPreferences.Editor editor;
@@ -2842,6 +2842,16 @@ public class CapacitorUpdaterPlugin extends Plugin {
2842
2842
  final String checksum,
2843
2843
  final JSONArray manifest
2844
2844
  ) throws IOException {
2845
+ if (!this.implementation.publicKey.isEmpty() && !CryptoCipher.isValidSessionKey(sessionKey)) {
2846
+ logger.error("Public key present but no valid session key provided");
2847
+ this.implementation.sendStats("session_key_required");
2848
+ throw new IOException("Session key required when public key is present");
2849
+ }
2850
+ if (manifest == null && (checksum == null || checksum.isEmpty())) {
2851
+ logger.error("No checksum provided");
2852
+ this.implementation.sendStats("checksum_required");
2853
+ throw new IOException("Checksum required");
2854
+ }
2845
2855
  // Manual/preview downloads must wait too — launch orphan sweep can delete their temps.
2846
2856
  waitForCleanupIfNeeded();
2847
2857
  if (manifest != null) {
@@ -4936,7 +4946,13 @@ public class CapacitorUpdaterPlugin extends Plugin {
4936
4946
  );
4937
4947
  return;
4938
4948
  }
4939
- if (latest.isDownloaded() && BundleStatus.DOWNLOADING != latest.getStatus()) {
4949
+ final String latestSessionKey = jsRes.has("sessionKey") ? jsRes.getString("sessionKey") : "";
4950
+ if (
4951
+ latest.isDownloaded() &&
4952
+ BundleStatus.DOWNLOADING != latest.getStatus() &&
4953
+ (CapacitorUpdaterPlugin.this.implementation.publicKey.isEmpty() ||
4954
+ CryptoCipher.isValidSessionKey(latestSessionKey))
4955
+ ) {
4940
4956
  logger.info("Latest bundle already exists and download is NOT required. " + messageUpdate);
4941
4957
  final boolean directUpdateAllowedNow = CapacitorUpdaterPlugin.this.isDirectUpdateCurrentlyAllowed(
4942
4958
  plannedDirectUpdate
@@ -285,6 +285,31 @@ public class CapgoUpdater {
285
285
  this.cachedKeyId = CryptoCipher.calcKeyId(publicKey);
286
286
  }
287
287
 
288
+ private void requireSessionKeyForEncryptedUpdate(final String sessionKey) throws IOException {
289
+ if (!this.publicKey.isEmpty() && !CryptoCipher.isValidSessionKey(sessionKey)) {
290
+ logger.error("Public key present but no valid session key provided");
291
+ this.sendStats("session_key_required");
292
+ throw new IOException("Session key required when public key is present");
293
+ }
294
+ }
295
+
296
+ private void requireBundleChecksum(final String checksum) throws IOException {
297
+ if (checksum == null || checksum.isEmpty()) {
298
+ logger.error("No checksum provided");
299
+ this.sendStats("checksum_required");
300
+ throw new IOException("Checksum required");
301
+ }
302
+ }
303
+
304
+ static boolean containsPathTraversalSegment(final String relativePath) {
305
+ for (final String segment : relativePath.split("/")) {
306
+ if ("..".equals(segment)) {
307
+ return true;
308
+ }
309
+ }
310
+ return false;
311
+ }
312
+
288
313
  static File resolvePathInsideDirectory(final File baseDirectory, final String relativePath) throws IOException {
289
314
  if (relativePath == null || relativePath.isEmpty()) {
290
315
  throw new IOException("Invalid empty path");
@@ -292,6 +317,9 @@ public class CapgoUpdater {
292
317
  if (relativePath.contains("\\") || relativePath.indexOf('\0') >= 0) {
293
318
  throw new IOException("Invalid path separator");
294
319
  }
320
+ if (containsPathTraversalSegment(relativePath)) {
321
+ throw new IOException("Path traversal segments are not allowed");
322
+ }
295
323
  if (new File(relativePath).isAbsolute()) {
296
324
  throw new IOException("Absolute paths are not allowed");
297
325
  }
@@ -302,13 +330,18 @@ public class CapgoUpdater {
302
330
  final String targetPath = canonicalTarget.getPath();
303
331
  final String normalizedBasePath = basePath.endsWith(File.separator) ? basePath : basePath + File.separator;
304
332
 
305
- if (!targetPath.equals(basePath) && !targetPath.startsWith(normalizedBasePath)) {
333
+ // Require a strict child of the base. Equality would accept "." and wipe/write the root.
334
+ if (!targetPath.startsWith(normalizedBasePath)) {
306
335
  throw new IOException("Path escapes base directory: " + relativePath);
307
336
  }
308
337
 
309
338
  return canonicalTarget;
310
339
  }
311
340
 
341
+ static File resolveBundleDirectory(final File documentsDir, final String bundleId) throws IOException {
342
+ return resolvePathInsideDirectory(new File(documentsDir, bundleDirectory), bundleId);
343
+ }
344
+
312
345
  public String getKeyId() {
313
346
  return this.cachedKeyId;
314
347
  }
@@ -376,11 +409,11 @@ public class CapgoUpdater {
376
409
  }
377
410
  }
378
411
 
379
- private void flattenAssets(final File sourceFile, final String dest) throws IOException {
412
+ private void flattenAssets(final File sourceFile, final File destinationFile) throws IOException {
380
413
  if (!sourceFile.exists()) {
381
414
  throw new FileNotFoundException("Source file not found: " + sourceFile.getPath());
382
415
  }
383
- final File destinationFile = new File(this.documentsDir, dest);
416
+ assertPathInsideBundleRoot(destinationFile);
384
417
  Objects.requireNonNull(destinationFile.getParentFile()).mkdirs();
385
418
  final String[] entries = sourceFile.list(this.filter);
386
419
  if (entries == null || entries.length == 0) {
@@ -409,7 +442,13 @@ public class CapgoUpdater {
409
442
  return;
410
443
  }
411
444
 
412
- final File bundleDir = this.getBundleDirectory(id);
445
+ final File bundleDir;
446
+ try {
447
+ bundleDir = this.getBundleDirectory(id);
448
+ } catch (IOException e) {
449
+ logger.debug("Skip delta cache population: invalid bundle id");
450
+ return;
451
+ }
413
452
  if (!bundleDir.exists()) {
414
453
  logger.debug("Skip delta cache population: bundle dir missing");
415
454
  return;
@@ -497,7 +536,10 @@ public class CapgoUpdater {
497
536
  if (fileHash.isEmpty()) {
498
537
  return "";
499
538
  }
500
- if (this.publicKey != null && !this.publicKey.isEmpty() && sessionKey != null && !sessionKey.isEmpty()) {
539
+ if (this.publicKey != null && !this.publicKey.isEmpty()) {
540
+ if (!CryptoCipher.isValidSessionKey(sessionKey)) {
541
+ return "";
542
+ }
501
543
  try {
502
544
  fileHash = CryptoCipher.decryptChecksum(fileHash, this.publicKey);
503
545
  } catch (Exception e) {
@@ -833,31 +875,24 @@ public class CapgoUpdater {
833
875
  String checksum = "";
834
876
 
835
877
  try {
878
+ this.requireSessionKeyForEncryptedUpdate(sessionKey);
836
879
  this.notifyDownload(id, 71);
837
880
  downloaded = new File(this.documentsDir, dest);
838
881
 
839
882
  if (!isManifest) {
840
- String checksumDecrypted = Objects.requireNonNullElse(checksumRes, "");
883
+ String expectedChecksum = Objects.requireNonNullElse(checksumRes, "");
884
+ this.requireBundleChecksum(expectedChecksum);
841
885
 
842
- // If public key is present but no checksum provided, refuse installation
843
- if (!this.publicKey.isEmpty() && checksumDecrypted.isEmpty()) {
844
- logger.error("Public key present but no checksum provided");
845
- this.sendStats("checksum_required");
846
- throw new IOException("Checksum required when public key is present: " + id);
847
- }
848
-
849
- if (!sessionKey.isEmpty()) {
886
+ if (CryptoCipher.isValidSessionKey(sessionKey)) {
850
887
  CryptoCipher.decryptFile(downloaded, publicKey, sessionKey);
851
- checksumDecrypted = CryptoCipher.decryptChecksum(checksumRes, publicKey);
852
- checksum = CryptoCipher.calcChecksum(downloaded);
853
- } else {
854
- checksum = CryptoCipher.calcChecksum(downloaded);
888
+ expectedChecksum = CryptoCipher.decryptChecksum(checksumRes, publicKey);
855
889
  }
890
+ checksum = CryptoCipher.calcChecksum(downloaded);
856
891
  CryptoCipher.logChecksumInfo("Calculated checksum", checksum);
857
- CryptoCipher.logChecksumInfo("Expected checksum", checksumDecrypted);
858
- if ((!checksumDecrypted.isEmpty() || !this.publicKey.isEmpty()) && !checksumDecrypted.equals(checksum)) {
892
+ CryptoCipher.logChecksumInfo("Expected checksum", expectedChecksum);
893
+ if (!expectedChecksum.equals(checksum)) {
859
894
  logger.error("Checksum mismatch");
860
- logger.debug("Expected: " + checksumDecrypted + ", Got: " + checksum);
895
+ logger.debug("Expected: " + expectedChecksum + ", Got: " + checksum);
861
896
  this.sendStats("checksum_fail");
862
897
  throw new IOException("Checksum failed: " + id);
863
898
  }
@@ -885,13 +920,11 @@ public class CapgoUpdater {
885
920
  if (!isManifest) {
886
921
  extractedDir = this.unzip(id, downloaded, TEMP_UNZIP_PREFIX + this.randomString());
887
922
  this.notifyDownload(id, 91);
888
- final String idName = bundleDirectory + "/" + id;
889
- this.flattenAssets(extractedDir, idName);
923
+ this.flattenAssets(extractedDir, this.getBundleDirectory(id));
890
924
  this.cacheBundleFilesAsync(id);
891
925
  } else {
892
926
  this.notifyDownload(id, 91);
893
- final String idName = bundleDirectory + "/" + id;
894
- this.flattenAssets(downloaded, idName);
927
+ this.flattenAssets(downloaded, this.getBundleDirectory(id));
895
928
  downloaded.delete();
896
929
  }
897
930
  // Remove old bundle info and set new one
@@ -1110,18 +1143,44 @@ public class CapgoUpdater {
1110
1143
  }
1111
1144
  }
1112
1145
 
1146
+ private void assertPathInsideDocumentsDir(final File target) throws IOException {
1147
+ if (this.documentsDir == null) {
1148
+ throw new IOException("Documents directory unavailable");
1149
+ }
1150
+ final File canonicalBase = this.documentsDir.getCanonicalFile();
1151
+ final File canonicalTarget = target.getCanonicalFile();
1152
+ final String basePath = canonicalBase.getPath();
1153
+ final String normalizedBasePath = basePath.endsWith(File.separator) ? basePath : basePath + File.separator;
1154
+ final String targetPath = canonicalTarget.getPath();
1155
+ if (!targetPath.equals(basePath) && !targetPath.startsWith(normalizedBasePath)) {
1156
+ throw new IOException("Path escapes updater storage: " + target.getPath());
1157
+ }
1158
+ }
1159
+
1160
+ private void assertPathInsideBundleRoot(final File target) throws IOException {
1161
+ final File bundleRoot = new File(this.documentsDir, bundleDirectory).getCanonicalFile();
1162
+ final File canonicalTarget = target.getCanonicalFile();
1163
+ final String basePath = bundleRoot.getPath();
1164
+ final String normalizedBasePath = basePath.endsWith(File.separator) ? basePath : basePath + File.separator;
1165
+ final String targetPath = canonicalTarget.getPath();
1166
+ if (!targetPath.equals(basePath) && !targetPath.startsWith(normalizedBasePath)) {
1167
+ throw new IOException("Path escapes bundle storage: " + target.getPath());
1168
+ }
1169
+ }
1170
+
1113
1171
  private void safeDelete(final File target) {
1114
1172
  if (target == null || !target.exists()) {
1115
1173
  return;
1116
1174
  }
1117
1175
  try {
1176
+ this.assertPathInsideDocumentsDir(target);
1118
1177
  if (target.isDirectory()) {
1119
1178
  this.deleteDirectory(target);
1120
1179
  } else if (!target.delete()) {
1121
1180
  logger.warn("Failed to delete file: " + target.getAbsolutePath());
1122
1181
  }
1123
1182
  } catch (IOException cleanupError) {
1124
- logger.warn("Cleanup failed for " + target.getAbsolutePath() + ": " + cleanupError.getMessage());
1183
+ logger.warn("Refusing unsafe delete for " + target.getAbsolutePath() + ": " + cleanupError.getMessage());
1125
1184
  }
1126
1185
  }
1127
1186
 
@@ -1395,6 +1454,15 @@ public class CapgoUpdater {
1395
1454
  final JSONArray manifest,
1396
1455
  final boolean setNext
1397
1456
  ) {
1457
+ try {
1458
+ this.requireSessionKeyForEncryptedUpdate(sessionKey);
1459
+ if (manifest == null) {
1460
+ this.requireBundleChecksum(checksum);
1461
+ }
1462
+ } catch (final IOException e) {
1463
+ logger.error("Download blocked: " + e.getMessage());
1464
+ return;
1465
+ }
1398
1466
  if (!this.runDownloadGateQuiet()) {
1399
1467
  return;
1400
1468
  }
@@ -1425,6 +1493,8 @@ public class CapgoUpdater {
1425
1493
  }
1426
1494
 
1427
1495
  public BundleInfo download(final String url, final String version, final String sessionKey, final String checksum) throws IOException {
1496
+ this.requireSessionKeyForEncryptedUpdate(sessionKey);
1497
+ this.requireBundleChecksum(checksum);
1428
1498
  this.runDownloadGate();
1429
1499
  // Check for existing bundle with same version and clean up if in error state
1430
1500
  BundleInfo existingBundle = this.getBundleInfoByName(version);
@@ -1476,6 +1546,7 @@ public class CapgoUpdater {
1476
1546
  final String checksum,
1477
1547
  final JSONArray manifest
1478
1548
  ) throws IOException {
1549
+ this.requireSessionKeyForEncryptedUpdate(sessionKey);
1479
1550
  this.runDownloadGate();
1480
1551
  if (manifest == null) {
1481
1552
  return download(url, version, sessionKey, checksum);
@@ -1557,6 +1628,14 @@ public class CapgoUpdater {
1557
1628
 
1558
1629
  public Boolean delete(final String id, final Boolean removeInfo, final boolean cancelActiveDownload) throws IOException {
1559
1630
  synchronized (this.deleteLock) {
1631
+ final File bundle;
1632
+ try {
1633
+ bundle = this.getBundleDirectory(id);
1634
+ } catch (IOException e) {
1635
+ logger.error("Cannot delete bundle with invalid id");
1636
+ logger.debug("Bundle ID: " + id + ", Error: " + e.getMessage());
1637
+ return false;
1638
+ }
1560
1639
  final BundleInfo deleted = this.getBundleInfo(id);
1561
1640
  if (deleted.isBuiltin() || this.getCurrentBundleId().equals(id)) {
1562
1641
  logger.error("Cannot delete current or builtin bundle");
@@ -1582,7 +1661,6 @@ public class CapgoUpdater {
1582
1661
  return false;
1583
1662
  }
1584
1663
 
1585
- final File bundle = this.getBundleDirectory(id);
1586
1664
  final boolean hadRegistry = this.hasStoredBundleInfo(id);
1587
1665
  final boolean hadFolder = bundle.exists();
1588
1666
  if (!hadRegistry && !hadFolder) {
@@ -1730,12 +1808,17 @@ public class CapgoUpdater {
1730
1808
  this.editor.commit();
1731
1809
  }
1732
1810
 
1733
- private File getBundleDirectory(final String id) {
1734
- return new File(this.documentsDir, bundleDirectory + "/" + id);
1811
+ private File getBundleDirectory(final String id) throws IOException {
1812
+ return resolveBundleDirectory(this.documentsDir, id);
1735
1813
  }
1736
1814
 
1737
1815
  private boolean bundleExists(final String id) {
1738
- final File bundle = this.getBundleDirectory(id);
1816
+ final File bundle;
1817
+ try {
1818
+ bundle = this.getBundleDirectory(id);
1819
+ } catch (IOException e) {
1820
+ return false;
1821
+ }
1739
1822
  final BundleInfo bundleInfo = this.getBundleInfo(id);
1740
1823
  return (
1741
1824
  bundle.isDirectory() &&
@@ -1812,7 +1895,16 @@ public class CapgoUpdater {
1812
1895
  this.reset();
1813
1896
  return true;
1814
1897
  }
1815
- final File bundle = this.getBundleDirectory(id);
1898
+ final File bundle;
1899
+ try {
1900
+ bundle = this.getBundleDirectory(id);
1901
+ } catch (IOException e) {
1902
+ logger.error("Invalid bundle id");
1903
+ logger.debug("Bundle ID: " + id + ", Error: " + e.getMessage());
1904
+ this.setBundleStatus(id, BundleStatus.ERROR);
1905
+ this.sendStats("set_fail", newBundle.getVersionName());
1906
+ return false;
1907
+ }
1816
1908
  logger.info("Setting next active bundle: " + id);
1817
1909
  if (this.bundleExists(id)) {
1818
1910
  var currentBundleName = this.getCurrentBundle().getVersionName();
@@ -1830,8 +1922,12 @@ public class CapgoUpdater {
1830
1922
  if (bundle == null || bundle.isBuiltin() || !this.bundleExists(bundle.getId())) {
1831
1923
  return false;
1832
1924
  }
1833
- this.setCurrentBundle(this.getBundleDirectory(bundle.getId()));
1834
- return true;
1925
+ try {
1926
+ this.setCurrentBundle(this.getBundleDirectory(bundle.getId()));
1927
+ return true;
1928
+ } catch (IOException e) {
1929
+ return false;
1930
+ }
1835
1931
  }
1836
1932
 
1837
1933
  boolean stagePreviewFallbackReload(final BundleInfo bundle) {
@@ -1845,8 +1941,12 @@ public class CapgoUpdater {
1845
1941
  if (!this.bundleExists(bundle.getId())) {
1846
1942
  return false;
1847
1943
  }
1848
- this.setCurrentBundle(this.getBundleDirectory(bundle.getId()));
1849
- return true;
1944
+ try {
1945
+ this.setCurrentBundle(this.getBundleDirectory(bundle.getId()));
1946
+ return true;
1947
+ } catch (IOException e) {
1948
+ return false;
1949
+ }
1850
1950
  }
1851
1951
 
1852
1952
  void finalizePendingReload(final BundleInfo bundle, final String previousBundleName) {
@@ -50,17 +50,15 @@ public class CryptoCipher {
50
50
  return decryptedBytes;
51
51
  }
52
52
 
53
- public static byte[] decryptAES(byte[] cipherText, SecretKey key, byte[] iv) {
54
- try {
55
- IvParameterSpec ivParameterSpec = new IvParameterSpec(iv);
56
- Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
57
- SecretKeySpec keySpec = new SecretKeySpec(key.getEncoded(), "AES");
58
- cipher.init(Cipher.DECRYPT_MODE, keySpec, ivParameterSpec);
59
- return cipher.doFinal(cipherText);
60
- } catch (Exception e) {
61
- e.printStackTrace();
53
+ public static byte[] decryptAES(byte[] cipherText, SecretKey key, byte[] iv) throws GeneralSecurityException {
54
+ if (key == null || iv == null) {
55
+ throw new IllegalArgumentException("AES key and IV must not be null");
62
56
  }
63
- return null;
57
+ IvParameterSpec ivParameterSpec = new IvParameterSpec(iv);
58
+ Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
59
+ SecretKeySpec keySpec = new SecretKeySpec(key.getEncoded(), "AES");
60
+ cipher.init(Cipher.DECRYPT_MODE, keySpec, ivParameterSpec);
61
+ return cipher.doFinal(cipherText);
64
62
  }
65
63
 
66
64
  public static SecretKey byteToSessionKey(byte[] sessionKey) {
@@ -137,13 +135,25 @@ public class CryptoCipher {
137
135
  return buf;
138
136
  }
139
137
 
138
+ public static boolean isValidSessionKey(final String sessionKey) {
139
+ if (sessionKey == null || sessionKey.isEmpty()) {
140
+ return false;
141
+ }
142
+ String[] sessionKeyParts = sessionKey.split(":", -1);
143
+ return sessionKeyParts.length == 2 && !sessionKeyParts[0].isEmpty() && !sessionKeyParts[1].isEmpty();
144
+ }
145
+
140
146
  public static void decryptFile(final File file, final String publicKey, final String ivSessionKey) throws IOException {
141
- if (publicKey.isEmpty() || ivSessionKey == null || ivSessionKey.isEmpty() || ivSessionKey.split(":").length != 2) {
142
- logger.info("Encryption not set, no public key or session, ignored");
147
+ if (publicKey.isEmpty() || !isValidSessionKey(ivSessionKey)) {
148
+ if (logger != null) {
149
+ logger.info("Encryption not set, no public key or session, ignored");
150
+ }
143
151
  return;
144
152
  }
145
153
  if (!publicKey.startsWith("-----BEGIN RSA PUBLIC KEY-----")) {
146
- logger.error("The public key is not a valid RSA Public key");
154
+ if (logger != null) {
155
+ logger.error("The public key is not a valid RSA Public key");
156
+ }
147
157
  return;
148
158
  }
149
159
 
@@ -152,29 +162,50 @@ public class CryptoCipher {
152
162
  String sessionKeyB64 = ivSessionKey.split(":")[1];
153
163
  byte[] iv = Base64.decode(ivB64.getBytes(), Base64.DEFAULT);
154
164
  byte[] sessionKey = Base64.decode(sessionKeyB64.getBytes(), Base64.DEFAULT);
165
+ if (iv.length != 16) {
166
+ throw new IOException("AES file decryption failed: IV must be 16 bytes");
167
+ }
155
168
  PublicKey pKey = CryptoCipher.stringToPublicKey(publicKey);
156
169
  byte[] decryptedSessionKey = CryptoCipher.decryptRSA(sessionKey, pKey);
170
+ if (decryptedSessionKey == null || decryptedSessionKey.length != 16) {
171
+ throw new IOException("AES file decryption failed: decrypted session key must be 16 bytes");
172
+ }
157
173
 
158
174
  SecretKey sKey = CryptoCipher.byteToSessionKey(decryptedSessionKey);
159
175
  decryptAesFile(file, sKey, iv);
160
176
  } catch (GeneralSecurityException e) {
161
- logger.info("decryptFile fail");
162
- throw new IOException("GeneralSecurityException", e);
177
+ if (logger != null) {
178
+ logger.info("decryptFile fail");
179
+ }
180
+ throw new IOException("AES file decryption failed: " + e.getMessage(), e);
163
181
  }
164
182
  }
165
183
 
166
- static void decryptAesFile(File file, SecretKey key, byte[] iv) throws IOException, GeneralSecurityException {
184
+ static void decryptAesFile(File file, SecretKey key, byte[] iv) throws IOException {
185
+ if (key == null) {
186
+ throw new IOException("AES file decryption failed: missing session key");
187
+ }
188
+ if (iv == null) {
189
+ throw new IOException("AES file decryption failed: missing IV");
190
+ }
191
+ if (iv.length != 16) {
192
+ throw new IOException("AES file decryption failed: IV must be 16 bytes");
193
+ }
194
+ byte[] keyBytes = key.getEncoded();
195
+ if (keyBytes == null || keyBytes.length != 16) {
196
+ throw new IOException("AES file decryption failed: session key must be 16 bytes");
197
+ }
167
198
  if (file.length() == 0) {
168
199
  throw new IOException("Empty encrypted data");
169
200
  }
170
- Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
171
- cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(key.getEncoded(), "AES"), new IvParameterSpec(iv));
172
201
  File parent = file.getAbsoluteFile().getParentFile();
173
202
  if (parent == null) {
174
203
  throw new IOException("Cannot create temp file for " + file.getAbsolutePath());
175
204
  }
176
205
  File tempFile = File.createTempFile("capgo-aes-", ".tmp", parent);
177
206
  try {
207
+ Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
208
+ cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(keyBytes, "AES"), new IvParameterSpec(iv));
178
209
  byte[] inBuf = new byte[ioBufferBytes()];
179
210
  // Reuse one output buffer. cipher.update(in) allocates a new byte[] per chunk.
180
211
  byte[] outBuf = new byte[inBuf.length + 16];
@@ -196,6 +227,8 @@ public class CryptoCipher {
196
227
  }
197
228
  replaceFile(tempFile, file);
198
229
  tempFile = null;
230
+ } catch (GeneralSecurityException e) {
231
+ throw new IOException("AES file decryption failed: " + e.getMessage(), e);
199
232
  } finally {
200
233
  if (tempFile != null && tempFile.exists()) {
201
234
  tempFile.delete();