Skip to content

Commit 56bde0e

Browse files
committed
Add support for importing from the new Google Authenticator export QR codes
1 parent 6b650e7 commit 56bde0e

File tree

9 files changed

+234
-14
lines changed

9 files changed

+234
-14
lines changed

app/build.gradle

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
apply plugin: 'com.android.application'
2+
apply plugin: 'com.google.protobuf'
23

34
def getCmdOutput = { cmd ->
45
def stdout = new ByteArrayOutputStream()
@@ -74,9 +75,25 @@ android {
7475
}
7576
}
7677

78+
protobuf {
79+
protoc {
80+
artifact = 'com.google.protobuf:protoc:3.8.0'
81+
}
82+
generateProtoTasks {
83+
all().each { task ->
84+
task.builtins {
85+
java {
86+
option "lite"
87+
}
88+
}
89+
}
90+
}
91+
}
92+
7793
dependencies {
7894
def libsuVersion = '2.5.1'
7995
implementation fileTree(dir: 'libs', include: ['*.jar'])
96+
implementation 'com.google.protobuf:protobuf-javalite:3.8.0'
8097
implementation 'com.amulyakhare:com.amulyakhare.textdrawable:1.0.1'
8198
implementation 'androidx.appcompat:appcompat:1.1.0'
8299
implementation "androidx.biometric:biometric:1.0.1"

app/proguard-rules.pro

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,5 @@
1818

1919
-keep class com.beemdevelopment.aegis.importers.** { *; }
2020
-keep class net.sqlcipher.** { *; }
21+
22+
-keep class * extends com.google.protobuf.GeneratedMessageLite { *; }

app/src/main/java/com/beemdevelopment/aegis/otp/GoogleAuthInfo.java

Lines changed: 107 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,19 @@
22

33
import android.net.Uri;
44

5+
import com.beemdevelopment.aegis.GoogleAuthProtos;
56
import com.beemdevelopment.aegis.encoding.Base32;
7+
import com.beemdevelopment.aegis.encoding.Base64;
68
import com.beemdevelopment.aegis.encoding.EncodingException;
9+
import com.google.protobuf.InvalidProtocolBufferException;
10+
11+
import java.util.ArrayList;
12+
import java.util.List;
713

814
public class GoogleAuthInfo {
15+
public static final String SCHEME = "otpauth";
16+
public static final String SCHEME_EXPORT = "otpauth-migration";
17+
918
private OtpInfo _info;
1019
private String _accountName;
1120
private String _issuer;
@@ -22,7 +31,7 @@ public OtpInfo getOtpInfo() {
2231

2332
public Uri getUri() {
2433
Uri.Builder builder = new Uri.Builder();
25-
builder.scheme("otpauth");
34+
builder.scheme(SCHEME);
2635

2736
if (_info instanceof TotpInfo) {
2837
if (_info instanceof SteamInfo) {
@@ -62,7 +71,7 @@ public static GoogleAuthInfo parseUri(String s) throws GoogleAuthInfoException {
6271

6372
public static GoogleAuthInfo parseUri(Uri uri) throws GoogleAuthInfoException {
6473
String scheme = uri.getScheme();
65-
if (scheme == null || !scheme.equals("otpauth")) {
74+
if (scheme == null || !scheme.equals(SCHEME)) {
6675
throw new GoogleAuthInfoException("Unsupported protocol");
6776
}
6877

@@ -164,11 +173,107 @@ public static GoogleAuthInfo parseUri(Uri uri) throws GoogleAuthInfoException {
164173
return new GoogleAuthInfo(info, accountName, issuer);
165174
}
166175

176+
public static Export parseExportUri(String s) throws GoogleAuthInfoException {
177+
Uri uri = Uri.parse(s);
178+
if (uri == null) {
179+
throw new GoogleAuthInfoException("Bad URI format");
180+
}
181+
return GoogleAuthInfo.parseExportUri(uri);
182+
}
183+
184+
public static Export parseExportUri(Uri uri) throws GoogleAuthInfoException {
185+
String scheme = uri.getScheme();
186+
if (scheme == null || !scheme.equals(SCHEME_EXPORT)) {
187+
throw new GoogleAuthInfoException("Unsupported protocol");
188+
}
189+
190+
String host = uri.getHost();
191+
if (host == null || !host.equals("offline")) {
192+
throw new GoogleAuthInfoException("Unsupported host");
193+
}
194+
195+
String data = uri.getQueryParameter("data");
196+
if (data == null) {
197+
throw new GoogleAuthInfoException("Parameter 'data' is not set");
198+
}
199+
200+
GoogleAuthProtos.MigrationPayload payload;
201+
try {
202+
byte[] bytes = Base64.decode(data);
203+
payload = GoogleAuthProtos.MigrationPayload.parseFrom(bytes);
204+
} catch (EncodingException | InvalidProtocolBufferException e) {
205+
throw new GoogleAuthInfoException(e);
206+
}
207+
208+
List<GoogleAuthInfo> infos = new ArrayList<>();
209+
for (GoogleAuthProtos.MigrationPayload.OtpParameters params : payload.getOtpParametersList()) {
210+
OtpInfo otp;
211+
try {
212+
byte[] secret = params.getSecret().toByteArray();
213+
switch (params.getType()) {
214+
case OTP_HOTP:
215+
otp = new HotpInfo(secret, params.getCounter());
216+
break;
217+
case OTP_TOTP:
218+
otp = new TotpInfo(secret);
219+
break;
220+
default:
221+
throw new GoogleAuthInfoException(String.format("Unsupported algorithm: %d", params.getType().ordinal()));
222+
}
223+
} catch (OtpInfoException e){
224+
throw new GoogleAuthInfoException(e);
225+
}
226+
227+
String name = params.getName();
228+
String issuer = params.getIssuer();
229+
int colonI = name.indexOf(':');
230+
if (issuer.isEmpty() && colonI != -1) {
231+
issuer = name.substring(0, colonI);
232+
name = name.substring(colonI + 1);
233+
}
234+
235+
GoogleAuthInfo info = new GoogleAuthInfo(otp, name, issuer);
236+
infos.add(info);
237+
}
238+
239+
return new Export(infos, payload.getBatchId(), payload.getBatchIndex(), payload.getBatchSize());
240+
}
241+
167242
public String getIssuer() {
168243
return _issuer;
169244
}
170245

171246
public String getAccountName() {
172247
return _accountName;
173248
}
249+
250+
public static class Export {
251+
private int _batchId;
252+
private int _batchIndex;
253+
private int _batchSize;
254+
private List<GoogleAuthInfo> _entries;
255+
256+
public Export(List<GoogleAuthInfo> entries, int batchId, int batchIndex, int batchSize) {
257+
_batchId = batchId;
258+
_batchIndex = batchIndex;
259+
_batchSize = batchSize;
260+
_entries = entries;
261+
}
262+
263+
public List<GoogleAuthInfo> getEntries() {
264+
return _entries;
265+
}
266+
267+
public int getBatchSize() {
268+
return _batchSize;
269+
}
270+
271+
public int getBatchIndex() {
272+
return _batchIndex;
273+
}
274+
275+
public int getBatchId() {
276+
return _batchId;
277+
}
278+
}
174279
}

app/src/main/java/com/beemdevelopment/aegis/ui/MainActivity.java

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -248,8 +248,17 @@ private void startEditEntryActivity(int requestCode, VaultEntry entry, boolean i
248248
}
249249

250250
private void onScanResult(Intent data) {
251-
VaultEntry entry = (VaultEntry) data.getSerializableExtra("entry");
252-
startEditEntryActivity(CODE_ADD_ENTRY, entry, true);
251+
List<VaultEntry> entries = (ArrayList<VaultEntry>) data.getSerializableExtra("entries");
252+
if (entries.size() == 1) {
253+
startEditEntryActivity(CODE_ADD_ENTRY, entries.get(0), true);
254+
} else {
255+
for (VaultEntry entry : entries) {
256+
_vault.addEntry(entry);
257+
_entryListView.addEntry(entry);
258+
}
259+
260+
saveVault();
261+
}
253262
}
254263

255264
private void onAddEntryResult(Intent data) {

app/src/main/java/com/beemdevelopment/aegis/ui/ScannerActivity.java

Lines changed: 57 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,21 +3,24 @@
33
import android.content.Context;
44
import android.content.Intent;
55
import android.hardware.Camera;
6+
import android.net.Uri;
67
import android.os.Bundle;
78
import android.view.Menu;
89
import android.view.MenuItem;
910
import android.widget.Toast;
1011

1112
import com.beemdevelopment.aegis.R;
1213
import com.beemdevelopment.aegis.Theme;
13-
import com.beemdevelopment.aegis.vault.VaultEntry;
1414
import com.beemdevelopment.aegis.helpers.SquareFinderView;
1515
import com.beemdevelopment.aegis.otp.GoogleAuthInfo;
1616
import com.beemdevelopment.aegis.otp.GoogleAuthInfoException;
17+
import com.beemdevelopment.aegis.vault.VaultEntry;
1718
import com.google.zxing.BarcodeFormat;
1819
import com.google.zxing.Result;
1920

21+
import java.util.ArrayList;
2022
import java.util.Collections;
23+
import java.util.List;
2124

2225
import me.dm7.barcodescanner.core.IViewFinder;
2326
import me.dm7.barcodescanner.zxing.ZXingScannerView;
@@ -30,10 +33,15 @@ public class ScannerActivity extends AegisActivity implements ZXingScannerView.R
3033
private Menu _menu;
3134
private int _facing = CAMERA_FACING_BACK;
3235

36+
private int _batchId = 0;
37+
private int _batchIndex = -1;
38+
private List<VaultEntry> _entries;
39+
3340
@Override
3441
protected void onCreate(Bundle state) {
3542
super.onCreate(state);
3643

44+
_entries = new ArrayList<>();
3745
_scannerView = new ZXingScannerView(this) {
3846
@Override
3947
protected IViewFinder createViewFinderView(Context context) {
@@ -107,13 +115,14 @@ public void onPause() {
107115
@Override
108116
public void handleResult(Result rawResult) {
109117
try {
110-
GoogleAuthInfo info = GoogleAuthInfo.parseUri(rawResult.getText().trim());
111-
VaultEntry entry = new VaultEntry(info);
118+
Uri uri = Uri.parse(rawResult.getText().trim());
119+
if (uri.getScheme() != null && uri.getScheme().equals(GoogleAuthInfo.SCHEME_EXPORT)) {
120+
handleExportUri(uri);
121+
} else {
122+
handleUri(uri);
123+
}
112124

113-
Intent intent = new Intent();
114-
intent.putExtra("entry", entry);
115-
setResult(RESULT_OK, intent);
116-
finish();
125+
_scannerView.resumeCameraPreview(this);
117126
} catch (GoogleAuthInfoException e) {
118127
e.printStackTrace();
119128
Dialogs.showErrorDialog(this, R.string.read_qr_error, e, (dialog, which) -> {
@@ -122,6 +131,47 @@ public void handleResult(Result rawResult) {
122131
}
123132
}
124133

134+
private void handleUri(Uri uri) throws GoogleAuthInfoException {
135+
GoogleAuthInfo info = GoogleAuthInfo.parseUri(uri);
136+
List<VaultEntry> entries = new ArrayList<>();
137+
entries.add(new VaultEntry(info));
138+
finish(entries);
139+
}
140+
141+
private void handleExportUri(Uri uri) throws GoogleAuthInfoException {
142+
GoogleAuthInfo.Export export = GoogleAuthInfo.parseExportUri(uri);
143+
144+
if (_batchId == 0) {
145+
_batchId = export.getBatchId();
146+
}
147+
148+
int batchIndex = export.getBatchIndex();
149+
if (_batchId != export.getBatchId()) {
150+
Toast.makeText(this, R.string.google_qr_export_unrelated, Toast.LENGTH_SHORT).show();
151+
} else if (_batchIndex == -1 || _batchIndex == batchIndex - 1) {
152+
for (GoogleAuthInfo info : export.getEntries()) {
153+
VaultEntry entry = new VaultEntry(info);
154+
_entries.add(entry);
155+
}
156+
157+
_batchIndex = batchIndex;
158+
if (_batchIndex + 1 == export.getBatchSize()) {
159+
finish(_entries);
160+
}
161+
162+
Toast.makeText(this, getString(R.string.google_qr_export_scanned, _batchIndex + 1, export.getBatchSize()), Toast.LENGTH_SHORT).show();
163+
} else if (_batchIndex != batchIndex) {
164+
Toast.makeText(this, getString(R.string.google_qr_export_unexpected, _batchIndex + 1, batchIndex + 1), Toast.LENGTH_SHORT).show();
165+
}
166+
}
167+
168+
private void finish(List<VaultEntry> entries) {
169+
Intent intent = new Intent();
170+
intent.putExtra("entries", (ArrayList<VaultEntry>) entries);
171+
setResult(RESULT_OK, intent);
172+
finish();
173+
}
174+
125175
private void updateCameraIcon() {
126176
if (_menu != null) {
127177
MenuItem item = _menu.findItem(R.id.action_camera);

app/src/main/proto/google_auth.proto

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
syntax = "proto3";
2+
3+
option java_package = "com.beemdevelopment.aegis";
4+
option java_outer_classname = "GoogleAuthProtos";
5+
6+
message MigrationPayload {
7+
enum Algorithm {
8+
ALGO_INVALID = 0;
9+
ALGO_SHA1 = 1;
10+
}
11+
12+
enum OtpType {
13+
OTP_INVALID = 0;
14+
OTP_HOTP = 1;
15+
OTP_TOTP = 2;
16+
}
17+
18+
message OtpParameters {
19+
bytes secret = 1;
20+
string name = 2;
21+
string issuer = 3;
22+
Algorithm algorithm = 4;
23+
int32 digits = 5;
24+
OtpType type = 6;
25+
int64 counter = 7;
26+
}
27+
28+
repeated OtpParameters otp_parameters = 1;
29+
int32 version = 2;
30+
int32 batch_size = 3;
31+
int32 batch_index = 4;
32+
int32 batch_id = 5;
33+
}

app/src/main/res/values/strings.xml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,9 @@
234234
<string name="time_sync_warning_title">Automatic time synchronization</string>
235235
<string name="time_sync_warning_message">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?</string>
236236
<string name="time_sync_warning_disable">Stop warning me. I know what I\'m doing.</string>
237+
<string name="google_qr_export_unrelated">Unrelated QR code found. Try restarting the scanner.</string>
238+
<string name="google_qr_export_scanned">Scanned %d/%d QR codes</string>
239+
<string name="google_qr_export_unexpected">Expected QR code #%d, but scanned #%d instead</string>
237240

238241
<string name="custom_notices_format_style" translatable="false" >
239242
body {

build.gradle

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ buildscript {
66
google()
77
}
88
dependencies {
9-
classpath 'com.android.tools.build:gradle:3.5.3'
9+
classpath 'com.android.tools.build:gradle:3.6.3'
10+
classpath 'com.google.protobuf:protobuf-gradle-plugin:0.8.12'
1011

1112
// NOTE: Do not place your application dependencies here; they belong
1213
// in the individual module build.gradle files
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
#Sun Sep 01 21:12:20 CEST 2019
1+
#Fri May 08 13:48:01 GMT 2020
22
distributionBase=GRADLE_USER_HOME
33
distributionPath=wrapper/dists
44
zipStoreBase=GRADLE_USER_HOME
55
zipStorePath=wrapper/dists
6-
distributionUrl=https\://services.gradle.org/distributions/gradle-5.4.1-all.zip
6+
distributionUrl=https\://services.gradle.org/distributions/gradle-5.6.4-all.zip

0 commit comments

Comments
 (0)