[tor-commits] [orbot/master] UI For Create, Backing up Restoring, etc V3 Client Authorizations

n8fr8 at torproject.org n8fr8 at torproject.org
Wed Dec 22 21:55:06 UTC 2021


commit 64c4a4627c518862181685a6e5dff8bcbde9b2f5
Author: bim <dsnake at protonmail.com>
Date:   Thu Jan 28 11:40:24 2021 -0500

    UI For Create, Backing up Restoring, etc V3 Client Authorizations
---
 app/src/main/AndroidManifest.xml                   |  11 +-
 .../org/torproject/android/OrbotMainActivity.java  |   8 +-
 .../ui/hiddenservices/ClientCookiesActivity.java   |   7 +-
 .../ui/hiddenservices/backup/BackupUtils.java      |  37 +++++-
 .../ui/hiddenservices/dialogs/AddCookieDialog.java |   6 +-
 .../OnionServiceActionsDialogFragment.java         |  13 +-
 ...icesActivity.java => OnionServiceActivity.java} |  11 +-
 .../OnionServiceContentProvider.java               |  21 +--
 ....java => OnionServiceCreateDialogFragment.java} |   4 +-
 .../ui/v3onionservice/OnionServiceDatabase.java    |   3 +-
 ....java => OnionServiceDeleteDialogFragment.java} |   9 +-
 .../ui/v3onionservice/OnionV3ListAdapter.java      |   1 -
 .../ui/v3onionservice/V3ClientAuthActivity.java    |  25 ----
 .../ClientAuthActionsDialogFragment.java           |  42 ++++++
 .../clientauth/ClientAuthActivity.java             | 142 +++++++++++++++++++++
 .../clientauth/ClientAuthBackupDialogFragment.java |  90 +++++++++++++
 .../clientauth/ClientAuthContentProvider.java      | 104 +++++++++++++++
 .../ClientAuthCreateDialogFragment.java}           |  27 +++-
 .../clientauth/ClientAuthDatabase.java             |  30 +++++
 .../clientauth/ClientAuthDeleteDialogFragment.java |  36 ++++++
 .../clientauth/ClientAuthListAdapter.java          |  49 +++++++
 app/src/main/res/layout/activity_v3auth.xml        |  11 +-
 .../main/res/layout/dialog_add_v3_client_auth.xml  |   5 +-
 app/src/main/res/values/dimens.xml                 |   1 +
 app/src/main/res/values/strings.xml                |  17 ++-
 .../java/org/torproject/android/core/DiskUtils.kt  |   2 +-
 .../torproject/android/service/OrbotService.java   |  81 +++++++++---
 .../android/service/TorServiceConstants.java       |   1 +
 28 files changed, 679 insertions(+), 115 deletions(-)

diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 51fc720a..ae1a8be8 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -95,7 +95,7 @@
         </activity>
 
         <activity
-            android:name=".ui.v3onionservice.OnionServicesActivity"
+            android:name=".ui.v3onionservice.OnionServiceActivity"
             android:label="@string/hidden_services"
             android:theme="@style/DefaultTheme">
             <meta-data
@@ -103,8 +103,8 @@
                 android:value=".OrbotMainActivity" />
         </activity>
 
-        <activity android:name=".ui.v3onionservice.V3ClientAuthActivity"
-            android:label="@string/v3_client_auth"
+        <activity android:name=".ui.v3onionservice.clientauth.ClientAuthActivity"
+            android:label="@string/v3_client_auth_activity_title"
             android:theme="@style/DefaultTheme">
             <meta-data
                 android:name="android.support.PARENT_ACTIVITY"
@@ -147,6 +147,11 @@
             android:authorities="org.torproject.android.ui.v3onionservice"
             android:exported="false" />
 
+        <provider
+            android:authorities="org.torproject.android.ui.v3onionservice.clientauth"
+            android:name=".ui.v3onionservice.clientauth.ClientAuthContentProvider"
+            android:exported="false"/>
+
         <provider
             android:name="androidx.core.content.FileProvider"
             android:authorities="org.torproject.android.ui.hiddenservices.storage"
diff --git a/app/src/main/java/org/torproject/android/OrbotMainActivity.java b/app/src/main/java/org/torproject/android/OrbotMainActivity.java
index d58381d4..bf36020b 100644
--- a/app/src/main/java/org/torproject/android/OrbotMainActivity.java
+++ b/app/src/main/java/org/torproject/android/OrbotMainActivity.java
@@ -74,8 +74,8 @@ import org.torproject.android.ui.hiddenservices.providers.HSContentProvider;
 import org.torproject.android.ui.onboarding.BridgeWizardActivity;
 import org.torproject.android.ui.onboarding.OnboardingActivity;
 import org.torproject.android.ui.v3onionservice.OnionServiceContentProvider;
-import org.torproject.android.ui.v3onionservice.OnionServicesActivity;
-import org.torproject.android.ui.v3onionservice.V3ClientAuthActivity;
+import org.torproject.android.ui.v3onionservice.OnionServiceActivity;
+import org.torproject.android.ui.v3onionservice.clientauth.ClientAuthActivity;
 
 import java.io.File;
 import java.io.UnsupportedEncodingException;
