Repository navigation
feat: store camera URLs encrypted with an Android Keystore key (part of #16) #54
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
116 changes: 116 additions & 0 deletions
116
android/app/src/main/java/com/openipc/decoder/CredentialStore.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,116 @@ | ||
| /* | ||
| * Copyright (c) OpenIPC https://openipc.org Prosperity Public License 3.0.0 | ||
| * | ||
| * CredentialStore.java — camera URLs (with passwords) encrypted at rest | ||
| * | ||
| */ | ||
|
|
||
| package com.openipc.decoder; | ||
|
|
||
| import android.os.Build; | ||
| import android.util.Log; | ||
|
|
||
| import java.nio.charset.StandardCharsets; | ||
|
|
||
| /** | ||
| * Seals camera URLs before they go to SharedPreferences and opens them on load (#16). | ||
| * The key lives in the Android Keystore (Android 6.0+) and never leaves it; on older | ||
| * versions, or if the Keystore fails, URLs are stored as before. | ||
| */ | ||
| class CredentialStore { | ||
| private static final String TAG = "OpenIPCDecoder"; | ||
| static final String PREFIX = "enc1:"; | ||
|
|
||
| /** Authenticated encryption; the output carries everything needed to decrypt it. */ | ||
| interface Cipherer { | ||
| byte[] encrypt(byte[] plain) throws Exception; | ||
|
|
||
| byte[] decrypt(byte[] sealed) throws Exception; | ||
| } | ||
|
|
||
| private final Cipherer cipher; | ||
|
|
||
| CredentialStore(Cipherer cipher) { | ||
| this.cipher = cipher; | ||
| } | ||
|
|
||
| static CredentialStore create() { | ||
| Cipherer c = null; | ||
| if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { | ||
| try { | ||
| c = new KeystoreCipher(); | ||
| } catch (Exception e) { | ||
| Log.w(TAG, "Android Keystore unavailable, URLs stored unencrypted: " + e); | ||
| } | ||
| } | ||
| return new CredentialStore(c); | ||
| } | ||
|
|
||
| boolean encrypts() { return cipher != null; } | ||
|
|
||
| /** Value to store for {@code url}; null stays null. */ | ||
| String seal(String url) { | ||
| if (url == null || cipher == null) return url; | ||
| try { | ||
| return PREFIX + toHex(cipher.encrypt(url.getBytes(StandardCharsets.UTF_8))); | ||
|
qodo-free-for-open-source-projects[bot] marked this conversation as resolved.
|
||
| } catch (Exception e) { | ||
| Log.w(TAG, "Cannot encrypt camera URL, storing it unencrypted: " + e); | ||
| return url; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * URL from a stored value: sealed values are decrypted, older plain values are returned | ||
| * as they are. Returns null if a sealed value cannot be opened (e.g. the key is gone | ||
| * after a restore to another device), so that the slot shows as not configured. | ||
| */ | ||
| String open(String stored) { | ||
| if (stored == null || !stored.startsWith(PREFIX)) return stored; | ||
| if (cipher == null) return null; | ||
| try { | ||
| byte[] plain = cipher.decrypt(fromHex(stored.substring(PREFIX.length()))); | ||
| return new String(plain, StandardCharsets.UTF_8); | ||
| } catch (Exception e) { | ||
| Log.w(TAG, "Cannot decrypt a stored camera URL: " + e); | ||
| return null; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Value to store for a slot. If the slot's stored value could not be opened | ||
| * ({@code unreadable} != null) and the slot still shows the placeholder, the original | ||
| * stored value is kept, so a temporarily unavailable key does not destroy it when | ||
| * another slot is saved. A slot the user edited is sealed as usual. | ||
| */ | ||
| String sealForSlot(String url, String unreadable, String placeholder) { | ||
| if (unreadable != null && (url == null || url.equals(placeholder))) return unreadable; | ||
| return seal(url); | ||
| } | ||
|
|
||
| /** True if {@code stored} is a plain value that should be re-saved sealed. */ | ||
| boolean needsMigration(String stored) { | ||
| return cipher != null && stored != null && !stored.startsWith(PREFIX); | ||
| } | ||
|
|
||
| static String toHex(byte[] b) { | ||
| char[] digits = "0123456789abcdef".toCharArray(); | ||
| char[] out = new char[b.length * 2]; | ||
| for (int i = 0; i < b.length; i++) { | ||
| out[2 * i] = digits[(b[i] >> 4) & 0xF]; | ||
| out[2 * i + 1] = digits[b[i] & 0xF]; | ||
| } | ||
| return new String(out); | ||
| } | ||
|
|
||
| static byte[] fromHex(String s) { | ||
| if (s.length() % 2 != 0) throw new IllegalArgumentException("odd length"); | ||
| byte[] out = new byte[s.length() / 2]; | ||
| for (int i = 0; i < out.length; i++) { | ||
| int hi = Character.digit(s.charAt(2 * i), 16); | ||
| int lo = Character.digit(s.charAt(2 * i + 1), 16); | ||
| if (hi < 0 || lo < 0) throw new IllegalArgumentException("not hex"); | ||
| out[i] = (byte) ((hi << 4) | lo); | ||
| } | ||
| return out; | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
75 changes: 75 additions & 0 deletions
75
android/app/src/main/java/com/openipc/decoder/KeystoreCipher.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,75 @@ | ||
| /* | ||
| * Copyright (c) OpenIPC https://openipc.org Prosperity Public License 3.0.0 | ||
| * | ||
| * KeystoreCipher.java — AES-GCM with a key kept in the Android Keystore | ||
| * | ||
| */ | ||
|
|
||
| package com.openipc.decoder; | ||
|
|
||
| import android.annotation.SuppressLint; | ||
| import android.annotation.TargetApi; | ||
| import android.os.Build; | ||
| import android.security.keystore.KeyGenParameterSpec; | ||
| import android.security.keystore.KeyProperties; | ||
|
|
||
| import java.security.KeyStore; | ||
|
|
||
| import javax.crypto.Cipher; | ||
| import javax.crypto.KeyGenerator; | ||
| import javax.crypto.SecretKey; | ||
| import javax.crypto.spec.GCMParameterSpec; | ||
|
|
||
| /** | ||
| * Output layout: 12-byte IV followed by the ciphertext with its 16-byte GCM tag. | ||
| * Only created behind an SDK_INT check in CredentialStore.create(); @RequiresApi would need | ||
| * the AndroidX annotation library, which the app does not use. | ||
| */ | ||
| @SuppressLint("UseRequiresApi") | ||
| @TargetApi(Build.VERSION_CODES.M) | ||
| class KeystoreCipher implements CredentialStore.Cipherer { | ||
| private static final String KEYSTORE = "AndroidKeyStore"; | ||
| private static final String ALIAS = "camera_urls"; | ||
| private static final String TRANSFORMATION = "AES/GCM/NoPadding"; | ||
| private static final int IV_LENGTH = 12; | ||
| private static final int TAG_BITS = 128; | ||
|
|
||
| private final SecretKey key; | ||
|
|
||
| KeystoreCipher() throws Exception { | ||
| KeyStore ks = KeyStore.getInstance(KEYSTORE); | ||
| ks.load(null); | ||
| if (!ks.containsAlias(ALIAS)) { | ||
| KeyGenerator gen = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE); | ||
| gen.init(new KeyGenParameterSpec.Builder(ALIAS, | ||
| KeyProperties.PURPOSE_ENCRYPT | KeyProperties.PURPOSE_DECRYPT) | ||
| .setBlockModes(KeyProperties.BLOCK_MODE_GCM) | ||
| .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) | ||
| .setKeySize(256) | ||
| .build()); | ||
| gen.generateKey(); | ||
| } | ||
| key = (SecretKey) ks.getKey(ALIAS, null); | ||
| if (key == null) throw new IllegalStateException("No key in the Android Keystore"); | ||
| } | ||
|
|
||
| @Override | ||
| public byte[] encrypt(byte[] plain) throws Exception { | ||
| Cipher c = Cipher.getInstance(TRANSFORMATION); | ||
| c.init(Cipher.ENCRYPT_MODE, key); // the Keystore picks a random IV | ||
| byte[] iv = c.getIV(); | ||
| byte[] ct = c.doFinal(plain); | ||
| byte[] out = new byte[iv.length + ct.length]; | ||
| System.arraycopy(iv, 0, out, 0, iv.length); | ||
| System.arraycopy(ct, 0, out, iv.length, ct.length); | ||
| return out; | ||
| } | ||
|
|
||
| @Override | ||
| public byte[] decrypt(byte[] sealed) throws Exception { | ||
| if (sealed.length <= IV_LENGTH) throw new IllegalArgumentException("too short"); | ||
| Cipher c = Cipher.getInstance(TRANSFORMATION); | ||
| c.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(TAG_BITS, sealed, 0, IV_LENGTH)); | ||
| return c.doFinal(sealed, IV_LENGTH, sealed.length - IV_LENGTH); | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.