From 66f17d466ad8df8d88a82035109753eaed88ac2d Mon Sep 17 00:00:00 2001 From: Alexander Bakker Date: Sat, 9 May 2020 15:32:57 +0200 Subject: [PATCH] Add support for importing from the new Google Authenticator export QR codes --- app/build.gradle | 17 +++ .../aegis/otp/GoogleAuthInfo.java | 101 +++++++++++++++++- .../aegis/ui/MainActivity.java | 13 ++- .../aegis/ui/ScannerActivity.java | 64 +++++++++-- app/src/main/proto/google_auth.proto | 33 ++++++ app/src/main/res/values/strings.xml | 3 + build.gradle | 3 +- gradle/wrapper/gradle-wrapper.properties | 4 +- 8 files changed, 224 insertions(+), 14 deletions(-) create mode 100644 app/src/main/proto/google_auth.proto diff --git a/app/build.gradle b/app/build.gradle index 86bce593c0..381914ced3 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,4 +1,5 @@ apply plugin: 'com.android.application' +apply plugin: 'com.google.protobuf' def getCmdOutput = { cmd -> def stdout = new ByteArrayOutputStream() @@ -74,9 +75,25 @@ android { } } +protobuf { + protoc { + artifact = 'com.google.protobuf:protoc:3.8.0' + } + generateProtoTasks { + all().each { task -> + task.builtins { + java { + option "lite" + } + } + } + } +} + dependencies { def libsuVersion = '2.5.1' implementation fileTree(dir: 'libs', include: ['*.jar']) + implementation 'com.google.protobuf:protobuf-javalite:3.8.0' implementation 'com.amulyakhare:com.amulyakhare.textdrawable:1.0.1' implementation 'androidx.appcompat:appcompat:1.1.0' implementation "androidx.biometric:biometric:1.0.1" diff --git a/app/src/main/java/com/beemdevelopment/aegis/otp/GoogleAuthInfo.java b/app/src/main/java/com/beemdevelopment/aegis/otp/GoogleAuthInfo.java index c6005c854f..3ca225b834 100644 --- a/app/src/main/java/com/beemdevelopment/aegis/otp/GoogleAuthInfo.java +++ b/app/src/main/java/com/beemdevelopment/aegis/otp/GoogleAuthInfo.java @@ -2,10 +2,19 @@ import android.net.Uri; +import com.beemdevelopment.aegis.GoogleAuthProtos; import com.beemdevelopment.aegis.encoding.Base32; +import com.beemdevelopment.aegis.encoding.Base64; import com.beemdevelopment.aegis.encoding.EncodingException; +import com.google.protobuf.InvalidProtocolBufferException; + +import java.util.ArrayList; +import java.util.List; public class GoogleAuthInfo { + public static final String SCHEME = "otpauth"; + public static final String SCHEME_EXPORT = "otpauth-migration"; + private OtpInfo _info; private String _accountName; private String _issuer; @@ -22,7 +31,7 @@ public OtpInfo getOtpInfo() { public Uri getUri() { Uri.Builder builder = new Uri.Builder(); - builder.scheme("otpauth"); + builder.scheme(SCHEME); if (_info instanceof TotpInfo) { if (_info instanceof SteamInfo) { @@ -62,7 +71,7 @@ public static GoogleAuthInfo parseUri(String s) throws GoogleAuthInfoException { public static GoogleAuthInfo parseUri(Uri uri) throws GoogleAuthInfoException { String scheme = uri.getScheme(); - if (scheme == null || !scheme.equals("otpauth")) { + if (scheme == null || !scheme.equals(SCHEME)) { throw new GoogleAuthInfoException("Unsupported protocol"); } @@ -164,6 +173,64 @@ public static GoogleAuthInfo parseUri(Uri uri) throws GoogleAuthInfoException { return new GoogleAuthInfo(info, accountName, issuer); } + public static Export parseExportUri(String s) throws GoogleAuthInfoException { + Uri uri = Uri.parse(s); + if (uri == null) { + throw new GoogleAuthInfoException("Bad URI format"); + } + return GoogleAuthInfo.parseExportUri(uri); + } + + public static Export parseExportUri(Uri uri) throws GoogleAuthInfoException { + String scheme = uri.getScheme(); + if (scheme == null || !scheme.equals(SCHEME_EXPORT)) { + throw new GoogleAuthInfoException("Unsupported protocol"); + } + + String host = uri.getHost(); + if (host == null || !host.equals("offline")) { + throw new GoogleAuthInfoException("Unsupported host"); + } + + String data = uri.getQueryParameter("data"); + if (data == null) { + throw new GoogleAuthInfoException("Parameter 'data' is not set"); + } + + GoogleAuthProtos.MigrationPayload payload; + try { + byte[] bytes = Base64.decode(data); + payload = GoogleAuthProtos.MigrationPayload.parseFrom(bytes); + } catch (EncodingException | InvalidProtocolBufferException e) { + throw new GoogleAuthInfoException(e); + } + + List infos = new ArrayList<>(); + for (GoogleAuthProtos.MigrationPayload.OtpParameters params : payload.getOtpParametersList()) { + OtpInfo otp; + try { + byte[] secret = params.getSecret().toByteArray(); + switch (params.getType()) { + case OTP_HOTP: + otp = new HotpInfo(secret, params.getCounter()); + break; + case OTP_TOTP: + otp = new TotpInfo(secret); + break; + default: + throw new GoogleAuthInfoException(String.format("Unsupported algorithm: %d", params.getType().ordinal())); + } + } catch (OtpInfoException e){ + throw new GoogleAuthInfoException(e); + } + + GoogleAuthInfo info = new GoogleAuthInfo(otp, params.getName(), params.getIssuer()); + infos.add(info); + } + + return new Export(infos, payload.getBatchId(), payload.getBatchIndex(), payload.getBatchSize()); + } + public String getIssuer() { return _issuer; } @@ -171,4 +238,34 @@ public String getIssuer() { public String getAccountName() { return _accountName; } + + public static class Export { + private int _batchId; + private int _batchIndex; + private int _batchSize; + private List _entries; + + public Export(List entries, int batchId, int batchIndex, int batchSize) { + _batchId = batchId; + _batchIndex = batchIndex; + _batchSize = batchSize; + _entries = entries; + } + + public List getEntries() { + return _entries; + } + + public int getBatchSize() { + return _batchSize; + } + + public int getBatchIndex() { + return _batchIndex; + } + + public int getBatchId() { + return _batchId; + } + } } diff --git a/app/src/main/java/com/beemdevelopment/aegis/ui/MainActivity.java b/app/src/main/java/com/beemdevelopment/aegis/ui/MainActivity.java index 8b70f4b988..46aff94ae6 100644 --- a/app/src/main/java/com/beemdevelopment/aegis/ui/MainActivity.java +++ b/app/src/main/java/com/beemdevelopment/aegis/ui/MainActivity.java @@ -248,8 +248,17 @@ private void startEditEntryActivity(int requestCode, VaultEntry entry, boolean i } private void onScanResult(Intent data) { - VaultEntry entry = (VaultEntry) data.getSerializableExtra("entry"); - startEditEntryActivity(CODE_ADD_ENTRY, entry, true); + List entries = (ArrayList) data.getSerializableExtra("entries"); + if (entries.size() == 1) { + startEditEntryActivity(CODE_ADD_ENTRY, entries.get(0), true); + } else { + for (VaultEntry entry : entries) { + _vault.addEntry(entry); + _entryListView.addEntry(entry); + } + + saveVault(); + } } private void onAddEntryResult(Intent data) { diff --git a/app/src/main/java/com/beemdevelopment/aegis/ui/ScannerActivity.java b/app/src/main/java/com/beemdevelopment/aegis/ui/ScannerActivity.java index 2cd99d6a4d..ca1120b336 100644 --- a/app/src/main/java/com/beemdevelopment/aegis/ui/ScannerActivity.java +++ b/app/src/main/java/com/beemdevelopment/aegis/ui/ScannerActivity.java @@ -3,6 +3,7 @@ import android.content.Context; import android.content.Intent; import android.hardware.Camera; +import android.net.Uri; import android.os.Bundle; import android.view.Menu; import android.view.MenuItem; @@ -10,14 +11,16 @@ import com.beemdevelopment.aegis.R; import com.beemdevelopment.aegis.Theme; -import com.beemdevelopment.aegis.vault.VaultEntry; import com.beemdevelopment.aegis.helpers.SquareFinderView; import com.beemdevelopment.aegis.otp.GoogleAuthInfo; import com.beemdevelopment.aegis.otp.GoogleAuthInfoException; +import com.beemdevelopment.aegis.vault.VaultEntry; import com.google.zxing.BarcodeFormat; import com.google.zxing.Result; +import java.util.ArrayList; import java.util.Collections; +import java.util.List; import me.dm7.barcodescanner.core.IViewFinder; import me.dm7.barcodescanner.zxing.ZXingScannerView; @@ -30,10 +33,15 @@ public class ScannerActivity extends AegisActivity implements ZXingScannerView.R private Menu _menu; private int _facing = CAMERA_FACING_BACK; + private int _batchId = 0; + private int _batchIndex = -1; + private List _entries; + @Override protected void onCreate(Bundle state) { super.onCreate(state); + _entries = new ArrayList<>(); _scannerView = new ZXingScannerView(this) { @Override protected IViewFinder createViewFinderView(Context context) { @@ -107,13 +115,14 @@ public void onPause() { @Override public void handleResult(Result rawResult) { try { - GoogleAuthInfo info = GoogleAuthInfo.parseUri(rawResult.getText().trim()); - VaultEntry entry = new VaultEntry(info); + Uri uri = Uri.parse(rawResult.getText().trim()); + if (uri.getScheme() != null && uri.getScheme().equals(GoogleAuthInfo.SCHEME_EXPORT)) { + handleExportUri(uri); + } else { + handleUri(uri); + } - Intent intent = new Intent(); - intent.putExtra("entry", entry); - setResult(RESULT_OK, intent); - finish(); + _scannerView.resumeCameraPreview(this); } catch (GoogleAuthInfoException e) { e.printStackTrace(); Dialogs.showErrorDialog(this, R.string.read_qr_error, e, (dialog, which) -> { @@ -122,6 +131,47 @@ public void handleResult(Result rawResult) { } } + private void handleUri(Uri uri) throws GoogleAuthInfoException { + GoogleAuthInfo info = GoogleAuthInfo.parseUri(uri); + List entries = new ArrayList<>(); + entries.add(new VaultEntry(info)); + finish(entries); + } + + private void handleExportUri(Uri uri) throws GoogleAuthInfoException { + GoogleAuthInfo.Export export = GoogleAuthInfo.parseExportUri(uri); + + if (_batchId == 0) { + _batchId = export.getBatchId(); + } + + int batchIndex = export.getBatchIndex(); + if (_batchId != export.getBatchId()) { + Toast.makeText(this, R.string.google_qr_export_unrelated, Toast.LENGTH_SHORT).show(); + } else if (_batchIndex == -1 || _batchIndex == batchIndex - 1) { + for (GoogleAuthInfo info : export.getEntries()) { + VaultEntry entry = new VaultEntry(info); + _entries.add(entry); + } + + _batchIndex = batchIndex; + if (_batchIndex + 1 == export.getBatchSize()) { + finish(_entries); + } + + Toast.makeText(this, getString(R.string.google_qr_export_scanned, _batchIndex + 1, export.getBatchSize()), Toast.LENGTH_SHORT).show(); + } else if (_batchIndex != batchIndex) { + Toast.makeText(this, getString(R.string.google_qr_export_unexpected, _batchIndex + 1, batchIndex + 1), Toast.LENGTH_SHORT).show(); + } + } + + private void finish(List entries) { + Intent intent = new Intent(); + intent.putExtra("entries", (ArrayList) entries); + setResult(RESULT_OK, intent); + finish(); + } + private void updateCameraIcon() { if (_menu != null) { MenuItem item = _menu.findItem(R.id.action_camera); diff --git a/app/src/main/proto/google_auth.proto b/app/src/main/proto/google_auth.proto new file mode 100644 index 0000000000..4346853fda --- /dev/null +++ b/app/src/main/proto/google_auth.proto @@ -0,0 +1,33 @@ +syntax = "proto3"; + +option java_package = "com.beemdevelopment.aegis"; +option java_outer_classname = "GoogleAuthProtos"; + +message MigrationPayload { + enum Algorithm { + ALGO_INVALID = 0; + ALGO_SHA1 = 1; + } + + enum OtpType { + OTP_INVALID = 0; + OTP_HOTP = 1; + OTP_TOTP = 2; + } + + message OtpParameters { + bytes secret = 1; + string name = 2; + string issuer = 3; + Algorithm algorithm = 4; + int32 digits = 5; + OtpType type = 6; + int64 counter = 7; + } + + repeated OtpParameters otp_parameters = 1; + int32 version = 2; + int32 batch_size = 3; + int32 batch_index = 4; + int32 batch_id = 5; +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 047eaaa4b6..7d2e1f3595 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -234,6 +234,9 @@ Automatic time synchronization Aegis relies on the system time to be in sync to generate correct codes. A deviation of only a few seconds could result in incorrect codes. It looks like your device is not configured to automatically synchronize the time. Would you like to do so now? Stop warning me. I know what I\'m doing. + Unrelated QR code found. Try restarting the scanner. + Scanned %d/%d QR codes + Expected QR code #%d, but scanned #%d instead body { diff --git a/build.gradle b/build.gradle index bbde53a416..f3c5bed539 100644 --- a/build.gradle +++ b/build.gradle @@ -6,7 +6,8 @@ buildscript { google() } dependencies { - classpath 'com.android.tools.build:gradle:3.5.3' + classpath 'com.android.tools.build:gradle:3.6.3' + classpath 'com.google.protobuf:protobuf-gradle-plugin:0.8.12' // NOTE: Do not place your application dependencies here; they belong // in the individual module build.gradle files diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 38dd1109e1..9d87c537f2 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ -#Sun Sep 01 21:12:20 CEST 2019 +#Fri May 08 13:48:01 GMT 2020 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-5.4.1-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-5.6.4-all.zip