@@ -468,9 +468,9 @@ public class OrbotMainActivity extends AppCompatActivity implements OrbotConstan
             }
 
         } else if (item.getItemId() == R.id.menu_v3_onion_services) {
-            startActivity(new Intent(this, OnionServicesActivity.class));
+            startActivity(new Intent(this, OnionServiceActivity.class));
         } else if (item.getItemId() == R.id.menu_v3_onion_client_auth) {
-            startActivity(new Intent(this, V3ClientAuthActivity.class));
+            startActivity(new Intent(this, ClientAuthActivity.class));
         } else if (item.getItemId() == R.id.menu_hidden_services) {
             startActivity(new Intent(this, HiddenServicesActivity.class));
         } else if (item.getItemId() == R.id.menu_client_cookies) {
diff --git a/app/src/main/java/org/torproject/android/ui/hiddenservices/ClientCookiesActivity.java b/app/src/main/java/org/torproject/android/ui/hiddenservices/ClientCookiesActivity.java
index 7ffe2b43..3e1b60a3 100644
--- a/app/src/main/java/org/torproject/android/ui/hiddenservices/ClientCookiesActivity.java
+++ b/app/src/main/java/org/torproject/android/ui/hiddenservices/ClientCookiesActivity.java
@@ -15,7 +15,6 @@ import android.widget.ListView;
 import android.widget.Toast;
 
 import androidx.appcompat.app.AppCompatActivity;
-import androidx.appcompat.widget.Toolbar;
 
 import com.google.zxing.integration.android.IntentIntegrator;
 import com.google.zxing.integration.android.IntentResult;
@@ -53,10 +52,8 @@ public class ClientCookiesActivity extends AppCompatActivity {
 
         mResolver = getContentResolver();
 
-        findViewById(R.id.fab).setOnClickListener(view -> {
-            AddCookieDialog dialog = new AddCookieDialog();
-            dialog.show(getSupportFragmentManager(), "AddCookieDialog");
-        });
+        findViewById(R.id.fab).setOnClickListener(view ->
+            new AddCookieDialog().show(getSupportFragmentManager(), AddCookieDialog.class.getSimpleName()));
 
         mAdapter = new ClientCookiesAdapter(this, mResolver.query(CookieContentProvider.CONTENT_URI, CookieContentProvider.PROJECTION, null, null, null), 0);
 
diff --git a/app/src/main/java/org/torproject/android/ui/hiddenservices/backup/BackupUtils.java b/app/src/main/java/org/torproject/android/ui/hiddenservices/backup/BackupUtils.java
index 1dbad3cb..af7f1333 100644
--- a/app/src/main/java/org/torproject/android/ui/hiddenservices/backup/BackupUtils.java
+++ b/app/src/main/java/org/torproject/android/ui/hiddenservices/backup/BackupUtils.java
@@ -12,10 +12,12 @@ import android.widget.Toast;
 import org.json.JSONException;
 import org.json.JSONObject;
 import org.torproject.android.R;
+import org.torproject.android.service.OrbotService;
 import org.torproject.android.service.TorServiceConstants;
 import org.torproject.android.ui.hiddenservices.providers.CookieContentProvider;
 import org.torproject.android.ui.hiddenservices.providers.HSContentProvider;
 import org.torproject.android.ui.v3onionservice.OnionServiceContentProvider;
+import org.torproject.android.ui.v3onionservice.clientauth.ClientAuthContentProvider;
 
 import java.io.File;
 import java.io.FileInputStream;
@@ -38,10 +40,6 @@ public class BackupUtils {
         mResolver = mContext.getContentResolver();
     }
 
-    public static boolean isV2OnionAddressValid(String onionToTest) {
-        return onionToTest.matches("([a-z0-9]{16}).onion");
-    }
-
     public String createV3ZipBackup(String port, Uri zipFile) {
         String[] files = createFilesForZippingV3(port);
         ZipUtilities zip = new ZipUtilities(files, zipFile, mResolver);
@@ -49,6 +47,20 @@ public class BackupUtils {
         return zipFile.getPath();
     }
 
+    public String createV3AuthBackup(String domain, String keyHash, Uri backupFile) {
+        String fileText = OrbotService.buildV3ClientAuthFile(domain, keyHash);
+        try {
+            ParcelFileDescriptor pfd = mContext.getContentResolver().openFileDescriptor(backupFile, "w");
+            FileOutputStream fos = new FileOutputStream(pfd.getFileDescriptor());
+            fos.write(fileText.getBytes());
+            fos.close();
+            pfd.close();
+        } catch (IOException ioe) {
+            return null;
+        }
+        return backupFile.getPath();
+    }
+
     public String createV2ZipBackup(int port, Uri zipFile) {
         String[] files = createFilesForZippingV2(port);
         ZipUtilities zip = new ZipUtilities(files, zipFile, mResolver);
@@ -59,7 +71,7 @@ public class BackupUtils {
         return zipFile.getPath();
     }
 
-    // todo also write out authorized clients...
+    // todo this doesn't export data for onions that orbot hosts which have authentication (not supported yet...)
     private String[] createFilesForZippingV3(String port) {
         final String v3BasePath = getV3BasePath() + "/v3" + port + "/";
         final String hostnamePath = v3BasePath + "hostname",
@@ -304,7 +316,6 @@ public class BackupUtils {
             Toast.makeText(mContext, R.string.error, Toast.LENGTH_LONG).show();
     }
 
-
     public void restoreKeyBackup(int hsPort, Uri hsKeyPath) {
         File mHSBasePath = new File(
                 mContext.getFilesDir().getAbsolutePath(),
@@ -333,6 +344,20 @@ public class BackupUtils {
         }
     }
 
+
+    public void restoreClientAuthBackup(String authFileContents) {
+        ContentValues fields = new ContentValues();
+        String[] split = authFileContents.split(":");
+        if (split.length != 4) {
+            Toast.makeText(mContext, R.string.error, Toast.LENGTH_LONG).show();
+            return;
+        }
+        fields.put(ClientAuthContentProvider.V3ClientAuth.DOMAIN, split[0]);
+        fields.put(ClientAuthContentProvider.V3ClientAuth.HASH, split[3]);
+        mResolver.insert(ClientAuthContentProvider.CONTENT_URI, fields);
+        Toast.makeText(mContext, R.string.backup_restored, Toast.LENGTH_LONG).show();
+    }
+
     public void restoreCookieBackup(String jString) {
         try {
             JSONObject savedValues = new JSONObject(jString);
diff --git a/app/src/main/java/org/torproject/android/ui/hiddenservices/dialogs/AddCookieDialog.java b/app/src/main/java/org/torproject/android/ui/hiddenservices/dialogs/AddCookieDialog.java
index a0e584c4..1da1d1e4 100644
--- a/app/src/main/java/org/torproject/android/ui/hiddenservices/dialogs/AddCookieDialog.java
+++ b/app/src/main/java/org/torproject/android/ui/hiddenservices/dialogs/AddCookieDialog.java
@@ -76,7 +76,11 @@ public class AddCookieDialog extends DialogFragment {
         String onion = etOnion.getText().toString();
         String cookie = etCookie.getText().toString();
         if (TextUtils.isEmpty(onion.trim()) || TextUtils.isEmpty(cookie.trim())) return false;
-        return BackupUtils.isV2OnionAddressValid(onion);
+        return isV2OnionAddressValid(onion);
+    }
+
+    private static boolean isV2OnionAddressValid(String onionToTest) {
+        return onionToTest.matches("([a-z0-9]{16}).onion");
     }
 
     private void saveData(String domain, String cookie) {
diff --git a/app/src/main/java/org/torproject/android/ui/v3onionservice/OnionServiceActionsDialogFragment.java b/app/src/main/java/org/torproject/android/ui/v3onionservice/OnionServiceActionsDialogFragment.java
index 4d26270d..17ce59a7 100644
--- a/app/src/main/java/org/torproject/android/ui/v3onionservice/OnionServiceActionsDialogFragment.java
+++ b/app/src/main/java/org/torproject/android/ui/v3onionservice/OnionServiceActionsDialogFragment.java
@@ -6,6 +6,7 @@ import android.content.Context;
 import android.content.Intent;
 import android.net.Uri;
 import android.os.Bundle;
+import android.text.Html;
 import android.widget.Toast;
 
 import androidx.annotation.NonNull;
@@ -35,7 +36,7 @@ public class OnionServiceActionsDialogFragment extends DialogFragment {
         AlertDialog ad = new AlertDialog.Builder(getActivity())
                 .setItems(new CharSequence[]{
                         getString(R.string.copy_address_to_clipboard),
-                        getString(R.string.backup_service),
+                        Html.fromHtml(getString(R.string.backup_service)),
                         getString(R.string.delete_service)}, null)
                 .setNegativeButton(android.R.string.cancel, (dialog, which) -> dialog.dismiss())
                 .setTitle(R.string.hidden_services)
@@ -46,14 +47,14 @@ public class OnionServiceActionsDialogFragment extends DialogFragment {
             if (position == 0) doCopy(arguments, getContext());
             else if (position == 1) doBackup(arguments, getContext());
             else if (position == 2)
-                new DeleteOnionServiceDialogFragment(arguments).show(getFragmentManager(), DeleteOnionServiceDialogFragment.class.getSimpleName());
+                new OnionServiceDeleteDialogFragment(arguments).show(getFragmentManager(), OnionServiceDeleteDialogFragment.class.getSimpleName());
             if (position != 1) dismiss();
         });
         return ad;
     }
 
     private void doCopy(Bundle arguments, Context context) {
-        String onion = arguments.getString(OnionServicesActivity.BUNDLE_KEY_DOMAIN);
+        String onion = arguments.getString(OnionServiceActivity.BUNDLE_KEY_DOMAIN);
         if (onion == null)
             Toast.makeText(context, R.string.please_restart_Orbot_to_enable_the_changes, Toast.LENGTH_LONG).show();
         else
@@ -61,8 +62,8 @@ public class OnionServiceActionsDialogFragment extends DialogFragment {
     }
 
     private void doBackup(Bundle arguments, Context context) {
-        String filename = "onion_service" + arguments.getString(OnionServicesActivity.BUNDLE_KEY_PORT) + ".zip";
-        if (arguments.getString(OnionServicesActivity.BUNDLE_KEY_DOMAIN) == null) {
+        String filename = "onion_service" + arguments.getString(OnionServiceActivity.BUNDLE_KEY_PORT) + ".zip";
+        if (arguments.getString(OnionServiceActivity.BUNDLE_KEY_DOMAIN) == null) {
             Toast.makeText(context, R.string.please_restart_Orbot_to_enable_the_changes, Toast.LENGTH_LONG).show();
             return;
         }
@@ -85,7 +86,7 @@ public class OnionServiceActionsDialogFragment extends DialogFragment {
 
     private void attemptToWriteBackup(Uri outputFile) {
         BackupUtils backupUtils = new BackupUtils(getContext());
-        String backup = backupUtils.createV3ZipBackup(getArguments().getString(OnionServicesActivity.BUNDLE_KEY_PORT), outputFile);
+        String backup = backupUtils.createV3ZipBackup(getArguments().getString(OnionServiceActivity.BUNDLE_KEY_PORT), outputFile);
         Toast.makeText(getContext(), backup != null ? R.string.backup_saved_at_external_storage : R.string.error, Toast.LENGTH_LONG).show();
         dismiss();
     }
diff --git a/app/src/main/java/org/torproject/android/ui/v3onionservice/OnionServicesActivity.java b/app/src/main/java/org/torproject/android/ui/v3onionservice/OnionServiceActivity.java
similarity index 94%
rename from app/src/main/java/org/torproject/android/ui/v3onionservice/OnionServicesActivity.java
rename to app/src/main/java/org/torproject/android/ui/v3onionservice/OnionServiceActivity.java
index 64a014e4..39db0b10 100644
--- a/app/src/main/java/org/torproject/android/ui/v3onionservice/OnionServicesActivity.java
+++ b/app/src/main/java/org/torproject/android/ui/v3onionservice/OnionServiceActivity.java
@@ -30,7 +30,7 @@ import org.torproject.android.ui.hiddenservices.permissions.PermissionManager;
 
 import java.io.File;
 
-public class OnionServicesActivity extends AppCompatActivity {
+public class OnionServiceActivity extends AppCompatActivity {
 
     static final String BUNDLE_KEY_ID = "id", BUNDLE_KEY_PORT = "port", BUNDLE_KEY_DOMAIN = "domain";
     private static final String BASE_WHERE_SELECTION_CLAUSE = OnionServiceContentProvider.OnionService.CREATED_BY_USER + "=";
@@ -51,7 +51,7 @@ public class OnionServicesActivity extends AppCompatActivity {
         getSupportActionBar().setDisplayHomeAsUpEnabled(true);
 
         fab = findViewById(R.id.fab);
-        fab.setOnClickListener(v -> new NewOnionServiceDialogFragment().show(getSupportFragmentManager(), NewOnionServiceDialogFragment.class.getSimpleName()));
+        fab.setOnClickListener(v -> new OnionServiceCreateDialogFragment().show(getSupportFragmentManager(), OnionServiceCreateDialogFragment.class.getSimpleName()));
 
         mContentResolver = getContentResolver();
         mAdapter = new OnionV3ListAdapter(this, mContentResolver.query(OnionServiceContentProvider.CONTENT_URI, OnionServiceContentProvider.PROJECTION, BASE_WHERE_SELECTION_CLAUSE + '1', null, null), 0);
@@ -123,8 +123,7 @@ public class OnionServicesActivity extends AppCompatActivity {
     private void doRestoreLegacy() { // APIs 16, 17, 18
         File backupDir = DiskUtils.getOrCreateLegacyBackupDir(getString(R.string.app_name));
         File[] files = backupDir.listFiles(ZipUtilities.FILTER_ZIP_FILES);
-        if (files == null) return;
-        if (files.length == 0) {
+        if (files == null || files.length == 0) {
             Toast.makeText(this, R.string.create_a_backup_first, Toast.LENGTH_LONG).show();
             return;
         }
@@ -168,9 +167,9 @@ public class OnionServicesActivity extends AppCompatActivity {
                     OnionServiceContentProvider.OnionService.ENABLED + "=1", null, null);
             if (activeServices == null) return;
             if (activeServices.getCount() > 0)
-                PermissionManager.requestBatteryPermissions(OnionServicesActivity.this, getApplicationContext());
+                PermissionManager.requestBatteryPermissions(OnionServiceActivity.this, getApplicationContext());
             else
-                PermissionManager.requestDropBatteryPermissions(OnionServicesActivity.this, getApplicationContext());
+                PermissionManager.requestDropBatteryPermissions(OnionServiceActivity.this, getApplicationContext());
             activeServices.close();
         }
     }
diff --git a/app/src/main/java/org/torproject/android/ui/v3onionservice/OnionServiceContentProvider.java b/app/src/main/java/org/torproject/android/ui/v3onionservice/OnionServiceContentProvider.java
index d61f7935..61b8540a 100644
--- a/app/src/main/java/org/torproject/android/ui/v3onionservice/OnionServiceContentProvider.java
+++ b/app/src/main/java/org/torproject/android/ui/v3onionservice/OnionServiceContentProvider.java
@@ -46,13 +46,10 @@ public class OnionServiceContentProvider extends ContentProvider {
     @Nullable
     @Override
     public Cursor query(@NonNull Uri uri, @Nullable String[] projection, @Nullable String selection, @Nullable String[] selectionArgs, @Nullable String sortOrder) {
-        String where = selection;
-        if (uriMatcher.match(uri) == ONION_ID) {
-            where = "_id=" + uri.getLastPathSegment();
-        }
-
+        if (uriMatcher.match(uri) == ONION_ID)
+            selection = "_id=" + uri.getLastPathSegment();
         SQLiteDatabase db = mDatabase.getReadableDatabase();
-        return db.query(OnionServiceDatabase.ONION_SERVICE_TABLE_NAME, projection, where, selectionArgs, null, null, sortOrder);
+        return db.query(OnionServiceDatabase.ONION_SERVICE_TABLE_NAME, projection, selection, selectionArgs, null, null, sortOrder);
     }
 
     @Nullable
@@ -80,9 +77,8 @@ public class OnionServiceContentProvider extends ContentProvider {
 
     @Override
     public int delete(@NonNull Uri uri, @Nullable String selection, @Nullable String[] selectionArgs) {
-        if (uriMatcher.match(uri) == ONION_ID) {
+        if (uriMatcher.match(uri) == ONION_ID)
             selection = "_id=" + uri.getLastPathSegment();
-        }
         SQLiteDatabase db = mDatabase.getWritableDatabase();
         int rows = db.delete(OnionServiceDatabase.ONION_SERVICE_TABLE_NAME, selection, selectionArgs);
         getContext().getContentResolver().notifyChange(CONTENT_URI, null);
@@ -92,12 +88,9 @@ public class OnionServiceContentProvider extends ContentProvider {
     @Override
     public int update(@NonNull Uri uri, @Nullable ContentValues values, @Nullable String selection, @Nullable String[] selectionArgs) {
         SQLiteDatabase db = mDatabase.getWritableDatabase();
-        String where = selection;
-        if (uriMatcher.match(uri) == ONION_ID) {
-            where = "_id=" + uri.getLastPathSegment();
-        }
-
-        int rows = db.update(OnionServiceDatabase.ONION_SERVICE_TABLE_NAME, values, where, null);
+        if (uriMatcher.match(uri) == ONION_ID)
+            selection = "_id=" + uri.getLastPathSegment();
+        int rows = db.update(OnionServiceDatabase.ONION_SERVICE_TABLE_NAME, values, selection, null);
         getContext().getContentResolver().notifyChange(CONTENT_URI, null);
         return rows;
     }
diff --git a/app/src/main/java/org/torproject/android/ui/v3onionservice/NewOnionServiceDialogFragment.java b/app/src/main/java/org/torproject/android/ui/v3onionservice/OnionServiceCreateDialogFragment.java
similarity index 96%
rename from app/src/main/java/org/torproject/android/ui/v3onionservice/NewOnionServiceDialogFragment.java
rename to app/src/main/java/org/torproject/android/ui/v3onionservice/OnionServiceCreateDialogFragment.java
index 1929eb72..1d22ac6c 100644
--- a/app/src/main/java/org/torproject/android/ui/v3onionservice/NewOnionServiceDialogFragment.java
+++ b/app/src/main/java/org/torproject/android/ui/v3onionservice/OnionServiceCreateDialogFragment.java
@@ -19,7 +19,7 @@ import androidx.fragment.app.DialogFragment;
 
 import org.torproject.android.R;
 
-public class NewOnionServiceDialogFragment extends DialogFragment {
+public class OnionServiceCreateDialogFragment extends DialogFragment {
 
     private EditText etServer, etLocalPort, etOnionPort;
     private TextWatcher inputValidator;
@@ -90,7 +90,7 @@ public class NewOnionServiceDialogFragment extends DialogFragment {
         fields.put(OnionServiceContentProvider.OnionService.PORT, localPort);
         fields.put(OnionServiceContentProvider.OnionService.ONION_PORT, onionPort);
         fields.put(OnionServiceContentProvider.OnionService.CREATED_BY_USER, 1);
-        ContentResolver cr = getContext().getContentResolver();
+        ContentResolver cr = context.getContentResolver();
         cr.insert(OnionServiceContentProvider.CONTENT_URI, fields);
         Toast.makeText(context, R.string.please_restart_Orbot_to_enable_the_changes, Toast.LENGTH_LONG).show();
     }
diff --git a/app/src/main/java/org/torproject/android/ui/v3onionservice/OnionServiceDatabase.java b/app/src/main/java/org/torproject/android/ui/v3onionservice/OnionServiceDatabase.java
index f2a812e8..558babac 100644
--- a/app/src/main/java/org/torproject/android/ui/v3onionservice/OnionServiceDatabase.java
+++ b/app/src/main/java/org/torproject/android/ui/v3onionservice/OnionServiceDatabase.java
@@ -20,7 +20,7 @@ public class OnionServiceDatabase extends SQLiteOpenHelper {
                     "enabled INTEGER DEFAULT 1, " +
                     "port INTEGER);";
 
-    public OnionServiceDatabase(Context context) {
+    OnionServiceDatabase(Context context) {
         super(context, DATABASE_NAME, null, DATABASE_VERSION);
     }
 
@@ -31,7 +31,6 @@ public class OnionServiceDatabase extends SQLiteOpenHelper {
 
     @Override
     public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
-
     }
 
 
diff --git a/app/src/main/java/org/torproject/android/ui/v3onionservice/DeleteOnionServiceDialogFragment.java b/app/src/main/java/org/torproject/android/ui/v3onionservice/OnionServiceDeleteDialogFragment.java
similarity index 80%
rename from app/src/main/java/org/torproject/android/ui/v3onionservice/DeleteOnionServiceDialogFragment.java
rename to app/src/main/java/org/torproject/android/ui/v3onionservice/OnionServiceDeleteDialogFragment.java
index 5028e8f3..71111fe1 100644
--- a/app/src/main/java/org/torproject/android/ui/v3onionservice/DeleteOnionServiceDialogFragment.java
+++ b/app/src/main/java/org/torproject/android/ui/v3onionservice/OnionServiceDeleteDialogFragment.java
@@ -12,12 +12,11 @@ import androidx.fragment.app.DialogFragment;
 import org.torproject.android.R;
 import org.torproject.android.core.DiskUtils;
 import org.torproject.android.service.TorServiceConstants;
-import org.torproject.android.ui.hiddenservices.HiddenServicesActivity;
 
 import java.io.File;
 
-public class DeleteOnionServiceDialogFragment extends DialogFragment {
-    DeleteOnionServiceDialogFragment(Bundle arguments) {
+public class OnionServiceDeleteDialogFragment extends DialogFragment {
+    OnionServiceDeleteDialogFragment(Bundle arguments) {
         super();
         setArguments(arguments);
     }
@@ -33,9 +32,9 @@ public class DeleteOnionServiceDialogFragment extends DialogFragment {
     }
 
     private void doDelete(Bundle arguments, Context context) {
-        context.getContentResolver().delete(OnionServiceContentProvider.CONTENT_URI, OnionServiceContentProvider.OnionService._ID + '=' + arguments.getInt(OnionServicesActivity.BUNDLE_KEY_ID), null);
+        context.getContentResolver().delete(OnionServiceContentProvider.CONTENT_URI, OnionServiceContentProvider.OnionService._ID + '=' + arguments.getInt(OnionServiceActivity.BUNDLE_KEY_ID), null);
         String base = context.getFilesDir().getAbsolutePath() + "/" + TorServiceConstants.ONION_SERVICES_DIR;
-        DiskUtils.recursivelyDeleteDirectory(new File(base, "v3" + arguments.getString(OnionServicesActivity.BUNDLE_KEY_PORT)));
+        DiskUtils.recursivelyDeleteDirectory(new File(base, "v3" + arguments.getString(OnionServiceActivity.BUNDLE_KEY_PORT)));
     }
 
 }
diff --git a/app/src/main/java/org/torproject/android/ui/v3onionservice/OnionV3ListAdapter.java b/app/src/main/java/org/torproject/android/ui/v3onionservice/OnionV3ListAdapter.java
index c0489d3c..6a858a9a 100644
--- a/app/src/main/java/org/torproject/android/ui/v3onionservice/OnionV3ListAdapter.java
+++ b/app/src/main/java/org/torproject/android/ui/v3onionservice/OnionV3ListAdapter.java
@@ -14,7 +14,6 @@ import android.widget.Toast;
 import androidx.appcompat.widget.SwitchCompat;
 
 import org.torproject.android.R;
-import org.torproject.android.ui.hiddenservices.providers.HSContentProvider;
 
 public class OnionV3ListAdapter extends CursorAdapter {
 
diff --git a/app/src/main/java/org/torproject/android/ui/v3onionservice/V3ClientAuthActivity.java b/app/src/main/java/org/torproject/android/ui/v3onionservice/V3ClientAuthActivity.java
deleted file mode 100644
index 53a35f30..00000000
--- a/app/src/main/java/org/torproject/android/ui/v3onionservice/V3ClientAuthActivity.java
+++ /dev/null
@@ -1,25 +0,0 @@
-package org.torproject.android.ui.v3onionservice;
-
-import android.os.Bundle;
-
-import androidx.annotation.Nullable;
-import androidx.appcompat.app.AppCompatActivity;
-
-import org.torproject.android.R;
-
-public class V3ClientAuthActivity extends AppCompatActivity {
-    @Override
-    protected void onCreate(@Nullable Bundle savedInstanceState) {
-        super.onCreate(savedInstanceState);
-        setContentView(R.layout.activity_v3auth);
-
-        setSupportActionBar(findViewById(R.id.toolbar));
-        getSupportActionBar().setDisplayHomeAsUpEnabled(true);
-
-        findViewById(R.id.fab).setOnClickListener(v -> {
-            new AddV3ClientAuthDialogFragment().show(getSupportFragmentManager(), "AddV3ClientAuthDialogFragment");
-        });
-
-
-    }
-}
diff --git a/app/src/main/java/org/torproject/android/ui/v3onionservice/clientauth/ClientAuthActionsDialogFragment.java b/app/src/main/java/org/torproject/android/ui/v3onionservice/clientauth/ClientAuthActionsDialogFragment.java
new file mode 100644
index 00000000..af5c6844
--- /dev/null
+++ b/app/src/main/java/org/torproject/android/ui/v3onionservice/clientauth/ClientAuthActionsDialogFragment.java
@@ -0,0 +1,42 @@
+package org.torproject.android.ui.v3onionservice.clientauth;
+
+import android.app.AlertDialog;
+import android.app.Dialog;
+import android.os.Bundle;
+import android.text.Html;
+
+import androidx.annotation.NonNull;
+import androidx.fragment.app.DialogFragment;
+
+import org.torproject.android.R;
+
+public class ClientAuthActionsDialogFragment extends DialogFragment {
+
+    public ClientAuthActionsDialogFragment() {}
+
+    public ClientAuthActionsDialogFragment(Bundle args) {
+        super();
+        setArguments(args);
+    }
+
+    @NonNull
+    @Override
+    public Dialog onCreateDialog(Bundle savedInstanceState) {
+        AlertDialog ad = new AlertDialog.Builder(getActivity())
+                .setTitle(R.string.v3_client_auth)
+                .setItems(new CharSequence[]{
+                        Html.fromHtml(getString(R.string.v3_backup_key)),
+                        getString(R.string.v3_delete_client_authorization)
+                }, null)
+                .setNegativeButton(android.R.string.cancel, (dialog, which) -> dialog.dismiss())
+                .create();
+        ad.getListView().setOnItemClickListener((parent, view, position, id) -> {
+            if (position == 0)
+                new ClientAuthBackupDialogFragment(getArguments()).show(getActivity().getSupportFragmentManager(), ClientAuthBackupDialogFragment.class.getSimpleName());
+            else
+                new ClientAuthDeleteDialogFragment(getArguments()).show(getActivity().getSupportFragmentManager(), ClientAuthDeleteDialogFragment.class.getSimpleName());
+            ad.dismiss();
+        });
+        return ad;
+    }
+}
diff --git a/app/src/main/java/org/torproject/android/ui/v3onionservice/clientauth/ClientAuthActivity.java b/app/src/main/java/org/torproject/android/ui/v3onionservice/clientauth/ClientAuthActivity.java
new file mode 100644
index 00000000..b24b81f7
--- /dev/null
+++ b/app/src/main/java/org/torproject/android/ui/v3onionservice/clientauth/ClientAuthActivity.java
@@ -0,0 +1,142 @@
+package org.torproject.android.ui.v3onionservice.clientauth;
+
+import android.content.ContentResolver;
+import android.content.Context;
+import android.content.Intent;
+import android.database.ContentObserver;
+import android.database.Cursor;
+import android.net.Uri;
+import android.os.Bundle;
+import android.os.Handler;
+import android.view.Menu;
+import android.view.MenuItem;
+import android.widget.ListView;
+import android.widget.Toast;
+
+import androidx.annotation.Nullable;
+import androidx.appcompat.app.AlertDialog;
+import androidx.appcompat.app.AppCompatActivity;
+import androidx.fragment.app.Fragment;
+
+import org.torproject.android.R;
+import org.torproject.android.core.DiskUtils;
+import org.torproject.android.core.LocaleHelper;
+import org.torproject.android.ui.hiddenservices.backup.BackupUtils;
+import org.torproject.android.ui.hiddenservices.backup.ZipUtilities;
+
+import java.io.File;
+import java.util.List;
+
+public class ClientAuthActivity extends AppCompatActivity {
+
+    public static final String BUNDLE_KEY_ID = "_id",
+            BUNDLE_KEY_DOMAIN = "domain",
+            BUNDLE_KEY_HASH = "key_hash_value";
+
+    private ContentResolver mResolver;
+    private ClientAuthListAdapter mAdapter;
+
+    @Override
+    protected void onCreate(@Nullable Bundle savedInstanceState) {
+        super.onCreate(savedInstanceState);
+        setContentView(R.layout.activity_v3auth);
+
+        setSupportActionBar(findViewById(R.id.toolbar));
+        getSupportActionBar().setDisplayHomeAsUpEnabled(true);
+
+        mResolver = getContentResolver();
+        mAdapter = new ClientAuthListAdapter(this, mResolver.query(ClientAuthContentProvider.CONTENT_URI, ClientAuthContentProvider.PROJECTION, null, null, null), 0);
+        mResolver.registerContentObserver(ClientAuthContentProvider.CONTENT_URI, true, new V3ClientAuthContentObserver(new Handler()));
+
+        findViewById(R.id.fab).setOnClickListener(v ->
+                new ClientAuthCreateDialogFragment().show(getSupportFragmentManager(), ClientAuthCreateDialogFragment.class.getSimpleName()));
+
+        ListView auths = findViewById(R.id.auth_hash_list);
+        auths.setAdapter(mAdapter);
+        auths.setOnItemClickListener((parent, view, position, id) -> {
+            Cursor item = (Cursor) parent.getItemAtPosition(position);
+            Bundle args = new Bundle();
+            args.putInt(BUNDLE_KEY_ID, item.getInt(item.getColumnIndex(ClientAuthContentProvider.V3ClientAuth._ID)));
+            args.putString(BUNDLE_KEY_DOMAIN, item.getString(item.getColumnIndex(ClientAuthContentProvider.V3ClientAuth.DOMAIN)));
+            args.putString(BUNDLE_KEY_HASH, item.getString(item.getColumnIndex(ClientAuthContentProvider.V3ClientAuth.HASH)));
+            new ClientAuthActionsDialogFragment(args).show(getSupportFragmentManager(), ClientAuthActionsDialogFragment.class.getSimpleName());
+        });
+    }
+
+
+    @Override
+    public void onActivityResult(int requestCode, int resultCode, Intent data) {
+        if (requestCode == REQUEST_CODE_READ_ZIP_BACKUP && resultCode == RESULT_OK) {
+            Uri uri = data.getData();
+            if (uri != null) {
+                String authText = DiskUtils.readFileFromInputStream(getContentResolver(), uri);
+                new BackupUtils(this).restoreClientAuthBackup(authText);
+            }
+        } else {
+            super.onActivityResult(requestCode, resultCode, data);
+            List<Fragment> frags = getSupportFragmentManager().getFragments();
+            if (frags != null)
+                for (Fragment f : frags) f.onActivityResult(requestCode, resultCode, data);
+        }
+    }
+
+    @Override
+    protected void attachBaseContext(Context base) {
+        super.attachBaseContext(LocaleHelper.onAttach(base));
+    }
+
+    private class V3ClientAuthContentObserver extends ContentObserver {
+        V3ClientAuthContentObserver(Handler handler) {
+            super(handler);
+        }
+
+        @Override
+        public void onChange(boolean selfChange) {
+            mAdapter.changeCursor(mResolver.query(ClientAuthContentProvider.CONTENT_URI, ClientAuthContentProvider.PROJECTION, null, null, null));
+        }
+
+    }
+
+    private static final int REQUEST_CODE_READ_ZIP_BACKUP = 12;
+
+    @Override
+    public boolean onOptionsItemSelected(MenuItem item) {
+        if (item.getItemId() == R.id.menu_restore_backup) {
+            if (DiskUtils.supportsStorageAccessFramework()) {
+                Intent readFileIntent = DiskUtils.createReadFileIntent("text/*");
+                startActivityForResult(readFileIntent, REQUEST_CODE_READ_ZIP_BACKUP);
+            } else { // APIs 16, 17, 18
+                doRestoreLegacy();
+            }
+        }
+        return super.onOptionsItemSelected(item);
+    }
+
+    private void doRestoreLegacy() {
+        File backupDir = DiskUtils.getOrCreateLegacyBackupDir(getString(R.string.app_name));
+        File[] files = backupDir.listFiles((dir, name) -> name.toLowerCase().endsWith(".auth_private"));
+        if (files == null || files.length == 0) {
+            Toast.makeText(this, R.string.create_a_backup_first, Toast.LENGTH_LONG).show();
+            return;
+        }
+
+        CharSequence[] fileNames = new CharSequence[files.length];
+        for (int i = 0; i < files.length; i++) fileNames[i] = files[i].getName();
+
+        new AlertDialog.Builder(this)
+                .setTitle(R.string.restore_backup)
+                .setItems(fileNames, (dialog, which) -> {
+                    String authFileText = DiskUtils.readFile(getContentResolver(), files[which]);
+                    new BackupUtils(this).restoreClientAuthBackup(authFileText);
+                })
+                .show();
+    }
+
+    @Override
+    public boolean onCreateOptionsMenu(Menu menu) {
+        getMenuInflater().inflate(R.menu.hs_menu, menu);
+        return true;
+    }
+
+
+}
diff --git a/app/src/main/java/org/torproject/android/ui/v3onionservice/clientauth/ClientAuthBackupDialogFragment.java b/app/src/main/java/org/torproject/android/ui/v3onionservice/clientauth/ClientAuthBackupDialogFragment.java
new file mode 100644
index 00000000..c924fe0e
--- /dev/null
+++ b/app/src/main/java/org/torproject/android/ui/v3onionservice/clientauth/ClientAuthBackupDialogFragment.java
@@ -0,0 +1,90 @@
+package org.torproject.android.ui.v3onionservice.clientauth;
+
+import android.app.Activity;
+import android.app.AlertDialog;
+import android.app.Dialog;
+import android.content.Intent;
+import android.net.Uri;
+import android.os.Bundle;
+import android.view.ViewGroup;
+import android.widget.FrameLayout;
+import android.widget.Toast;
+
+import androidx.annotation.NonNull;
+import androidx.fragment.app.DialogFragment;
+
+import org.torproject.android.R;
+import org.torproject.android.core.DiskUtils;
+import org.torproject.android.core.ui.NoPersonalizedLearningEditText;
+import org.torproject.android.ui.hiddenservices.backup.BackupUtils;
+
+import java.io.File;
+
+public class ClientAuthBackupDialogFragment extends DialogFragment {
+
+    private NoPersonalizedLearningEditText etFilename;
+
+    public ClientAuthBackupDialogFragment() {
+    }
+
+    public ClientAuthBackupDialogFragment(Bundle args) {
+        super();
+        setArguments(args);
+    }
+
+    @NonNull
+    @Override
+    public Dialog onCreateDialog(Bundle savedInstanceState) {
+        AlertDialog ad = new AlertDialog.Builder(getContext())
+                .setTitle(R.string.v3_backup_key)
+                .setMessage(R.string.v3_backup_key_warning)
+                .setPositiveButton(R.string.confirm, null)
+                .setNegativeButton(android.R.string.cancel, (dialog, which) -> dialog.dismiss())
+                .create();
+        ad.setOnShowListener(dialog -> ad.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener(v -> doBackup()));
+        FrameLayout container = new FrameLayout(ad.getContext());
+        FrameLayout.LayoutParams params = new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
+        int margin = getResources().getDimensionPixelOffset(R.dimen.alert_dialog_margin);
+        params.leftMargin = margin;
+        params.rightMargin = margin;
+        etFilename = new NoPersonalizedLearningEditText(ad.getContext(), null);
+        etFilename.setSingleLine(true);
+        etFilename.setHint(R.string.v3_backup_name_hint);
+        etFilename.setLayoutParams(params);
+        container.addView(etFilename);
+        ad.setView(container);
+        return ad;
+    }
+
+    private void doBackup() {
+        String filename = etFilename.getText().toString().trim();
+        if (filename.equals("")) filename = "filename";
+        filename += ".auth_private";
+        if (DiskUtils.supportsStorageAccessFramework()) {
+            Intent createFileIntent = DiskUtils.createWriteFileIntent(filename, "text/*");
+            getActivity().startActivityForResult(createFileIntent, REQUEST_CODE_WRITE_FILE);
+        } else { // APIs 16, 17, 18
+            attemptToWriteBackup(Uri.fromFile(new File(DiskUtils.getOrCreateLegacyBackupDir("Orbot"), filename)));
+        }
+    }
+
+    @Override
+    public void onActivityResult(int requestCode, int resultCode, Intent data) {
+        if (requestCode == REQUEST_CODE_WRITE_FILE && resultCode == Activity.RESULT_OK) {
+            if (data != null) {
+                attemptToWriteBackup(data.getData());
+            }
+        }
+    }
+
+    private void attemptToWriteBackup(Uri outputFile) {
+        BackupUtils backupUtils = new BackupUtils(getContext());
+        String domain = getArguments().getString(ClientAuthActivity.BUNDLE_KEY_DOMAIN);
+        String hash = getArguments().getString(ClientAuthActivity.BUNDLE_KEY_HASH);
+        String backup = backupUtils.createV3AuthBackup(domain, hash, outputFile);
+        Toast.makeText(getContext(), backup != null ? R.string.backup_saved_at_external_storage : R.string.error, Toast.LENGTH_LONG).show();
+        dismiss();
+    }
+
+    private static final int REQUEST_CODE_WRITE_FILE = 432;
+}
diff --git a/app/src/main/java/org/torproject/android/ui/v3onionservice/clientauth/ClientAuthContentProvider.java b/app/src/main/java/org/torproject/android/ui/v3onionservice/clientauth/ClientAuthContentProvider.java
new file mode 100644
index 00000000..549e776f
--- /dev/null
+++ b/app/src/main/java/org/torproject/android/ui/v3onionservice/clientauth/ClientAuthContentProvider.java
@@ -0,0 +1,104 @@
+package org.torproject.android.ui.v3onionservice.clientauth;
+
+import android.content.ContentProvider;
+import android.content.ContentUris;
+import android.content.ContentValues;
+import android.content.UriMatcher;
+import android.database.Cursor;
+import android.database.sqlite.SQLiteDatabase;
+import android.net.Uri;
+import android.provider.BaseColumns;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+public class ClientAuthContentProvider extends ContentProvider {
+    public static final String[] PROJECTION = {
+            V3ClientAuth._ID,
+            V3ClientAuth.DOMAIN,
+            V3ClientAuth.HASH,
+            V3ClientAuth.ENABLED,
+    };
+    private static final String AUTH = "org.torproject.android.ui.v3onionservice.clientauth";
+    public static final Uri CONTENT_URI = Uri.parse("content://" + AUTH + "/v3auth");
+    private static final int V3AUTHS = 1, V3AUTH_ID = 2;
+
+    private static final UriMatcher uriMatcher;
+
+    static {
+        uriMatcher = new UriMatcher(UriMatcher.NO_MATCH);
+        uriMatcher.addURI(AUTH, "v3auth", V3AUTHS);
+        uriMatcher.addURI(AUTH, "v3auth/#", V3AUTH_ID);
+    }
+
+    private ClientAuthDatabase mDatabase;
+
+    @Override
+    public boolean onCreate() {
+        mDatabase = new ClientAuthDatabase(getContext());
+        return true;
+    }
+
+    @Nullable
+    @Override
+    public String getType(@NonNull Uri uri) {
+        int match = uriMatcher.match(uri);
+        switch (match) {
+            case V3AUTHS:
+                return "vnd.android.cursor.dir/vnd.torproject.v3auths";
+            case V3AUTH_ID:
+                return "vnd.android.cursor.item/vnd.torproject.v3auth";
+            default:
+                return null;
+        }
+    }
+
+    @Nullable
+    @Override
+    public Cursor query(@NonNull Uri uri, @Nullable String[] projection, @Nullable String selection, @Nullable String[] selectionArgs, @Nullable String sortOrder) {
+        if (uriMatcher.match(uri) == V3AUTH_ID)
+            selection = "_id=" + uri.getLastPathSegment();
+        SQLiteDatabase db = mDatabase.getReadableDatabase();
+        return db.query(ClientAuthDatabase.DATABASE_NAME, projection, selection, selectionArgs, null, null, sortOrder);
+    }
+
+    @Nullable
+    @Override
+    public Uri insert(@NonNull Uri uri, @Nullable ContentValues values) {
+        SQLiteDatabase db = mDatabase.getWritableDatabase();
+        long regId = db.insert(ClientAuthDatabase.DATABASE_NAME, null, values);
+        getContext().getContentResolver().notifyChange(CONTENT_URI, null);
+        return ContentUris.withAppendedId(CONTENT_URI, regId);
+    }
+
+    @Override
+    public int delete(@NonNull Uri uri, @Nullable String selection, @Nullable String[] selectionArgs) {
+        if (uriMatcher.match(uri) == V3AUTH_ID)
+            selection = "_id=" + uri.getLastPathSegment();
+        SQLiteDatabase db = mDatabase.getWritableDatabase();
+        int rows = db.delete(ClientAuthDatabase.DATABASE_NAME, selection, selectionArgs);
+        getContext().getContentResolver().notifyChange(CONTENT_URI, null);
+        return rows;
+    }
+
+    @Override
+    public int update(@NonNull Uri uri, @Nullable ContentValues values, @Nullable String selection, @Nullable String[] selectionArgs) {
+        SQLiteDatabase db = mDatabase.getWritableDatabase();
+        if (uriMatcher.match(uri) == V3AUTH_ID)
+            selection = "id_=" + uri.getLastPathSegment();
+        int rows = db.update(ClientAuthDatabase.DATABASE_NAME, values, selection, null);
+        getContext().getContentResolver().notifyChange(CONTENT_URI, null);
+        return rows;
+    }
+
+    public static final class V3ClientAuth implements BaseColumns {
+        private V3ClientAuth() {
+        } // no-op
+
+        public static final String
+                DOMAIN = "domain",
+                HASH = "hash",
+                ENABLED = "enabled";
+    }
+
+}
diff --git a/app/src/main/java/org/torproject/android/ui/v3onionservice/AddV3ClientAuthDialogFragment.java b/app/src/main/java/org/torproject/android/ui/v3onionservice/clientauth/ClientAuthCreateDialogFragment.java
similarity index 69%
rename from app/src/main/java/org/torproject/android/ui/v3onionservice/AddV3ClientAuthDialogFragment.java
rename to app/src/main/java/org/torproject/android/ui/v3onionservice/clientauth/ClientAuthCreateDialogFragment.java
index a3efe0d6..5ce4f491 100644
--- a/app/src/main/java/org/torproject/android/ui/v3onionservice/AddV3ClientAuthDialogFragment.java
+++ b/app/src/main/java/org/torproject/android/ui/v3onionservice/clientauth/ClientAuthCreateDialogFragment.java
@@ -1,20 +1,23 @@
-package org.torproject.android.ui.v3onionservice;
+package org.torproject.android.ui.v3onionservice.clientauth;
 
 import android.app.AlertDialog;
 import android.app.Dialog;
+import android.content.ContentResolver;
+import android.content.ContentValues;
 import android.content.Context;
 import android.os.Bundle;
 import android.text.Editable;
 import android.text.TextWatcher;
 import android.view.View;
 import android.widget.EditText;
+import android.widget.Toast;
 
 import androidx.annotation.NonNull;
 import androidx.fragment.app.DialogFragment;
 
 import org.torproject.android.R;
 
-public class AddV3ClientAuthDialogFragment extends DialogFragment {
+public class ClientAuthCreateDialogFragment extends DialogFragment {
 
     private EditText etOnionUrl, etKeyHash;
     private TextWatcher inputValidator;
@@ -58,7 +61,22 @@ public class AddV3ClientAuthDialogFragment extends DialogFragment {
     }
 
     private void doSave(Context context) {
+        String onionName = sanitizeOnionDomainTextField();
+        String hash = etKeyHash.getText().toString();
+        ContentValues fields = new ContentValues();
+        fields.put(ClientAuthContentProvider.V3ClientAuth.DOMAIN, onionName);
+        fields.put(ClientAuthContentProvider.V3ClientAuth.HASH, hash);
+        ContentResolver cr = context.getContentResolver();
+        cr.insert(ClientAuthContentProvider.CONTENT_URI, fields);
+        Toast.makeText(context, R.string.please_restart_Orbot_to_enable_the_changes, Toast.LENGTH_LONG).show();
+    }
 
+    private String sanitizeOnionDomainTextField() {
+        String domain = ".onion";
+        String onion = etOnionUrl.getText().toString();
+        if (onion.endsWith(domain))
+            return onion.substring(0, onion.indexOf(domain));
+        return onion;
     }
 
     @Override
@@ -68,10 +86,7 @@ public class AddV3ClientAuthDialogFragment extends DialogFragment {
     }
 
     private boolean checkInput() {
-        String domain = ".onion";
-        String onion = etOnionUrl.getText().toString();
-        if (onion.endsWith(domain))
-            onion = onion.substring(0, onion.indexOf(domain));
+        String onion = sanitizeOnionDomainTextField();
         if (!onion.matches("([a-z0-9]{56})")) return false;
         String hash = etKeyHash.getText().toString();
         return hash.matches("([A-Z2-7]{52})");
diff --git a/app/src/main/java/org/torproject/android/ui/v3onionservice/clientauth/ClientAuthDatabase.java b/app/src/main/java/org/torproject/android/ui/v3onionservice/clientauth/ClientAuthDatabase.java
new file mode 100644
index 00000000..50b04bc6
--- /dev/null
+++ b/app/src/main/java/org/torproject/android/ui/v3onionservice/clientauth/ClientAuthDatabase.java
@@ -0,0 +1,30 @@
+package org.torproject.android.ui.v3onionservice.clientauth;
+
+import android.content.Context;
+import android.database.sqlite.SQLiteDatabase;
+import android.database.sqlite.SQLiteOpenHelper;
+
+public class ClientAuthDatabase extends SQLiteOpenHelper {
+    static final String DATABASE_NAME = "v3_client_auths";
+    private static final int DATABASE_VERSION = 1;
+
+    private static final String V3_AUTHS_CREATE_SQL =
+            "CREATE TABLE " + DATABASE_NAME + " (" +
+                    "_id INTEGER PRIMARY KEY AUTOINCREMENT, " +
+                    "domain TEXT, " +
+                    "hash TEXT, " +
+                    "enabled INTEGER DEFAULT 1);";
+
+    ClientAuthDatabase(Context context) {
+        super(context, DATABASE_NAME, null, DATABASE_VERSION);
+    }
+
+    @Override
+    public void onCreate(SQLiteDatabase db) {
+        db.execSQL(V3_AUTHS_CREATE_SQL);
+    }
+
+    @Override
+    public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
+    }
+}
diff --git a/app/src/main/java/org/torproject/android/ui/v3onionservice/clientauth/ClientAuthDeleteDialogFragment.java b/app/src/main/java/org/torproject/android/ui/v3onionservice/clientauth/ClientAuthDeleteDialogFragment.java
new file mode 100644
index 00000000..6a349f8e
--- /dev/null
+++ b/app/src/main/java/org/torproject/android/ui/v3onionservice/clientauth/ClientAuthDeleteDialogFragment.java
@@ -0,0 +1,36 @@
+package org.torproject.android.ui.v3onionservice.clientauth;
+
+import android.app.AlertDialog;
+import android.app.Dialog;
+import android.os.Bundle;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.fragment.app.DialogFragment;
+
+import org.torproject.android.R;
+
+public class ClientAuthDeleteDialogFragment extends DialogFragment {
+
+    public ClientAuthDeleteDialogFragment() {}
+    public ClientAuthDeleteDialogFragment(Bundle args) {
+        super();
+        setArguments(args);
+    }
+
+    @NonNull
+    @Override
+    public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) {
+        return new AlertDialog.Builder(getActivity())
+                .setTitle(R.string.v3_delete_client_authorization)
+                .setPositiveButton(R.string.v3_delete_client_authorization_confirm, (dialog, which) -> doDelete())
+                .setNegativeButton(android.R.string.cancel, (dialog, which) -> dialog.dismiss())
+                .create();
+    }
+
+    private void doDelete() {
+        int id = getArguments().getInt(ClientAuthActivity.BUNDLE_KEY_ID);
+        getContext().getContentResolver().delete(ClientAuthContentProvider.CONTENT_URI, ClientAuthContentProvider.V3ClientAuth._ID + "=" + id, null);
+    }
+
+}
diff --git a/app/src/main/java/org/torproject/android/ui/v3onionservice/clientauth/ClientAuthListAdapter.java b/app/src/main/java/org/torproject/android/ui/v3onionservice/clientauth/ClientAuthListAdapter.java
new file mode 100644
index 00000000..8f08225d
--- /dev/null
+++ b/app/src/main/java/org/torproject/android/ui/v3onionservice/clientauth/ClientAuthListAdapter.java
@@ -0,0 +1,49 @@
+package org.torproject.android.ui.v3onionservice.clientauth;
+
+import android.content.ContentResolver;
+import android.content.ContentValues;
+import android.content.Context;
+import android.database.Cursor;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.CursorAdapter;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import androidx.appcompat.widget.SwitchCompat;
+
+import org.torproject.android.R;
+
+public class ClientAuthListAdapter extends CursorAdapter {
+    private final LayoutInflater mLayoutInflator;
+
+    ClientAuthListAdapter(Context context, Cursor cursor, int flags) {
+        super(context, cursor, flags);
+        mLayoutInflator = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
+    }
+
+    @Override
+    public View newView(Context context, Cursor cursor, ViewGroup parent) {
+        return mLayoutInflator.inflate(R.layout.layout_client_cookie_list_item, null);
+    }
+
+    @Override
+    public void bindView(View view, Context context, Cursor cursor) {
+        int id = cursor.getInt(cursor.getColumnIndex(ClientAuthContentProvider.V3ClientAuth._ID));
+        final String where = ClientAuthContentProvider.V3ClientAuth._ID + "=" + id;
+        TextView domain = view.findViewById(R.id.cookie_onion);
+        String url = cursor.getString(cursor.getColumnIndex(ClientAuthContentProvider.V3ClientAuth.DOMAIN)) + ".onion";
+        domain.setText(url);
+        SwitchCompat enabled = view.findViewById(R.id.cookie_switch);
+        enabled.setChecked(cursor.getInt(cursor.getColumnIndex(ClientAuthContentProvider.V3ClientAuth.ENABLED)) == 1);
+        enabled.setOnCheckedChangeListener((buttonView, isChecked) -> {
+            ContentResolver resolver = context.getContentResolver();
+            ContentValues fields = new ContentValues();
+            fields.put(ClientAuthContentProvider.V3ClientAuth.ENABLED, isChecked);
+            resolver.update(ClientAuthContentProvider.CONTENT_URI, fields, where, null);
+            Toast.makeText(context, R.string.please_restart_Orbot_to_enable_the_changes, Toast.LENGTH_LONG).show();
+        });
+    }
+}
+
diff --git a/app/src/main/res/layout/activity_v3auth.xml b/app/src/main/res/layout/activity_v3auth.xml
index a0e66741..647976c8 100644
--- a/app/src/main/res/layout/activity_v3auth.xml
+++ b/app/src/main/res/layout/activity_v3auth.xml
@@ -21,7 +21,16 @@
 
     </com.google.android.material.appbar.AppBarLayout>
 
-    <include layout="@layout/layout_content_client_cookies" />
+    <FrameLayout
+        app:layout_behavior="@string/appbar_scrolling_view_behavior"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content">
+
+        <ListView
+            android:id="@+id/auth_hash_list"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content" />
+    </FrameLayout>
 
     <com.google.android.material.floatingactionbutton.FloatingActionButton
         android:id="@+id/fab"
diff --git a/app/src/main/res/layout/dialog_add_v3_client_auth.xml b/app/src/main/res/layout/dialog_add_v3_client_auth.xml
index ec310a0c..9502da01 100644
--- a/app/src/main/res/layout/dialog_add_v3_client_auth.xml
+++ b/app/src/main/res/layout/dialog_add_v3_client_auth.xml
@@ -8,7 +8,8 @@
     <TextView
         android:layout_width="match_parent"
         android:layout_height="wrap_content"
-        android:text="@string/onion"
+        android:text="@string/v3_onion"
+        android:hint="@string/onion"
         android:textAppearance="@style/TextAppearance.AppCompat.Widget.PopupMenu.Small" />
 
     <org.torproject.android.core.ui.NoPersonalizedLearningEditText
@@ -21,7 +22,7 @@
     <TextView
         android:layout_width="match_parent"
         android:layout_height="wrap_content"
-        android:text="@string/auth_cookie"
+        android:text="@string/v3_key_hash"
         android:textAppearance="@style/TextAppearance.AppCompat.Widget.PopupMenu.Small" />
 
     <org.torproject.android.core.ui.NoPersonalizedLearningEditText
diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml
index c1ac42e8..1ad03b32 100644
--- a/app/src/main/res/values/dimens.xml
+++ b/app/src/main/res/values/dimens.xml
@@ -238,4 +238,5 @@
     <dimen name="activity_horizontal_margin">16dp</dimen>
     <dimen name="activity_vertical_margin">16dp</dimen>
     <dimen name="fab_margin">16dp</dimen>
+    <dimen name="alert_dialog_margin">20dp</dimen>
 </resources>
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index a312d6da..e812a3c2 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -169,9 +169,18 @@
 
     <string name="vpn_default_world">Global (Auto)</string>
     <string name="hidden_services">Onion Services</string>
-    <string name="v2_hidden_services">V2 Onion Services (Deprecated)</string>
-    <string name="v3_hosted_services">Hosted V3 Onion Services</string>
-    <string name="v3_client_auth">V3 Onion Service Client Authorization</string>
+    <string name="v2_hidden_services">v2 Onion Services (Deprecated)</string>
+    <string name="v3_hosted_services">Hosted v3 Onion Services</string>
+    <string name="v3_client_auth">v3 Onion Service Client Authorization</string>
+    <string name="v3_client_auth_activity_title">v3 Client Authorization</string>
+    <string name="v3_key_hash">X25519 Private Key in Base 32</string>
+    <string name="v3_onion">v3 .onion Domain</string>
+    <string name="v3_backup_key">Backup Client Authorization Key</string>
+    <string name="v3_backup_key_warning">Warning: This Could Expose Your Key to Other Apps</string>
+    <string name="v3_delete_client_authorization">Delete Client Authorization Key</string>
+    <string name="v3_delete_client_authorization_confirm">Delete Client Authorization</string>
+    <string name="v3_backup_name_hint">Backup filename…</string>
+    <string name="confirm">Confirm</string>
     <string name="title_activity_hidden_services">Onion Services</string>
     <string name="menu_hidden_services">Onion Services</string>
     <string name="save">Save</string>
@@ -181,7 +190,7 @@
     <string name="done">Done!</string>
     <string name="copy_address_to_clipboard">Copy address to clipboard</string>
     <string name="show_auth_cookie">Show auth cookie</string>
-    <string name="backup_service">Backup Service</string>
+    <string name="backup_service">Backup Service <i>(Warning: This Could Expose Your Service Configuration to Other Apps)</i></string>
     <string name="delete_service">Delete Service</string>
     <string name="backup_saved_at_external_storage">Backup saved at external storage</string>
     <string name="backup_restored">Backup restored</string>
diff --git a/appcore/src/main/java/org/torproject/android/core/DiskUtils.kt b/appcore/src/main/java/org/torproject/android/core/DiskUtils.kt
index 8ac067b7..f473297c 100644
--- a/appcore/src/main/java/org/torproject/android/core/DiskUtils.kt
+++ b/appcore/src/main/java/org/torproject/android/core/DiskUtils.kt
@@ -72,7 +72,7 @@ object DiskUtils {
     }
 
     @JvmStatic
-    fun recursivelyDeleteDirectory(directory: File) : Boolean {
+    fun recursivelyDeleteDirectory(directory: File): Boolean {
         val contents = directory.listFiles()
         contents?.forEach { recursivelyDeleteDirectory(it) }
         return directory.delete()
diff --git a/orbotservice/src/main/java/org/torproject/android/service/OrbotService.java b/orbotservice/src/main/java/org/torproject/android/service/OrbotService.java
index b9dd34a1..9a401ac6 100644
--- a/orbotservice/src/main/java/org/torproject/android/service/OrbotService.java
+++ b/orbotservice/src/main/java/org/torproject/android/service/OrbotService.java
@@ -58,6 +58,7 @@ import java.io.DataInputStream;
 import java.io.File;
 import java.io.FileInputStream;
 import java.io.FileNotFoundException;
+import java.io.FileOutputStream;
 import java.io.FileReader;
 import java.io.FileWriter;
 import java.io.IOException;
@@ -89,6 +90,7 @@ public class OrbotService extends VpnService implements TorServiceConstants, Orb
     private static final Uri V2_HS_CONTENT_URI = Uri.parse("content://org.torproject.android.ui.hiddenservices.providers/hs");
     private static final Uri V3_ONION_SERVICES_CONTENT_URI = Uri.parse("content://org.torproject.android.ui.v3onionservice/v3");
     private static final Uri COOKIE_CONTENT_URI = Uri.parse("content://org.torproject.android.ui.hiddenservices.providers.cookie/cookie");
+    private static final Uri V3_CLIENT_AUTH_URI = Uri.parse("content://org.torproject.android.ui.v3onionservice.clientauth/v3auth");
     private final static String NOTIFICATION_CHANNEL_ID = "orbot_channel_1";
     private static final String[] LEGACY_V2_ONION_SERVICE_PROJECTION = new String[]{
             OnionService._ID,
@@ -112,6 +114,12 @@ public class OrbotService extends VpnService implements TorServiceConstants, Orb
             ClientCookie.DOMAIN,
             ClientCookie.AUTH_COOKIE_VALUE,
             ClientCookie.ENABLED};
+    private static final String[] V3_CLIENT_AUTH_PROJECTION = new String[]{
+            V3ClientAuth._ID,
+            V3ClientAuth.DOMAIN,
+            V3ClientAuth.HASH,
+            V3ClientAuth.ENABLED
+    };
     public static int mPortSOCKS = -1;
     public static int mPortHTTP = -1;
     public static int mPortDns = TOR_DNS_PORT_DEFAULT;
@@ -135,7 +143,7 @@ public class OrbotService extends VpnService implements TorServiceConstants, Orb
     private NotificationManager mNotificationManager = null;
     private NotificationCompat.Builder mNotifyBuilder;
     private boolean mNotificationShowing = false;
-    private File mHSBasePath, mV3OnionBasePath;
+    private File mHSBasePath, mV3OnionBasePath, mV3AuthBasePath;
     private ArrayList<Bridge> alBridges = null;
 
     /**
@@ -305,17 +313,17 @@ public class OrbotService extends VpnService implements TorServiceConstants, Orb
 
     private void stopTorAsync() {
 
-        new Thread(() ->{
+        new Thread(() -> {
             Log.i("OrbotService", "stopTor");
             try {
                 sendCallbackStatus(STATUS_STOPPING);
                 sendCallbackLogMessage(getString(R.string.status_shutting_down));
 
-            	if (useIPtObfsMeekProxy())
-               	 IPtProxy.stopObfs4Proxy();
+                if (useIPtObfsMeekProxy())
+                    IPtProxy.stopObfs4Proxy();
 
-            	if (useIPtSnowflakeProxy())
-                IPtProxy.stopSnowflake();
+                if (useIPtSnowflakeProxy())
+                    IPtProxy.stopSnowflake();
 
 
                 stopTorDaemon(true);
@@ -333,21 +341,19 @@ public class OrbotService extends VpnService implements TorServiceConstants, Orb
         }).start();
     }
 
-    private static boolean useIPtObfsMeekProxy ()
-    {
+    private static boolean useIPtObfsMeekProxy() {
         String bridgeList = Prefs.getBridgesList();
-        return bridgeList.contains("obfs")||bridgeList.contains("meek");
+        return bridgeList.contains("obfs") || bridgeList.contains("meek");
     }
 
-    private static boolean useIPtSnowflakeProxy ()
-    {
+    private static boolean useIPtSnowflakeProxy() {
         String bridgeList = Prefs.getBridgesList();
         return bridgeList.contains("snowflake");
     }
 
-    private void startSnowflakeProxy () {
+    private void startSnowflakeProxy() {
         //this is using the current, default Tor snowflake infrastructure
-        IPtProxy.startSnowflake( "stun:stun.l.google.com:19302", "https://snowflake-broker.azureedge.net/",
+        IPtProxy.startSnowflake("stun:stun.l.google.com:19302", "https://snowflake-broker.azureedge.net/",
                 "ajax.aspnetcdn.com", null, true, false, true, 3);
     }
 
@@ -448,6 +454,10 @@ public class OrbotService extends VpnService implements TorServiceConstants, Orb
             if (!mV3OnionBasePath.isDirectory())
                 mV3OnionBasePath.mkdirs();
 
+            mV3AuthBasePath = new File(getFilesDir().getAbsolutePath(), TorServiceConstants.V3_CLIENT_AUTH_DIR);
+            if (!mV3AuthBasePath.isDirectory())
+                mV3AuthBasePath.mkdirs();
+
             mEventHandler = new TorEventHandler(this);
 
             if (mNotificationManager == null) {
@@ -780,6 +790,7 @@ public class OrbotService extends VpnService implements TorServiceConstants, Orb
         }
     }
 
+
     private void updateV3OnionNames() throws SecurityException {
         ContentResolver contentResolver = getApplicationContext().getContentResolver();
         Cursor onionServices = contentResolver.query(V3_ONION_SERVICES_CONTENT_URI, null, null, null, null);
@@ -1309,8 +1320,7 @@ public class OrbotService extends VpnService implements TorServiceConstants, Orb
 
             if (!TextUtils.isEmpty(builtInBridgeType))
                 getBridges(builtInBridgeType, extraLines);
-            else
-            {
+            else {
                 String[] bridgeListLines = parseBridgesFromSettings(bridgeList);
                 int bridgeIdx = (int) Math.floor(Math.random() * ((double) bridgeListLines.length));
                 String bridgeLine = bridgeListLines[bridgeIdx];
@@ -1370,6 +1380,7 @@ public class OrbotService extends VpnService implements TorServiceConstants, Orb
 
         ContentResolver contentResolver = getApplicationContext().getContentResolver();
         addV3OnionServicesToTorrc(extraLines, contentResolver);
+        addV3ClientAuthToTorrc(extraLines, contentResolver);
         addV2HiddenServicesToTorrc(extraLines, contentResolver);
         addV2ClientCookiesToTorrc(extraLines, contentResolver);
         return extraLines;
@@ -1428,6 +1439,34 @@ public class OrbotService extends VpnService implements TorServiceConstants, Orb
         }
     }
 
+    public static String buildV3ClientAuthFile(String domain, String keyHash) {
+        return domain + ":descriptor:x25519:" + keyHash;
+    }
+
+    private void addV3ClientAuthToTorrc(StringBuffer torrc, ContentResolver contentResolver) {
+        Cursor v3auths = contentResolver.query(V3_CLIENT_AUTH_URI, V3_CLIENT_AUTH_PROJECTION, V3ClientAuth.ENABLED + "=1", null, null);
+        if (v3auths != null) {
+            for (File file : mV3AuthBasePath.listFiles()) {
+                if (!file.isDirectory())
+                    file.delete(); // todo the adapter should maybe just write these files and not do this in service...
+            }
+            torrc.append("ClientOnionAuthDir " + mV3AuthBasePath.getAbsolutePath()).append('\n');
+            try {
+                while (v3auths.moveToNext()) {
+                    String domain = v3auths.getString(v3auths.getColumnIndex(V3ClientAuth.DOMAIN));
+                    String hash = v3auths.getString(v3auths.getColumnIndex(V3ClientAuth.HASH));
+                    File authFile = new File(mV3AuthBasePath, domain + ".auth_private");
+                    authFile.createNewFile();
+                    FileOutputStream fos = new FileOutputStream(authFile);
+                    fos.write(buildV3ClientAuthFile(domain, hash).getBytes());
+                    fos.close();
+                }
+            } catch (Exception e) {
+                Log.e(TAG, "error adding v3 client auth...");
+            }
+        }
+    }
+
     private void addV2ClientCookiesToTorrc(StringBuffer torrc, ContentResolver contentResolver) {
         try {
             Cursor client_cookies = contentResolver.query(COOKIE_CONTENT_URI, LEGACY_COOKIE_PROJECTION, ClientCookie.ENABLED + "=1", null, null);
@@ -1617,18 +1656,18 @@ public class OrbotService extends VpnService implements TorServiceConstants, Orb
         public static final String AUTH_COOKIE = "auth_cookie";
         public static final String AUTH_COOKIE_VALUE = "auth_cookie_value";
         public static final String ENABLED = "enabled";
+    }
 
-        private OnionService() {
-        }
+    public static final class V3ClientAuth implements BaseColumns {
+        public static final String DOMAIN = "domain";
+        public static final String HASH = "hash";
+        public static final String ENABLED = "enabled";
     }
 
     public static final class ClientCookie implements BaseColumns {
         public static final String DOMAIN = "domain";
         public static final String AUTH_COOKIE_VALUE = "auth_cookie_value";
         public static final String ENABLED = "enabled";
-
-        private ClientCookie() {
-        }
     }
 
     // for bridge loading from the assets default bridges.txt file
@@ -1654,7 +1693,7 @@ public class OrbotService extends VpnService implements TorServiceConstants, Orb
                         IPtProxy.startObfs4Proxy("DEBUG", false, false);
 
                     if (useIPtSnowflakeProxy())
-                       startSnowflakeProxy();
+                        startSnowflakeProxy();
 
 
                     startTor();
diff --git a/orbotservice/src/main/java/org/torproject/android/service/TorServiceConstants.java b/orbotservice/src/main/java/org/torproject/android/service/TorServiceConstants.java
index 915f149d..e69d8ba6 100644
--- a/orbotservice/src/main/java/org/torproject/android/service/TorServiceConstants.java
+++ b/orbotservice/src/main/java/org/torproject/android/service/TorServiceConstants.java
@@ -113,5 +113,6 @@ public interface TorServiceConstants {
 
     String HIDDEN_SERVICES_DIR = "hidden_services";
     String ONION_SERVICES_DIR = "v3_onion_services";
+    String V3_CLIENT_AUTH_DIR = "v3_client_auth";
 
 }





More information about the tor-commits mailing list