From eb1cd3632b310d702b0b2f85e15f8eaaabaff824 Mon Sep 17 00:00:00 2001 From: althafvly Date: Thu, 4 Sep 2025 10:43:05 +0530 Subject: [PATCH 1/9] AM: Import untouched nextcloud code for notes app --- .../android/sso/InputStreamBinder.java | 527 ++++++ .../nextcloud/android/sso/PatchMethod.java | 100 ++ .../nextcloud/android/sso/PlainHeader.java | 43 + .../com/nextcloud/android/sso/Response.java | 59 + .../services/AccountManagerService.java | 44 + .../activity/SsoGrantPermissionActivity.java | 216 +++ .../com/owncloud/utils/EncryptionUtils.java | 1587 +++++++++++++++++ app/src/ose/AndroidManifest.xml | 10 + 8 files changed, 2586 insertions(+) create mode 100644 app/src/main/java/com/nextcloud/android/sso/InputStreamBinder.java create mode 100644 app/src/main/java/com/nextcloud/android/sso/PatchMethod.java create mode 100644 app/src/main/java/com/nextcloud/android/sso/PlainHeader.java create mode 100644 app/src/main/java/com/nextcloud/android/sso/Response.java create mode 100644 app/src/main/java/com/owncloud/android/services/AccountManagerService.java create mode 100644 app/src/main/java/com/owncloud/android/ui/activity/SsoGrantPermissionActivity.java create mode 100644 app/src/main/java/com/owncloud/utils/EncryptionUtils.java diff --git a/app/src/main/java/com/nextcloud/android/sso/InputStreamBinder.java b/app/src/main/java/com/nextcloud/android/sso/InputStreamBinder.java new file mode 100644 index 000000000..31e061b57 --- /dev/null +++ b/app/src/main/java/com/nextcloud/android/sso/InputStreamBinder.java @@ -0,0 +1,527 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2019 David Luhmer + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + * + * More information here: https://github.com/abeluck/android-streams-ipc + */ +package com.nextcloud.android.sso; + +import android.accounts.Account; +import android.accounts.AuthenticatorException; +import android.accounts.OperationCanceledException; +import android.content.Context; +import android.content.SharedPreferences; +import android.net.Uri; +import android.os.Binder; +import android.os.ParcelFileDescriptor; +import android.text.TextUtils; + +import com.nextcloud.android.sso.aidl.IInputStreamService; +import com.nextcloud.android.sso.aidl.NextcloudRequest; +import com.nextcloud.android.sso.aidl.ParcelFileDescriptorUtil; +import com.nextcloud.client.account.UserAccountManager; +import com.owncloud.android.lib.common.OwnCloudAccount; +import com.owncloud.android.lib.common.OwnCloudClient; +import com.owncloud.android.lib.common.OwnCloudClientManager; +import com.owncloud.android.lib.common.OwnCloudClientManagerFactory; +import com.owncloud.android.lib.common.utils.Log_OC; +import com.owncloud.android.utils.EncryptionUtils; + +import org.apache.commons.httpclient.HttpConnection; +import org.apache.commons.httpclient.HttpMethodBase; +import org.apache.commons.httpclient.HttpState; +import org.apache.commons.httpclient.NameValuePair; +import org.apache.commons.httpclient.methods.DeleteMethod; +import org.apache.commons.httpclient.methods.GetMethod; +import org.apache.commons.httpclient.methods.HeadMethod; +import org.apache.commons.httpclient.methods.InputStreamRequestEntity; +import org.apache.commons.httpclient.methods.PostMethod; +import org.apache.commons.httpclient.methods.PutMethod; +import org.apache.commons.httpclient.methods.RequestEntity; +import org.apache.commons.httpclient.methods.StringRequestEntity; +import org.apache.jackrabbit.webdav.DavConstants; +import org.apache.jackrabbit.webdav.client.methods.MkColMethod; +import org.apache.jackrabbit.webdav.client.methods.PropFindMethod; +import org.apache.jackrabbit.webdav.property.DavPropertyNameSet; + +import java.io.BufferedReader; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.ObjectInputStream; +import java.io.ObjectOutputStream; +import java.io.Serializable; +import java.util.Collection; +import java.util.List; +import java.util.Map; + +import androidx.annotation.VisibleForTesting; + +import static com.nextcloud.android.sso.Constants.DELIMITER; +import static com.nextcloud.android.sso.Constants.EXCEPTION_ACCOUNT_NOT_FOUND; +import static com.nextcloud.android.sso.Constants.EXCEPTION_HTTP_REQUEST_FAILED; +import static com.nextcloud.android.sso.Constants.EXCEPTION_INVALID_REQUEST_URL; +import static com.nextcloud.android.sso.Constants.EXCEPTION_INVALID_TOKEN; +import static com.nextcloud.android.sso.Constants.EXCEPTION_UNSUPPORTED_METHOD; +import static com.nextcloud.android.sso.Constants.SSO_SHARED_PREFERENCE; + + +/** + * Stream binder to pass usable InputStreams across the process boundary in Android. + */ +public class InputStreamBinder extends IInputStreamService.Stub { + + private final static String TAG = "InputStreamBinder"; + private static final String CONTENT_TYPE_APPLICATION_JSON = "application/json"; + private static final String CHARSET_UTF8 = "UTF-8"; + + private static final int HTTP_STATUS_CODE_OK = 200; + private static final int HTTP_STATUS_CODE_MULTIPLE_CHOICES = 300; + + private static final char PATH_SEPARATOR = '/'; + private static final int ZERO_LENGTH = 0; + private Context context; + private UserAccountManager accountManager; + + public InputStreamBinder(Context context, UserAccountManager accountManager) { + this.context = context; + this.accountManager = accountManager; + } + + public ParcelFileDescriptor performNextcloudRequestV2(ParcelFileDescriptor input) { + return performNextcloudRequestAndBodyStreamV2(input, null); + } + + public ParcelFileDescriptor performNextcloudRequestAndBodyStreamV2( + ParcelFileDescriptor input, + ParcelFileDescriptor requestBodyParcelFileDescriptor) { + // read the input + final InputStream is = new ParcelFileDescriptor.AutoCloseInputStream(input); + + final InputStream requestBodyInputStream = requestBodyParcelFileDescriptor != null ? + new ParcelFileDescriptor.AutoCloseInputStream(requestBodyParcelFileDescriptor) : null; + Exception exception = null; + Response response = new Response(); + + try { + // Start request and catch exceptions + NextcloudRequest request = deserializeObjectAndCloseStream(is); + response = processRequestV2(request, requestBodyInputStream); + } catch (Exception e) { + Log_OC.e(TAG, "Error during Nextcloud request", e); + exception = e; + } + + try { + // Write exception to the stream followed by the actual network stream + InputStream exceptionStream = serializeObjectToInputStreamV2(exception, response.getPlainHeadersString()); + InputStream resultStream = new java.io.SequenceInputStream(exceptionStream, response.getBody()); + + return ParcelFileDescriptorUtil.pipeFrom(resultStream, + thread -> Log_OC.d(TAG, "Done sending result"), + response.getMethod()); + } catch (IOException e) { + Log_OC.e(TAG, "Error while sending response back to client app", e); + } + return null; + } + + public ParcelFileDescriptor performNextcloudRequest(ParcelFileDescriptor input) { + return performNextcloudRequestAndBodyStream(input, null); + } + + public ParcelFileDescriptor performNextcloudRequestAndBodyStream( + ParcelFileDescriptor input, + ParcelFileDescriptor requestBodyParcelFileDescriptor) { + // read the input + final InputStream is = new ParcelFileDescriptor.AutoCloseInputStream(input); + + final InputStream requestBodyInputStream = requestBodyParcelFileDescriptor != null ? + new ParcelFileDescriptor.AutoCloseInputStream(requestBodyParcelFileDescriptor) : null; + Exception exception = null; + HttpMethodBase httpMethod = null; + InputStream httpStream = new InputStream() { + @Override + public int read() { + return ZERO_LENGTH; + } + }; + + try { + // Start request and catch exceptions + NextcloudRequest request = deserializeObjectAndCloseStream(is); + httpMethod = processRequest(request, requestBodyInputStream); + httpStream = httpMethod.getResponseBodyAsStream(); + } catch (Exception e) { + Log_OC.e(TAG, "Error during Nextcloud request", e); + exception = e; + } + + try { + // Write exception to the stream followed by the actual network stream + InputStream exceptionStream = serializeObjectToInputStream(exception); + InputStream resultStream; + if (httpStream != null) { + resultStream = new java.io.SequenceInputStream(exceptionStream, httpStream); + } else { + resultStream = exceptionStream; + } + return ParcelFileDescriptorUtil.pipeFrom(resultStream, + thread -> Log_OC.d(TAG, "Done sending result"), + httpMethod); + } catch (IOException e) { + Log_OC.e(TAG, "Error while sending response back to client app", e); + } + return null; + } + + private ByteArrayInputStream serializeObjectToInputStreamV2(Exception exception, String headers) { + byte[] baosByteArray = new byte[0]; + try (ByteArrayOutputStream baos = new ByteArrayOutputStream(); + ObjectOutputStream oos = new ObjectOutputStream(baos)) { + oos.writeObject(exception); + oos.writeObject(headers); + oos.flush(); + oos.close(); + + baosByteArray = baos.toByteArray(); + } catch (IOException e) { + Log_OC.e(TAG, "Error while sending response back to client app", e); + } + + return new ByteArrayInputStream(baosByteArray); + } + + private ByteArrayInputStream serializeObjectToInputStream(T obj) throws IOException { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + ObjectOutputStream oos = new ObjectOutputStream(baos); + oos.writeObject(obj); + oos.flush(); + oos.close(); + return new ByteArrayInputStream(baos.toByteArray()); + } + + private T deserializeObjectAndCloseStream(InputStream is) throws IOException, + ClassNotFoundException { + ObjectInputStream ois = new ObjectInputStream(is); + T result = (T) ois.readObject(); + is.close(); + ois.close(); + return result; + } + + public class NCPropFindMethod extends PropFindMethod { + NCPropFindMethod(String uri, int propfindType, int depth) throws IOException { + super(uri, propfindType, new DavPropertyNameSet(), depth); + } + + @Override + protected void processResponseBody(HttpState httpState, HttpConnection httpConnection) { + // Do not process the response body here. Instead pass it on to client app. + } + } + + private HttpMethodBase buildMethod(NextcloudRequest request, Uri baseUri, InputStream requestBodyInputStream) + throws IOException { + String requestUrl = baseUri + request.getUrl(); + HttpMethodBase method; + switch (request.getMethod()) { + case "GET": + method = new GetMethod(requestUrl); + break; + + case "POST": + method = new PostMethod(requestUrl); + if (requestBodyInputStream != null) { + RequestEntity requestEntity = new InputStreamRequestEntity(requestBodyInputStream); + ((PostMethod) method).setRequestEntity(requestEntity); + } else if (request.getRequestBody() != null) { + StringRequestEntity requestEntity = new StringRequestEntity( + request.getRequestBody(), + CONTENT_TYPE_APPLICATION_JSON, + CHARSET_UTF8); + ((PostMethod) method).setRequestEntity(requestEntity); + } + break; + + case "PATCH": + method = new PatchMethod(requestUrl); + if (requestBodyInputStream != null) { + RequestEntity requestEntity = new InputStreamRequestEntity(requestBodyInputStream); + ((PatchMethod) method).setRequestEntity(requestEntity); + } else if (request.getRequestBody() != null) { + StringRequestEntity requestEntity = new StringRequestEntity( + request.getRequestBody(), + CONTENT_TYPE_APPLICATION_JSON, + CHARSET_UTF8); + ((PatchMethod) method).setRequestEntity(requestEntity); + } + break; + + case "PUT": + method = new PutMethod(requestUrl); + if (requestBodyInputStream != null) { + RequestEntity requestEntity = new InputStreamRequestEntity(requestBodyInputStream); + ((PutMethod) method).setRequestEntity(requestEntity); + } else if (request.getRequestBody() != null) { + StringRequestEntity requestEntity = new StringRequestEntity( + request.getRequestBody(), + CONTENT_TYPE_APPLICATION_JSON, + CHARSET_UTF8); + ((PutMethod) method).setRequestEntity(requestEntity); + } + break; + + case "DELETE": + method = new DeleteMethod(requestUrl); + break; + + case "PROPFIND": + method = new NCPropFindMethod(requestUrl, DavConstants.PROPFIND_ALL_PROP, DavConstants.DEPTH_1); + if (request.getRequestBody() != null) { + //text/xml; charset=UTF-8 is taken from XmlRequestEntity... Should be application/xml + StringRequestEntity requestEntity = new StringRequestEntity( + request.getRequestBody(), + "text/xml; charset=UTF-8", + CHARSET_UTF8); + ((PropFindMethod) method).setRequestEntity(requestEntity); + } + break; + + case "MKCOL": + method = new MkColMethod(requestUrl); + break; + + case "HEAD": + method = new HeadMethod(requestUrl); + break; + + default: + throw new UnsupportedOperationException(EXCEPTION_UNSUPPORTED_METHOD); + + } + return method; + } + + private HttpMethodBase processRequest(final NextcloudRequest request, final InputStream requestBodyInputStream) + throws UnsupportedOperationException, + com.owncloud.android.lib.common.accounts.AccountUtils.AccountNotFoundException, + OperationCanceledException, AuthenticatorException, IOException { + Account account = accountManager.getAccountByName(request.getAccountName()); + if (account == null) { + throw new IllegalStateException(EXCEPTION_ACCOUNT_NOT_FOUND); + } + + // Validate token + if (!isValid(request)) { + throw new IllegalStateException(EXCEPTION_INVALID_TOKEN); + } + + // Validate URL + if (request.getUrl().length() == 0 || request.getUrl().charAt(0) != PATH_SEPARATOR) { + throw new IllegalStateException(EXCEPTION_INVALID_REQUEST_URL, + new IllegalStateException("URL need to start with a /")); + } + + OwnCloudClientManager ownCloudClientManager = OwnCloudClientManagerFactory.getDefaultSingleton(); + OwnCloudAccount ocAccount = new OwnCloudAccount(account, context); + OwnCloudClient client = ownCloudClientManager.getClientFor(ocAccount, context); + + HttpMethodBase method = buildMethod(request, client.getBaseUri(), requestBodyInputStream); + + if (request.getParameterV2() != null && !request.getParameterV2().isEmpty()) { + method.setQueryString(convertListToNVP(request.getParameterV2())); + } else { + method.setQueryString(convertMapToNVP(request.getParameter())); + } + method.addRequestHeader("OCS-APIREQUEST", "true"); + + for (Map.Entry> header : request.getHeader().entrySet()) { + // https://stackoverflow.com/a/3097052 + method.addRequestHeader(header.getKey(), TextUtils.join(",", header.getValue())); + + if ("OCS-APIREQUEST".equalsIgnoreCase(header.getKey())) { + throw new IllegalStateException( + "The 'OCS-APIREQUEST' header will be automatically added by the Nextcloud SSO Library. " + + "Please remove the header before making a request"); + } + } + + client.setFollowRedirects(request.isFollowRedirects()); + int status = client.executeMethod(method); + + // Check if status code is 2xx --> https://en.wikipedia.org/wiki/List_of_HTTP_status_codes#2xx_Success + if (status >= HTTP_STATUS_CODE_OK && status < HTTP_STATUS_CODE_MULTIPLE_CHOICES) { + return method; + } else { + InputStream inputStream = method.getResponseBodyAsStream(); + String total = "No response body"; + + // If response body is available + if (inputStream != null) { + total = inputStreamToString(inputStream); + Log_OC.e(TAG, total); + } + + method.releaseConnection(); + throw new IllegalStateException(EXCEPTION_HTTP_REQUEST_FAILED, + new IllegalStateException(String.valueOf(status), + new IllegalStateException(total))); + } + } + + private Response processRequestV2(final NextcloudRequest request, final InputStream requestBodyInputStream) + throws UnsupportedOperationException, + com.owncloud.android.lib.common.accounts.AccountUtils.AccountNotFoundException, + OperationCanceledException, AuthenticatorException, IOException { + Account account = accountManager.getAccountByName(request.getAccountName()); + if (account == null) { + throw new IllegalStateException(EXCEPTION_ACCOUNT_NOT_FOUND); + } + + // Validate token + if (!isValid(request)) { + throw new IllegalStateException(EXCEPTION_INVALID_TOKEN); + } + + // Validate URL + if (request.getUrl().length() == 0 || request.getUrl().charAt(0) != PATH_SEPARATOR) { + throw new IllegalStateException(EXCEPTION_INVALID_REQUEST_URL, + new IllegalStateException("URL need to start with a /")); + } + + OwnCloudClientManager ownCloudClientManager = OwnCloudClientManagerFactory.getDefaultSingleton(); + OwnCloudAccount ocAccount = new OwnCloudAccount(account, context); + OwnCloudClient client = ownCloudClientManager.getClientFor(ocAccount, context); + + HttpMethodBase method = buildMethod(request, client.getBaseUri(), requestBodyInputStream); + + if (request.getParameterV2() != null && !request.getParameterV2().isEmpty()) { + method.setQueryString(convertListToNVP(request.getParameterV2())); + } else { + method.setQueryString(convertMapToNVP(request.getParameter())); + } + + method.addRequestHeader("OCS-APIREQUEST", "true"); + + for (Map.Entry> header : request.getHeader().entrySet()) { + // https://stackoverflow.com/a/3097052 + method.addRequestHeader(header.getKey(), TextUtils.join(",", header.getValue())); + + if ("OCS-APIREQUEST".equalsIgnoreCase(header.getKey())) { + throw new IllegalStateException( + "The 'OCS-APIREQUEST' header will be automatically added by the Nextcloud SSO Library. " + + "Please remove the header before making a request"); + } + } + + client.setFollowRedirects(request.isFollowRedirects()); + int status = client.executeMethod(method); + + // Check if status code is 2xx --> https://en.wikipedia.org/wiki/List_of_HTTP_status_codes#2xx_Success + if (status >= HTTP_STATUS_CODE_OK && status < HTTP_STATUS_CODE_MULTIPLE_CHOICES) { + return new Response(method); + } else { + InputStream inputStream = method.getResponseBodyAsStream(); + String total = "No response body"; + + // If response body is available + if (inputStream != null) { + total = inputStreamToString(inputStream); + Log_OC.e(TAG, total); + } + + method.releaseConnection(); + throw new IllegalStateException(EXCEPTION_HTTP_REQUEST_FAILED, + new IllegalStateException(String.valueOf(status), + new IllegalStateException(total))); + } + } + + private boolean isValid(NextcloudRequest request) { + String[] callingPackageNames = context.getPackageManager().getPackagesForUid(Binder.getCallingUid()); + + SharedPreferences sharedPreferences = context.getSharedPreferences(SSO_SHARED_PREFERENCE, + Context.MODE_PRIVATE); + for (String callingPackageName : callingPackageNames) { + String hash = sharedPreferences.getString(callingPackageName + DELIMITER + request.getAccountName(), ""); + if (hash.isEmpty()) + continue; + if (validateToken(hash, request.getToken())) { + return true; + } + } + return false; + } + + private boolean validateToken(String hash, String token) { + if (!hash.contains("$")) { + throw new IllegalStateException(EXCEPTION_INVALID_TOKEN); + } + + String salt = hash.split("\\$")[1]; // TODO extract "$" + + String newHash = EncryptionUtils.generateSHA512(token, salt); + + // As discussed with Lukas R. at the Nextcloud Conf 2018, always compare whole strings + // and don't exit prematurely if the string does not match anymore to prevent timing-attacks + return isEqual(hash.getBytes(), newHash.getBytes()); + } + + // Taken from http://codahale.com/a-lesson-in-timing-attacks/ + private static boolean isEqual(byte[] a, byte[] b) { + if (a.length != b.length) { + return false; + } + + int result = 0; + for (int i = 0; i < a.length; i++) { + result |= a[i] ^ b[i]; + } + return result == 0; + } + + private static String inputStreamToString(InputStream inputStream) { + try { + StringBuilder total = new StringBuilder(); + BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream)); + String line = reader.readLine(); + while (line != null) { + total.append(line).append('\n'); + line = reader.readLine(); + } + return total.toString(); + } catch (Exception e) { + return e.getMessage(); + } + } + + @VisibleForTesting + public static NameValuePair[] convertMapToNVP(Map map) { + final var nvp = new NameValuePair[map.size()]; + int i = 0; + + for (Map.Entry entry : map.entrySet()) { + final var nameValuePair = new NameValuePair(entry.getKey(), entry.getValue()); + nvp[i] = nameValuePair; + i++; + } + + return nvp; + } + + @VisibleForTesting + public static NameValuePair[] convertListToNVP(Collection list) { + NameValuePair[] nvp = new NameValuePair[list.size()]; + int i = 0; + for (QueryParam pair : list) { + nvp[i] = new NameValuePair(pair.key, pair.value); + i++; + } + return nvp; + } +} diff --git a/app/src/main/java/com/nextcloud/android/sso/PatchMethod.java b/app/src/main/java/com/nextcloud/android/sso/PatchMethod.java new file mode 100644 index 000000000..34c5e8b24 --- /dev/null +++ b/app/src/main/java/com/nextcloud/android/sso/PatchMethod.java @@ -0,0 +1,100 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2021 Timo Triebensky + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + * + * More information here: https://github.com/abeluck/android-streams-ipc + */ +package com.nextcloud.android.sso; + +import org.apache.commons.httpclient.methods.ByteArrayRequestEntity; +import org.apache.commons.httpclient.methods.EntityEnclosingMethod; +import org.apache.commons.httpclient.methods.PostMethod; +import org.apache.commons.httpclient.methods.RequestEntity; +import org.apache.commons.httpclient.util.EncodingUtil; + +import java.util.Vector; + +public class PatchMethod extends PostMethod { + + /** + * The buffered request body consisting of NameValuePairs. + */ + private Vector params = new Vector(); + + /** + * No-arg constructor. + */ + public PatchMethod() { + super(); + } + + /** + * Constructor specifying a URI. + * + * @param uri either an absolute or relative URI + */ + public PatchMethod(String uri) { + super(uri); + } + + /** + * Returns "PATCH". + * + * @return "PATCH" + * @since 2.0 + */ + @Override + public String getName() { + return "PATCH"; + } + + /** + * Returns true if there is a request body to be sent. + * + * @return boolean + * @since 2.0beta1 + */ + protected boolean hasRequestContent() { + if (!this.params.isEmpty()) { + return true; + } else { + return super.hasRequestContent(); + } + } + + /** + * Clears request body. + * + * @since 2.0beta1 + */ + protected void clearRequestBody() { + this.params.clear(); + super.clearRequestBody(); + } + + /** + * Generates a request entity from the patch parameters, if present. Calls {@link + * EntityEnclosingMethod#generateRequestBody()} if parameters have not been set. + * + * @since 3.0 + */ + protected RequestEntity generateRequestEntity() { + if (!this.params.isEmpty()) { + // Use a ByteArrayRequestEntity instead of a StringRequestEntity. + // This is to avoid potential encoding issues. Form url encoded strings + // are ASCII by definition but the content type may not be. Treating the content + // as bytes allows us to keep the current charset without worrying about how + // this charset will effect the encoding of the form url encoded string. + String content = EncodingUtil.formUrlEncode(getParameters(), getRequestCharSet()); + return new ByteArrayRequestEntity( + EncodingUtil.getAsciiBytes(content), + FORM_URL_ENCODED_CONTENT_TYPE + ); + } else { + return super.generateRequestEntity(); + } + } +} diff --git a/app/src/main/java/com/nextcloud/android/sso/PlainHeader.java b/app/src/main/java/com/nextcloud/android/sso/PlainHeader.java new file mode 100644 index 000000000..07c23c6b4 --- /dev/null +++ b/app/src/main/java/com/nextcloud/android/sso/PlainHeader.java @@ -0,0 +1,43 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2019 Tobias Kaminsky + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.android.sso; + +import java.io.IOException; +import java.io.ObjectInputStream; +import java.io.ObjectOutputStream; +import java.io.Serializable; + +public class PlainHeader implements Serializable { + private static final long serialVersionUID = 3284979177401282512L; + + private String name; + private String value; + + PlainHeader(String name, String value) { + this.name = name; + this.value = value; + } + + private void writeObject(ObjectOutputStream oos) throws IOException { + oos.writeObject(name); + oos.writeObject(value); + } + + private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException { + name = (String) in.readObject(); + value = (String) in.readObject(); + } + + public String getName() { + return this.name; + } + + public String getValue() { + return this.value; + } +} diff --git a/app/src/main/java/com/nextcloud/android/sso/Response.java b/app/src/main/java/com/nextcloud/android/sso/Response.java new file mode 100644 index 000000000..2402b8b1d --- /dev/null +++ b/app/src/main/java/com/nextcloud/android/sso/Response.java @@ -0,0 +1,59 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2019 Tobias Kaminsky + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.android.sso; + +import com.google.gson.Gson; + +import org.apache.commons.httpclient.Header; +import org.apache.commons.httpclient.HttpMethodBase; + +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; + +public class Response { + private InputStream body; + private Header[] headers; + private HttpMethodBase method; + + public Response() { + headers = new Header[0]; + body = new InputStream() { + @Override + public int read() { + return 0; + } + }; + } + + public Response(HttpMethodBase methodBase) throws IOException { + this.method = methodBase; + this.body = methodBase.getResponseBodyAsStream(); + this.headers = methodBase.getResponseHeaders(); + } + + public String getPlainHeadersString() { + List arrayList = new ArrayList<>(headers.length); + + for (Header header : headers) { + arrayList.add(new PlainHeader(header.getName(), header.getValue())); + } + + Gson gson = new Gson(); + return gson.toJson(arrayList); + } + + public InputStream getBody() { + return this.body; + } + + public HttpMethodBase getMethod() { + return method; + } +} diff --git a/app/src/main/java/com/owncloud/android/services/AccountManagerService.java b/app/src/main/java/com/owncloud/android/services/AccountManagerService.java new file mode 100644 index 000000000..05d7b54dc --- /dev/null +++ b/app/src/main/java/com/owncloud/android/services/AccountManagerService.java @@ -0,0 +1,44 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2017 David Luhmer + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.services; + +import android.app.Service; +import android.content.Intent; +import android.os.IBinder; + +import com.nextcloud.android.sso.InputStreamBinder; +import com.nextcloud.client.account.UserAccountManager; + +import javax.inject.Inject; + +import dagger.android.AndroidInjection; + +public class AccountManagerService extends Service { + + private InputStreamBinder mBinder; + @Inject UserAccountManager accountManager; + + @Override + public void onCreate() { + super.onCreate(); + AndroidInjection.inject(this); + } + + @Override + public IBinder onBind(Intent intent) { + if(mBinder == null) { + mBinder = new InputStreamBinder(getApplicationContext(), accountManager); + } + return mBinder; + } + + @Override + public boolean onUnbind(Intent intent) { + return super.onUnbind(intent); + } + +} diff --git a/app/src/main/java/com/owncloud/android/ui/activity/SsoGrantPermissionActivity.java b/app/src/main/java/com/owncloud/android/ui/activity/SsoGrantPermissionActivity.java new file mode 100644 index 000000000..41524cda3 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/activity/SsoGrantPermissionActivity.java @@ -0,0 +1,216 @@ +/* + * Nextcloud Android client application + * + * @author David Luhmer + * @author Andy Scherzinger + * Copyright (C) 2018 David Luhmer + * Copyright (C) 2018 Andy Scherzinger + * + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ + +package com.owncloud.android.ui.activity; + +import android.accounts.Account; +import android.accounts.AccountManager; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.content.pm.ApplicationInfo; +import android.content.pm.PackageManager; +import android.graphics.Typeface; +import android.graphics.drawable.Drawable; +import android.os.Bundle; +import android.text.Spannable; +import android.text.SpannableStringBuilder; +import android.text.style.ForegroundColorSpan; +import android.text.style.StyleSpan; + +import com.google.android.material.dialog.MaterialAlertDialogBuilder; +import com.nextcloud.android.sso.Constants; +import com.nextcloud.utils.extensions.IntentExtensionsKt; +import com.owncloud.android.MainApp; +import com.owncloud.android.R; +import com.owncloud.android.databinding.DialogSsoGrantPermissionBinding; +import com.owncloud.android.lib.common.OwnCloudAccount; +import com.owncloud.android.lib.common.accounts.AccountUtils; +import com.owncloud.android.lib.common.utils.Log_OC; +import com.owncloud.android.utils.EncryptionUtils; +import com.owncloud.android.utils.theme.ViewThemeUtils; + +import java.util.UUID; + +import javax.inject.Inject; + +import androidx.appcompat.app.AlertDialog; + +import static com.nextcloud.android.sso.Constants.DELIMITER; +import static com.nextcloud.android.sso.Constants.EXCEPTION_ACCOUNT_ACCESS_DECLINED; +import static com.nextcloud.android.sso.Constants.EXCEPTION_ACCOUNT_NOT_FOUND; +import static com.nextcloud.android.sso.Constants.NEXTCLOUD_FILES_ACCOUNT; +import static com.nextcloud.android.sso.Constants.NEXTCLOUD_SSO; +import static com.nextcloud.android.sso.Constants.NEXTCLOUD_SSO_EXCEPTION; +import static com.nextcloud.android.sso.Constants.SSO_SHARED_PREFERENCE; + + +/** + * Activity for granting access rights to a Nextcloud account, used for SSO. + */ +public class SsoGrantPermissionActivity extends BaseActivity { + + private static final String TAG = SsoGrantPermissionActivity.class.getCanonicalName(); + + private String packageName; + private Account account; + + @Inject ViewThemeUtils.Factory themeUtilsFactory; + private ViewThemeUtils viewThemeUtils; + + private AlertDialog dialog; + + private DialogSsoGrantPermissionBinding binding; + + public DialogSsoGrantPermissionBinding getBinding() { + return binding; + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + viewThemeUtils = themeUtilsFactory.withDefaultSchemes(); + + binding = DialogSsoGrantPermissionBinding.inflate(getLayoutInflater()); + + ComponentName callingActivity = getCallingActivity(); + + if (callingActivity != null) { + packageName = callingActivity.getPackageName(); + final String appName = getAppNameForPackage(packageName); + account = IntentExtensionsKt.getParcelableArgument(getIntent(), NEXTCLOUD_FILES_ACCOUNT, Account.class); + + if (account != null) { + final SpannableStringBuilder dialogText = makeSpecialPartsBold( + getString(R.string.single_sign_on_request_token, appName, account.name), + appName, + account.name); + binding.permissionText.setText(dialogText); + } + + try { + if (packageName != null) { + Drawable appIcon = getPackageManager().getApplicationIcon(packageName); + binding.appIcon.setImageDrawable(appIcon); + } + } catch (PackageManager.NameNotFoundException e) { + Log_OC.e(TAG, "Error retrieving app icon", e); + } + + MaterialAlertDialogBuilder builder = getMaterialAlertDialogBuilder(); + + builder + .setView(binding.getRoot()) + .setCancelable(false) + .setPositiveButton(R.string.permission_allow, (dialog, which) -> grantPermission()) + .setNegativeButton(R.string.permission_deny, (dialog, which) -> exitFailed()); + + viewThemeUtils.dialog.colorMaterialAlertDialogBackground(this, builder); + + dialog = builder.create(); + dialog.show(); + + Log_OC.v(TAG, "TOKEN-REQUEST: Calling Package: " + packageName); + Log_OC.v(TAG, "TOKEN-REQUEST: App Name: " + appName); + } else { + // Activity was not started using startActivityForResult! + Log_OC.e(TAG, "Calling Package is null"); + setResultAndExit("Request was not executed properly. Use startActivityForResult()"); + } + } + + public MaterialAlertDialogBuilder getMaterialAlertDialogBuilder() { + return new MaterialAlertDialogBuilder(this); + } + + @Override + protected void onStart() { + super.onStart(); + viewThemeUtils.platform.colorTextButtons(dialog.getButton(AlertDialog.BUTTON_POSITIVE), + dialog.getButton(AlertDialog.BUTTON_NEGATIVE)); + } + + private SpannableStringBuilder makeSpecialPartsBold(String text, String... toBeStyledText) { + SpannableStringBuilder ssb = new SpannableStringBuilder(text); + for (String textBlock : toBeStyledText) { + int start = text.indexOf(textBlock); + int end = start + textBlock.length(); + ssb.setSpan(new StyleSpan(Typeface.BOLD), start, end, 0); + ssb.setSpan(new ForegroundColorSpan(getResources().getColor(R.color.text_color)), start, end, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + } + + return ssb; + } + + private void setResultAndExit(String exception) { + Intent data = new Intent(); + data.putExtra(NEXTCLOUD_SSO_EXCEPTION, exception); + setResult(RESULT_CANCELED, data); + finish(); + } + + private String getAppNameForPackage(String pkg) { + final PackageManager pm = getApplicationContext().getPackageManager(); + ApplicationInfo ai = null; + try { + ai = pm.getApplicationInfo(pkg, 0); + } catch (final PackageManager.NameNotFoundException e) { + Log_OC.e(TAG, "Error fetching app name for package", e); + } + return (String) (ai != null ? pm.getApplicationLabel(ai) : "(unknown)"); + } + + private void exitFailed() { + setResultAndExit(EXCEPTION_ACCOUNT_ACCESS_DECLINED); + } + + private void grantPermission() { + // create token + SharedPreferences sharedPreferences = getSharedPreferences(SSO_SHARED_PREFERENCE, Context.MODE_PRIVATE); + String token = UUID.randomUUID().toString().replaceAll("-", ""); + + String hashedTokenWithSalt = EncryptionUtils.generateSHA512(token); + + SharedPreferences.Editor editor = sharedPreferences.edit(); + editor.putString(packageName + DELIMITER + account.name, hashedTokenWithSalt); + editor.apply(); + + String serverUrl; + String userId; + try { + OwnCloudAccount ocAccount = new OwnCloudAccount(account, this); + serverUrl = ocAccount.getBaseUri().toString(); + AccountManager accountManager = AccountManager.get(this); + userId = accountManager.getUserData(account, + com.owncloud.android.lib.common.accounts.AccountUtils.Constants.KEY_USER_ID); + } catch (AccountUtils.AccountNotFoundException e) { + Log_OC.e(TAG, "Account not found"); + setResultAndExit(EXCEPTION_ACCOUNT_NOT_FOUND); + return; + } + + final Bundle result = new Bundle(); + result.putString(AccountManager.KEY_ACCOUNT_NAME, account.name); + result.putString(AccountManager.KEY_ACCOUNT_TYPE, MainApp.getAccountType(this)); + result.putString(AccountManager.KEY_AUTHTOKEN, NEXTCLOUD_SSO); + result.putString(Constants.SSO_USER_ID, userId); + result.putString(Constants.SSO_TOKEN, token); + result.putString(Constants.SSO_SERVER_URL, serverUrl); + + Intent data = new Intent(); + data.putExtra(NEXTCLOUD_SSO, result); + setResult(RESULT_OK, data); + finish(); + } +} diff --git a/app/src/main/java/com/owncloud/utils/EncryptionUtils.java b/app/src/main/java/com/owncloud/utils/EncryptionUtils.java new file mode 100644 index 000000000..de6f21b5e --- /dev/null +++ b/app/src/main/java/com/owncloud/utils/EncryptionUtils.java @@ -0,0 +1,1587 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2017 Tobias Kaminsky + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.utils; + +import android.content.Context; +import android.text.TextUtils; +import android.util.Base64; +import android.util.Pair; + +import com.google.common.collect.Lists; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.reflect.TypeToken; +import com.nextcloud.client.account.User; +import com.owncloud.android.R; +import com.owncloud.android.datamodel.ArbitraryDataProvider; +import com.owncloud.android.datamodel.ArbitraryDataProviderImpl; +import com.owncloud.android.datamodel.EncryptedFiledrop; +import com.owncloud.android.datamodel.FileDataStorageManager; +import com.owncloud.android.datamodel.OCFile; +import com.owncloud.android.datamodel.e2e.v1.decrypted.DecryptedFile; +import com.owncloud.android.datamodel.e2e.v1.decrypted.DecryptedFolderMetadataFileV1; +import com.owncloud.android.datamodel.e2e.v1.decrypted.DecryptedMetadata; +import com.owncloud.android.datamodel.e2e.v1.encrypted.EncryptedFile; +import com.owncloud.android.datamodel.e2e.v1.encrypted.EncryptedFolderMetadataFileV1; +import com.owncloud.android.datamodel.e2e.v2.decrypted.DecryptedFolderMetadataFile; +import com.owncloud.android.datamodel.e2e.v2.decrypted.DecryptedUser; +import com.owncloud.android.datamodel.e2e.v2.encrypted.EncryptedFolderMetadataFile; +import com.owncloud.android.datamodel.e2e.v2.encrypted.EncryptedMetadata; +import com.owncloud.android.lib.common.OwnCloudClient; +import com.owncloud.android.lib.common.operations.RemoteOperationResult; +import com.owncloud.android.lib.common.utils.Log_OC; +import com.owncloud.android.lib.resources.e2ee.GetMetadataRemoteOperation; +import com.owncloud.android.lib.resources.e2ee.LockFileRemoteOperation; +import com.owncloud.android.lib.resources.e2ee.MetadataResponse; +import com.owncloud.android.lib.resources.e2ee.StoreMetadataRemoteOperation; +import com.owncloud.android.lib.resources.e2ee.StoreMetadataV2RemoteOperation; +import com.owncloud.android.lib.resources.e2ee.UnlockFileRemoteOperation; +import com.owncloud.android.lib.resources.e2ee.UnlockFileV1RemoteOperation; +import com.owncloud.android.lib.resources.e2ee.UpdateMetadataRemoteOperation; +import com.owncloud.android.lib.resources.e2ee.UpdateMetadataV2RemoteOperation; +import com.owncloud.android.lib.resources.files.model.ServerFileInterface; +import com.owncloud.android.lib.resources.status.E2EVersion; +import com.owncloud.android.lib.resources.status.NextcloudVersion; +import com.owncloud.android.lib.resources.status.OCCapability; +import com.owncloud.android.lib.resources.status.Problem; +import com.owncloud.android.lib.resources.status.SendClientDiagnosticRemoteOperation; +import com.owncloud.android.operations.UploadException; +import com.owncloud.android.utils.theme.CapabilityUtils; + +import org.apache.commons.httpclient.HttpStatus; + +import java.io.BufferedReader; +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.security.InvalidAlgorithmParameterException; +import java.security.InvalidKeyException; +import java.security.Key; +import java.security.KeyFactory; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.PrivateKey; +import java.security.PublicKey; +import java.security.SecureRandom; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.security.interfaces.RSAPrivateCrtKey; +import java.security.interfaces.RSAPublicKey; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.InvalidParameterSpecException; +import java.security.spec.PKCS8EncodedKeySpec; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import javax.crypto.BadPaddingException; +import javax.crypto.Cipher; +import javax.crypto.CipherOutputStream; +import javax.crypto.IllegalBlockSizeException; +import javax.crypto.KeyGenerator; +import javax.crypto.NoSuchPaddingException; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.IvParameterSpec; +import javax.crypto.spec.SecretKeySpec; + +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; + +/** + * Utils for encryption + */ +public final class EncryptionUtils { + private static final String TAG = EncryptionUtils.class.getSimpleName(); + + public static final String PUBLIC_KEY = "PUBLIC_KEY"; + public static final String PRIVATE_KEY = "PRIVATE_KEY"; + public static final String MNEMONIC = "MNEMONIC"; + public static final int ivLength = 16; + public static final int saltLength = 40; + public static final String ivDelimiter = "|"; // not base64 encoded + public static final String ivDelimiterOld = "fA=="; // "|" base64 encoded + + private static final char HASH_DELIMITER = '$'; + private static final String AES_CIPHER = "AES/GCM/NoPadding"; + private static final String AES = "AES"; + public static final String RSA_CIPHER = "RSA/ECB/OAEPWithSHA-256AndMGF1Padding"; + public static final String RSA = "RSA"; + @VisibleForTesting + public static final String MIGRATED_FOLDER_IDS = "MIGRATED_FOLDER_IDS"; + + private EncryptionUtils() { + // utility class -> private constructor + } + + /* + JSON + */ + + public static T deserializeJSON(String json, TypeToken type, boolean excludeTransient) { + if (excludeTransient) { + return new Gson().fromJson(json, type.getType()); + } else { + return new GsonBuilder().excludeFieldsWithModifiers(0).create().fromJson(json, type.getType()); + } + } + + public static T deserializeJSON(String json, TypeToken type) { + return deserializeJSON(json, type, false); + } + + public static String serializeJSON(Object data, boolean excludeTransient) { + if (excludeTransient) { + return new GsonBuilder() + .disableHtmlEscaping() + .create() + .toJson(data); + } else { + return new GsonBuilder() + .disableHtmlEscaping() + .excludeFieldsWithModifiers(0) + .create() + .toJson(data); + } + } + + public static void removeFileFromMetadata(String fileName, DecryptedFolderMetadataFileV1 metadata) { + metadata.getFiles().remove(fileName); + } + + public static String serializeJSON(Object data) { + return serializeJSON(data, false); + } + + /* + METADATA + */ + + /** + * Encrypt folder metaData V1 + * + * @param decryptedFolderMetadata folder metaData to encrypt + * @return EncryptedFolderMetadataFile encrypted folder metadata + */ + public static EncryptedFolderMetadataFileV1 encryptFolderMetadata( + DecryptedFolderMetadataFileV1 decryptedFolderMetadata, + String publicKey, + long parentId, + User user, + ArbitraryDataProvider arbitraryDataProvider + ) + throws NoSuchAlgorithmException, InvalidKeyException, + InvalidAlgorithmParameterException, NoSuchPaddingException, BadPaddingException, + IllegalBlockSizeException, CertificateException { + + HashMap files = new HashMap<>(); + HashMap filesdrop = new HashMap<>(); + EncryptedFolderMetadataFileV1 encryptedFolderMetadata = new EncryptedFolderMetadataFileV1(decryptedFolderMetadata + .getMetadata(), + files, + filesdrop); + + // set new metadata key + byte[] metadataKeyBytes = EncryptionUtils.generateKey(); + String encryptedMetadataKey = EncryptionUtils.encryptStringAsymmetric( + EncryptionUtils.encodeBytesToBase64String(metadataKeyBytes), + publicKey); + encryptedFolderMetadata.getMetadata().setMetadataKey(encryptedMetadataKey); + + // store that this folder has been migrated + addIdToMigratedIds(parentId, user, arbitraryDataProvider); + + // Encrypt each file in "files" + for (Map.Entry entry : decryptedFolderMetadata + .getFiles().entrySet()) { + String key = entry.getKey(); + DecryptedFile decryptedFile = entry.getValue(); + + EncryptedFolderMetadataFileV1.EncryptedFile encryptedFile = new EncryptedFolderMetadataFileV1.EncryptedFile(); + encryptedFile.setInitializationVector(decryptedFile.getInitializationVector()); + encryptedFile.setAuthenticationTag(decryptedFile.getAuthenticationTag()); + + // encrypt + String dataJson = EncryptionUtils.serializeJSON(decryptedFile.getEncrypted()); + encryptedFile.setEncrypted(EncryptionUtils.encryptStringSymmetricAsString(dataJson, metadataKeyBytes)); + + files.put(key, encryptedFile); + } + + // set checksum + String mnemonic = arbitraryDataProvider.getValue(user.getAccountName(), EncryptionUtils.MNEMONIC).trim(); + String checksum = EncryptionUtils.generateChecksum(decryptedFolderMetadata, mnemonic); + encryptedFolderMetadata.getMetadata().setChecksum(checksum); + + return encryptedFolderMetadata; + } + + /** + * normally done on server only internal test + */ + @VisibleForTesting + public static void encryptFileDropFiles(DecryptedFolderMetadataFileV1 decryptedFolderMetadata, + EncryptedFolderMetadataFileV1 encryptedFolderMetadata, + String cert) throws NoSuchAlgorithmException, NoSuchPaddingException, + InvalidKeyException, BadPaddingException, IllegalBlockSizeException, CertificateException, + InvalidAlgorithmParameterException { + final Map filesdrop = encryptedFolderMetadata.getFiledrop(); + for (Map.Entry entry : decryptedFolderMetadata + .getFiledrop().entrySet()) { + String key = entry.getKey(); + DecryptedFile decryptedFile = entry.getValue(); + + byte[] byt = generateKey(); + String metadataKey0 = encodeBytesToBase64String(byt); + String enc = encryptStringAsymmetric(metadataKey0, cert); + + String dataJson = EncryptionUtils.serializeJSON(decryptedFile.getEncrypted()); + + String encJson = encryptStringSymmetricAsString(dataJson, byt); + + int delimiterPosition = encJson.lastIndexOf(ivDelimiter); + String encryptedInitializationVector = encJson.substring(delimiterPosition + ivDelimiter.length()); + String encodedCryptedBytes = encJson.substring(0, delimiterPosition); + + + byte[] bytes = decodeStringToBase64Bytes(encodedCryptedBytes); + + // check authentication tag + byte[] extractedAuthenticationTag = Arrays.copyOfRange(bytes, + bytes.length - (128 / 8), + bytes.length); + + String encryptedTag = encodeBytesToBase64String(extractedAuthenticationTag); + + EncryptedFiledrop encryptedFile = new EncryptedFiledrop(encodedCryptedBytes, + decryptedFile.getInitializationVector(), + decryptedFile.getAuthenticationTag(), + enc, + encryptedTag, + encryptedInitializationVector); + + filesdrop.put(key, encryptedFile); + } + } + + /* + * decrypt folder metaData V1 with private key + */ + public static DecryptedFolderMetadataFileV1 decryptFolderMetaData(EncryptedFolderMetadataFileV1 encryptedFolderMetadata, + String privateKey, + ArbitraryDataProvider arbitraryDataProvider, + User user, + long remoteId) + throws NoSuchAlgorithmException, InvalidKeyException, + InvalidAlgorithmParameterException, NoSuchPaddingException, BadPaddingException, + IllegalBlockSizeException, InvalidKeySpecException { + + HashMap files = new HashMap<>(); + DecryptedFolderMetadataFileV1 decryptedFolderMetadata = new DecryptedFolderMetadataFileV1( + encryptedFolderMetadata.getMetadata(), files); + + byte[] decryptedMetadataKey = null; + + String encryptedMetadataKey = decryptedFolderMetadata.getMetadata().getMetadataKey(); + + if (encryptedMetadataKey != null) { + decryptedMetadataKey = decodeStringToBase64Bytes( + decryptStringAsymmetric(encryptedMetadataKey, privateKey)); + } + + if (encryptedFolderMetadata.getFiles() != null) { + for (Map.Entry entry : encryptedFolderMetadata + .getFiles().entrySet()) { + String key = entry.getKey(); + EncryptedFolderMetadataFileV1.EncryptedFile encryptedFile = entry.getValue(); + + DecryptedFile decryptedFile = new DecryptedFile(); + decryptedFile.setInitializationVector(encryptedFile.getInitializationVector()); + decryptedFile.setMetadataKey(encryptedFile.getMetadataKey()); + decryptedFile.setAuthenticationTag(encryptedFile.getAuthenticationTag()); + + if (decryptedMetadataKey == null) { + decryptedMetadataKey = EncryptionUtils.decodeStringToBase64Bytes( + decryptStringAsymmetric(decryptedFolderMetadata.getMetadata() + .getMetadataKeys().get(encryptedFile.getMetadataKey()), + privateKey)); + } + + // decrypt + String dataJson = EncryptionUtils.decryptStringSymmetric(encryptedFile.getEncrypted(), decryptedMetadataKey); + decryptedFile.setEncrypted(EncryptionUtils.deserializeJSON(dataJson, + new TypeToken<>() { + })); + + files.put(key, decryptedFile); + } + } + + // verify checksum + String mnemonic = arbitraryDataProvider.getValue(user.getAccountName(), EncryptionUtils.MNEMONIC).trim(); + String checksum = EncryptionUtils.generateChecksum(decryptedFolderMetadata, mnemonic); + String decryptedFolderChecksum = decryptedFolderMetadata.getMetadata().getChecksum(); + + if (TextUtils.isEmpty(decryptedFolderChecksum) && + isFolderMigrated(remoteId, user, arbitraryDataProvider)) { + reportE2eError(arbitraryDataProvider, user); + throw new IllegalStateException("Possible downgrade attack detected!"); + } + + if (!TextUtils.isEmpty(decryptedFolderChecksum) && !decryptedFolderChecksum.equals(checksum)) { + reportE2eError(arbitraryDataProvider, user); + throw new IllegalStateException("Wrong checksum!"); + } + + Map fileDrop = encryptedFolderMetadata.getFiledrop(); + + if (fileDrop != null) { + for (Map.Entry entry : fileDrop.entrySet()) { + String key = entry.getKey(); + EncryptedFiledrop encryptedFile = entry.getValue(); + + // decrypt key + String encryptedKey = decryptStringAsymmetric(encryptedFile.getEncryptedKey(), + privateKey); + + // decrypt encrypted blob with key + String decryptedData = decryptStringSymmetricAsString( + encryptedFile.getEncrypted(), + decodeStringToBase64Bytes(encryptedKey), + decodeStringToBase64Bytes(encryptedFile.getEncryptedInitializationVector()), + decodeStringToBase64Bytes(encryptedFile.getEncryptedTag()), + arbitraryDataProvider, + user + ); + + DecryptedFile decryptedFile = new DecryptedFile(); + decryptedFile.setInitializationVector(encryptedFile.getInitializationVector()); + decryptedFile.setAuthenticationTag(encryptedFile.getAuthenticationTag()); + + + decryptedFile.setEncrypted(EncryptionUtils.deserializeJSON(decryptedData, + new TypeToken<>() { + })); + + files.put(key, decryptedFile); + + // remove from filedrop + fileDrop.remove(key); + } + } + + return decryptedFolderMetadata; + } + + /** + * Download metadata (v1 or v2) for folder and decrypt it + * + * @return decrypted v2 metadata or null + */ + @SuppressFBWarnings("URV") + public static @Nullable + Object + downloadFolderMetadata(OCFile folder, + OwnCloudClient client, + Context context, + User user + ) { + RemoteOperationResult getMetadataOperationResult = new GetMetadataRemoteOperation(folder.getLocalId()) + .execute(client); + + if (!getMetadataOperationResult.isSuccess()) { + return null; + } + + OCCapability capability = CapabilityUtils.getCapability(context); + + // decrypt metadata + EncryptionUtilsV2 encryptionUtilsV2 = new EncryptionUtilsV2(); + String serializedEncryptedMetadata = getMetadataOperationResult.getResultData().getMetadata(); + + E2EVersion version = determinateVersion(serializedEncryptedMetadata); + + switch (version) { + case UNKNOWN: + Log_OC.e(TAG, "Unknown e2e state"); + return null; + + case V1_0, V1_1, V1_2: + ArbitraryDataProvider arbitraryDataProvider = new ArbitraryDataProviderImpl(context); + String privateKey = arbitraryDataProvider.getValue(user.getAccountName(), EncryptionUtils.PRIVATE_KEY); + String publicKey = arbitraryDataProvider.getValue(user.getAccountName(), EncryptionUtils.PUBLIC_KEY); + EncryptedFolderMetadataFileV1 encryptedFolderMetadata = EncryptionUtils.deserializeJSON( + serializedEncryptedMetadata, new TypeToken<>() { + }); + + try { + DecryptedFolderMetadataFileV1 v1 = decryptFolderMetaData(encryptedFolderMetadata, + privateKey, + arbitraryDataProvider, + user, + folder.getLocalId()); + + if (capability.getEndToEndEncryptionApiVersion().compareTo(E2EVersion.V2_0) >= 0) { + new EncryptionUtilsV2().migrateV1ToV2andUpload( + v1, + client.getUserId(), + publicKey, + folder, + new FileDataStorageManager(user, context.getContentResolver()), + client, + user, + context + ); + } else { + return v1; + } + } catch (Exception e) { + // TODO do not crash, but show meaningful error + Log_OC.e(TAG, "Could not decrypt metadata for " + folder.getDecryptedFileName(), e); + return null; + } + + case V2_0: + return encryptionUtilsV2.parseAnyMetadata(getMetadataOperationResult.getResultData(), + user, + client, + context, + folder); + } + return null; + } + + public static E2EVersion determinateVersion(String metadata) { + try { + EncryptedFolderMetadataFileV1 v1 = EncryptionUtils.deserializeJSON( + metadata, + new TypeToken<>() { + }); + + double version = v1.getMetadata().getVersion(); + + if (version == 1.0) { + return E2EVersion.V1_0; + } else if (version == 1.1) { + return E2EVersion.V1_1; + } else if (version == 1.2) { + return E2EVersion.V1_2; + } else { + throw new IllegalStateException("Unknown version"); + } + } catch (Exception e) { + EncryptedFolderMetadataFile v2 = EncryptionUtils.deserializeJSON( + metadata, + new TypeToken<>() { + }); + + if (v2 != null) { + if ("2.0".equals(v2.getVersion()) || "2".equals(v2.getVersion())) { + return E2EVersion.V2_0; + } + } else { + return E2EVersion.UNKNOWN; + } + } + + return E2EVersion.UNKNOWN; + } + + /* + BASE 64 + */ + @SuppressFBWarnings({"DM", "MDM"}) + public static byte[] encodeStringToBase64Bytes(String string) { + try { + return Base64.encode(string.getBytes(), Base64.NO_WRAP); + } catch (Exception e) { + return new byte[0]; + } + } + + @SuppressFBWarnings({"DM", "MDM"}) + public static String decodeBase64BytesToString(byte[] bytes) { + try { + return new String(Base64.decode(bytes, Base64.NO_WRAP)); + } catch (Exception e) { + return ""; + } + } + + public static String encodeBytesToBase64String(byte[] bytes) { + return Base64.encodeToString(bytes, Base64.NO_WRAP); + } + + @SuppressFBWarnings({"DM", "MDM"}) + public static String encodeStringToBase64String(String string) { + return Base64.encodeToString(string.getBytes(), Base64.NO_WRAP); + } + + @SuppressFBWarnings({"DM", "MDM"}) + public static String decodeBase64StringToString(String string) { + return new String(Base64.decode(string, Base64.NO_WRAP)); + } + + public static byte[] decodeStringToBase64Bytes(String string) { + return Base64.decode(string, Base64.NO_WRAP); + } + + public static EncryptedFile encryptFile(String accountName, File file, Cipher cipher) throws InvalidParameterSpecException, IOException { + File tempEncryptedFolder = FileDataStorageManager.createTempEncryptedFolder(accountName); + File tempEncryptedFile = File.createTempFile(file.getName(), null, tempEncryptedFolder); + encryptFileWithGivenCipher(file, tempEncryptedFile, cipher); + String authenticationTagString = getAuthenticationTag(cipher); + return new EncryptedFile(tempEncryptedFile, authenticationTagString); + } + + public static String getAuthenticationTag(Cipher cipher) throws InvalidParameterSpecException { + byte[] authenticationTag = cipher.getParameters().getParameterSpec(GCMParameterSpec.class).getIV(); + return encodeBytesToBase64String(authenticationTag); + } + + public static Cipher getCipher(int mode, byte[] encryptionKeyBytes, byte[] iv) throws NoSuchPaddingException, NoSuchAlgorithmException, InvalidAlgorithmParameterException, InvalidKeyException { + Cipher cipher = Cipher.getInstance(AES_CIPHER); + Key key = new SecretKeySpec(encryptionKeyBytes, AES); + GCMParameterSpec spec = new GCMParameterSpec(128, iv); + cipher.init(mode, key, spec); + return cipher; + } + + public static void encryptFileWithGivenCipher(File inputFile, File encryptedFile, Cipher cipher) { + try (FileInputStream inputStream = new FileInputStream(inputFile); + FileOutputStream fileOutputStream = new FileOutputStream(encryptedFile); + CipherOutputStream outputStream = new CipherOutputStream(fileOutputStream, cipher)) { + byte[] buffer = new byte[4096]; + int bytesRead; + + while ((bytesRead = inputStream.read(buffer)) != -1) { + outputStream.write(buffer, 0, bytesRead); + } + + outputStream.close(); + inputStream.close(); + + Log_OC.d(TAG, encryptedFile.getName() + "encrypted successfully"); + } catch (IOException exception) { + Log_OC.d(TAG, "Error caught at encryptFileWithGivenCipher(): " + exception.getLocalizedMessage()); + } + } + + public static void decryptFile(Cipher cipher, + File encryptedFile, + File decryptedFile, + String authenticationTag, + ArbitraryDataProvider arbitraryDataProvider, + User user) { + try (FileInputStream inputStream = new FileInputStream(encryptedFile); + FileOutputStream outputStream = new FileOutputStream(decryptedFile)) { + + byte[] buffer = new byte[4096]; + int bytesRead; + while ((bytesRead = inputStream.read(buffer)) != -1) { + byte[] output = cipher.update(buffer, 0, bytesRead); + if (output != null) { + outputStream.write(output); + } + } + byte[] output = cipher.doFinal(); + if (output != null) { + outputStream.write(output); + } + inputStream.close(); + outputStream.close(); + + if (!getAuthenticationTag(cipher).equals(authenticationTag)) { + reportE2eError(arbitraryDataProvider, user); + throw new SecurityException("Tag not correct"); + } + + Log_OC.d(TAG, encryptedFile.getName() + "decrypted successfully"); + } catch (IOException | BadPaddingException | IllegalBlockSizeException | InvalidParameterSpecException | + SecurityException exception) { + Log_OC.d(TAG, "Error caught at decryptFile(): " + exception.getLocalizedMessage()); + } + } + + /** + * Encrypt string with RSA algorithm, ECB mode, OAEPWithSHA-256AndMGF1 padding Asymmetric encryption, with private + * and public key + * + * @param string String to encrypt + * @param cert contains public key in it + * @return encrypted string + */ + public static String encryptStringAsymmetric(String string, String cert) + throws NoSuchAlgorithmException, + NoSuchPaddingException, InvalidKeyException, + BadPaddingException, IllegalBlockSizeException, + CertificateException { + + Cipher cipher = Cipher.getInstance(RSA_CIPHER); + + String trimmedCert = cert.replace("-----BEGIN CERTIFICATE-----\n", "") + .replace("-----END CERTIFICATE-----\n", ""); + byte[] encodedCert = trimmedCert.getBytes(StandardCharsets.UTF_8); + byte[] decodedCert = org.apache.commons.codec.binary.Base64.decodeBase64(encodedCert); + + CertificateFactory certFactory = CertificateFactory.getInstance("X.509"); + InputStream in = new ByteArrayInputStream(decodedCert); + X509Certificate certificate = (X509Certificate) certFactory.generateCertificate(in); + PublicKey realPublicKey = certificate.getPublicKey(); + + cipher.init(Cipher.ENCRYPT_MODE, realPublicKey); + + byte[] bytes = encodeStringToBase64Bytes(string); + byte[] cryptedBytes = cipher.doFinal(bytes); + + return encodeBytesToBase64String(cryptedBytes); + } + + public static String encryptStringAsymmetricV2(byte[] bytes, String cert) + throws NoSuchAlgorithmException, + NoSuchPaddingException, InvalidKeyException, + BadPaddingException, IllegalBlockSizeException, + CertificateException { + + Cipher cipher = Cipher.getInstance(RSA_CIPHER); + + String trimmedCert = cert.replace("-----BEGIN CERTIFICATE-----\n", "") + .replace("-----END CERTIFICATE-----\n", ""); + byte[] encodedCert = trimmedCert.getBytes(StandardCharsets.UTF_8); + byte[] decodedCert = org.apache.commons.codec.binary.Base64.decodeBase64(encodedCert); + + CertificateFactory certFactory = CertificateFactory.getInstance("X.509"); + InputStream in = new ByteArrayInputStream(decodedCert); + X509Certificate certificate = (X509Certificate) certFactory.generateCertificate(in); + PublicKey realPublicKey = certificate.getPublicKey(); + + cipher.init(Cipher.ENCRYPT_MODE, realPublicKey); + + byte[] cryptedBytes = cipher.doFinal(bytes); + + return encodeBytesToBase64String(cryptedBytes); + } + + public static String encryptStringAsymmetric(String string, PublicKey publicKey) throws NoSuchPaddingException, + NoSuchAlgorithmException, InvalidKeyException, BadPaddingException, IllegalBlockSizeException { + Cipher cipher = Cipher.getInstance(RSA_CIPHER); + cipher.init(Cipher.ENCRYPT_MODE, publicKey); + + byte[] bytes = encodeStringToBase64Bytes(string); + byte[] cryptedBytes = cipher.doFinal(bytes); + + return encodeBytesToBase64String(cryptedBytes); + } + + + /** + * Decrypt string with RSA algorithm, ECB mode, OAEPWithSHA-256AndMGF1 padding Asymmetric encryption, with private + * and public key + * + * @param string string to decrypt + * @param privateKeyString private key + * @return decrypted string + */ + public static String decryptStringAsymmetric(String string, String privateKeyString) + throws NoSuchAlgorithmException, + NoSuchPaddingException, InvalidKeyException, + BadPaddingException, IllegalBlockSizeException, + InvalidKeySpecException { + + Cipher cipher = Cipher.getInstance(RSA_CIPHER); + + byte[] privateKeyBytes = decodeStringToBase64Bytes(privateKeyString); + PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(privateKeyBytes); + KeyFactory kf = KeyFactory.getInstance(RSA); + PrivateKey privateKey = kf.generatePrivate(keySpec); + + cipher.init(Cipher.DECRYPT_MODE, privateKey); + + byte[] bytes = decodeStringToBase64Bytes(string); + byte[] encodedBytes = cipher.doFinal(bytes); + + return decodeBase64BytesToString(encodedBytes); + } + + public static byte[] decryptStringAsymmetricAsBytes(String string, String privateKeyString) + throws NoSuchAlgorithmException, + NoSuchPaddingException, InvalidKeyException, + BadPaddingException, IllegalBlockSizeException, + InvalidKeySpecException { + + Cipher cipher = Cipher.getInstance(RSA_CIPHER); + + byte[] privateKeyBytes = decodeStringToBase64Bytes(privateKeyString); + PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(privateKeyBytes); + KeyFactory kf = KeyFactory.getInstance(RSA); + PrivateKey privateKey = kf.generatePrivate(keySpec); + + cipher.init(Cipher.DECRYPT_MODE, privateKey); + + byte[] bytes = decodeStringToBase64Bytes(string); + + return cipher.doFinal(bytes); + } + + public static byte[] decryptStringAsymmetricV2(String string, String privateKeyString) + throws NoSuchAlgorithmException, + NoSuchPaddingException, InvalidKeyException, + BadPaddingException, IllegalBlockSizeException, + InvalidKeySpecException { + + Cipher cipher = Cipher.getInstance(RSA_CIPHER); + + byte[] privateKeyBytes = decodeStringToBase64Bytes(privateKeyString); + PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(privateKeyBytes); + KeyFactory kf = KeyFactory.getInstance(RSA); + PrivateKey privateKey = kf.generatePrivate(keySpec); + + cipher.init(Cipher.DECRYPT_MODE, privateKey); + + byte[] bytes; + try { + bytes = decodeStringToBase64Bytes(string); + } catch (Exception e) { + bytes = encodeStringToBase64Bytes(string); + } + + return cipher.doFinal(bytes); + } + + /** + * Decrypt string with RSA algorithm, ECB mode, OAEPWithSHA-256AndMGF1 padding Asymmetric encryption, with private + * and public key + * + * @param string string to decrypt + * @param privateKey private key + * @return decrypted string + */ + public static String decryptStringAsymmetric(String string, PrivateKey privateKey) throws NoSuchPaddingException, NoSuchAlgorithmException, InvalidKeyException, BadPaddingException, IllegalBlockSizeException { + Cipher cipher = Cipher.getInstance(RSA_CIPHER); + cipher.init(Cipher.DECRYPT_MODE, privateKey); + + byte[] bytes = decodeStringToBase64Bytes(string); + byte[] encodedBytes = cipher.doFinal(bytes); + + return decodeBase64BytesToString(encodedBytes); + } + + /** + * Decrypt string with AES/GCM/NoPadding + * + * @param string string to decrypt + * @param encryptionKeyBytes key from metadata + * @return decrypted string + */ + public static String encryptStringSymmetricAsString(String string, byte[] encryptionKeyBytes) + throws NoSuchPaddingException, + InvalidKeyException, + NoSuchAlgorithmException, + IllegalBlockSizeException, + BadPaddingException, + InvalidAlgorithmParameterException { + EncryptedMetadata metadata = encryptStringSymmetric(string, encryptionKeyBytes, ivDelimiter); + + return metadata.getCiphertext(); + } + + @VisibleForTesting + public static String encryptStringSymmetricAsStringOld(String string, byte[] encryptionKeyBytes) + throws NoSuchPaddingException, + InvalidKeyException, + NoSuchAlgorithmException, + IllegalBlockSizeException, + BadPaddingException, + InvalidAlgorithmParameterException { + EncryptedMetadata metadata = encryptStringSymmetric(string, encryptionKeyBytes, ivDelimiterOld); + + return metadata.getCiphertext(); + } + + // /** +// * Encrypt string with AES/GCM/NoPadding +// * +// * @param string string to encrypt +// * @param encryptionKeyBytes key from metadata +// * @return decrypted string +// */ +// private static String encryptStringSymmetric(String string, +// byte[] encryptionKeyBytes, +// String delimiter) +// throws NoSuchAlgorithmException, +// InvalidAlgorithmParameterException, +// NoSuchPaddingException, +// InvalidKeyException, +// BadPaddingException, +// IllegalBlockSizeException { +// +// Cipher cipher = Cipher.getInstance(AES_CIPHER); +// byte[] iv = randomBytes(ivLength); +// +// Key key = new SecretKeySpec(encryptionKeyBytes, AES); +// GCMParameterSpec spec = new GCMParameterSpec(128, iv); +// cipher.init(Cipher.ENCRYPT_MODE, key, spec); +// +// byte[] bytes = encodeStringToBase64Bytes(string); +// byte[] cryptedBytes = cipher.doFinal(bytes); +// +// String encodedCryptedBytes = encodeBytesToBase64String(cryptedBytes); +// String encodedIV = encodeBytesToBase64String(iv); +// +// return encodedCryptedBytes + delimiter + encodedIV; +// } + public static String decryptStringSymmetricAsString(String string, + byte[] encryptionKeyBytes, + byte[] iv, + byte[] authenticationTag, + ArbitraryDataProvider arbitraryDataProvider, + User user + ) throws InvalidAlgorithmParameterException, NoSuchPaddingException, IllegalBlockSizeException, NoSuchAlgorithmException, BadPaddingException, InvalidKeyException { + return decryptStringSymmetricAsString( + decodeStringToBase64Bytes(string), + encryptionKeyBytes, + iv, + authenticationTag, + false, + arbitraryDataProvider, + user); + } + + public static String decryptStringSymmetricAsString(String string, + byte[] encryptionKeyBytes, + byte[] iv, + byte[] authenticationTag, + boolean fileDropV2, + ArbitraryDataProvider arbitraryDataProvider, + User user) throws InvalidAlgorithmParameterException, NoSuchPaddingException, IllegalBlockSizeException, NoSuchAlgorithmException, BadPaddingException, InvalidKeyException { + + return decryptStringSymmetricAsString( + decodeStringToBase64Bytes(string), + encryptionKeyBytes, + iv, + authenticationTag, + fileDropV2, + arbitraryDataProvider, + user); + } + + public static String decryptStringSymmetricAsString(byte[] bytes, + byte[] encryptionKeyBytes, + byte[] iv, + byte[] authenticationTag, + boolean fileDropV2, + ArbitraryDataProvider arbitraryDataProvider, + User user) + throws NoSuchPaddingException, + NoSuchAlgorithmException, + InvalidAlgorithmParameterException, + InvalidKeyException, + IllegalBlockSizeException, + BadPaddingException { + Cipher cipher = Cipher.getInstance(AES_CIPHER); + Key key = new SecretKeySpec(encryptionKeyBytes, AES); + GCMParameterSpec spec = new GCMParameterSpec(128, iv); + cipher.init(Cipher.DECRYPT_MODE, key, spec); + + + // check authentication tag + byte[] extractedAuthenticationTag = Arrays.copyOfRange(bytes, + bytes.length - (128 / 8), + bytes.length); + + if (!Arrays.equals(extractedAuthenticationTag, authenticationTag)) { + reportE2eError(arbitraryDataProvider, user); + throw new SecurityException("Tag not correct"); + } + + byte[] encodedBytes = cipher.doFinal(bytes); + + if (fileDropV2) { + return new EncryptionUtilsV2().gZipDecompress(encodedBytes); + } else { + return decodeBase64BytesToString(encodedBytes); + } + } + + public static EncryptedMetadata encryptStringSymmetric( + String string, + byte[] encryptionKeyBytes, + String delimiter) throws InvalidAlgorithmParameterException, NoSuchPaddingException, IllegalBlockSizeException, NoSuchAlgorithmException, BadPaddingException, InvalidKeyException { + + byte[] bytes = encodeStringToBase64Bytes(string); + + return encryptStringSymmetric(bytes, encryptionKeyBytes, delimiter); + } + + /** + * Encrypt string with AES/GCM/NoPadding + * + * @param bytes byte array + * @param encryptionKeyBytes key from metadata + * @return decrypted string + */ + public static EncryptedMetadata encryptStringSymmetric( + byte[] bytes, + byte[] encryptionKeyBytes, + String delimiter) + throws NoSuchAlgorithmException, + InvalidAlgorithmParameterException, + NoSuchPaddingException, + InvalidKeyException, + BadPaddingException, + IllegalBlockSizeException { + + Cipher cipher = Cipher.getInstance(AES_CIPHER); + byte[] iv = randomBytes(ivLength); + + Key key = new SecretKeySpec(encryptionKeyBytes, AES); + GCMParameterSpec spec = new GCMParameterSpec(128, iv); + cipher.init(Cipher.ENCRYPT_MODE, key, spec); + + byte[] cryptedBytes = cipher.doFinal(bytes); + + String encodedCryptedBytes = encodeBytesToBase64String(cryptedBytes); + String encodedIV = encodeBytesToBase64String(iv); + String authenticationTag = encodeBytesToBase64String(Arrays.copyOfRange(cryptedBytes, + cryptedBytes.length - (128 / 8), + cryptedBytes.length)); + + return new EncryptedMetadata(encodedCryptedBytes + delimiter + encodedIV, encodedIV, authenticationTag); + } + + /** + * Decrypt string with RSA algorithm, ECB mode, OAEPWithSHA-256AndMGF1 padding Asymmetric encryption, with private + * and public key + * + * @param string string to decrypt + * @param encryptionKeyBytes key from metadata + * @return decrypted string + */ + public static String decryptStringSymmetric(String string, byte[] encryptionKeyBytes) + throws NoSuchAlgorithmException, + InvalidAlgorithmParameterException, NoSuchPaddingException, InvalidKeyException, + BadPaddingException, IllegalBlockSizeException { + + Cipher cipher = Cipher.getInstance(AES_CIPHER); + + String ivString; + int delimiterPosition = string.lastIndexOf(ivDelimiter); + + if (delimiterPosition == -1) { + // backward compatibility + delimiterPosition = string.lastIndexOf(ivDelimiterOld); + ivString = string.substring(delimiterPosition + ivDelimiterOld.length()); + } else { + ivString = string.substring(delimiterPosition + ivDelimiter.length()); + } + + String cipherString = string.substring(0, delimiterPosition); + + byte[] iv = new IvParameterSpec(decodeStringToBase64Bytes(ivString)).getIV(); + + Key key = new SecretKeySpec(encryptionKeyBytes, AES); + + GCMParameterSpec spec = new GCMParameterSpec(128, iv); + cipher.init(Cipher.DECRYPT_MODE, key, spec); + + byte[] bytes = decodeStringToBase64Bytes(cipherString); + byte[] encodedBytes = cipher.doFinal(bytes); + + return decodeBase64BytesToString(encodedBytes); + } + + /** + * Decrypt string with AES/GCM/NoPadding + * + * @param string string to decrypt + * @param encryptionKeyBytes key from metadata + * @param authenticationTag auth tag to check + * @return decrypted string + */ + public static byte[] decryptStringSymmetric(String string, + byte[] encryptionKeyBytes, + String authenticationTag, + String ivString) + throws NoSuchAlgorithmException, + InvalidAlgorithmParameterException, NoSuchPaddingException, InvalidKeyException, + BadPaddingException, IllegalBlockSizeException { + + Cipher cipher = Cipher.getInstance(AES_CIPHER); + + int delimiterPosition = string.lastIndexOf(ivDelimiter); + + String cipherString; + if (delimiterPosition == -1) { + cipherString = string; + } else { + cipherString = string.substring(0, delimiterPosition); + } + + byte[] iv = new IvParameterSpec(decodeStringToBase64Bytes(ivString)).getIV(); + + Key key = new SecretKeySpec(encryptionKeyBytes, AES); + + GCMParameterSpec spec = new GCMParameterSpec(128, iv); + cipher.init(Cipher.DECRYPT_MODE, key, spec); + + byte[] bytes = decodeStringToBase64Bytes(cipherString); + + // check authentication tag + if (authenticationTag != null) { + byte[] authenticationTagBytes = decodeStringToBase64Bytes(authenticationTag); + byte[] extractedAuthenticationTag = Arrays.copyOfRange(bytes, + bytes.length - (128 / 8), + bytes.length); + + if (!Arrays.equals(extractedAuthenticationTag, authenticationTagBytes)) { + throw new SecurityException("Tag not correct"); + } + } + + return cipher.doFinal(bytes); + } + + public static String privateKeyToPEM(PrivateKey privateKey) { + String privateKeyString = encodeBytesToBase64String(privateKey.getEncoded()); + + return "-----BEGIN PRIVATE KEY-----\n" + privateKeyString.replaceAll("(.{65})", "$1\n") + + "\n-----END PRIVATE KEY-----"; + } + + public static PrivateKey PEMtoPrivateKey(String pem) throws NoSuchAlgorithmException, InvalidKeySpecException { + byte[] privateKeyBytes = EncryptionUtils.decodeStringToBase64Bytes(pem); + PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(privateKeyBytes); + KeyFactory kf = KeyFactory.getInstance(EncryptionUtils.RSA); + return kf.generatePrivate(keySpec); + } + + /* + Helper + */ + + public static ArrayList getRandomWords(int count, Context context) throws IOException { + InputStream ins = context.getResources().openRawResource(R.raw.encryption_key_words); + + InputStreamReader inputStreamReader = new InputStreamReader(ins); + + BufferedReader bufferedReader = new BufferedReader(inputStreamReader); + + List lines = new ArrayList<>(); + String line; + while ((line = bufferedReader.readLine()) != null) { + lines.add(line); + } + + SecureRandom random = new SecureRandom(); + + ArrayList outputLines = Lists.newArrayListWithCapacity(count); + for (int i = 0; i < count; i++) { + int randomLine = random.nextInt(lines.size()); + outputLines.add(lines.get(randomLine)); + } + + return outputLines; + } + + /** + * Generates private/public key pair, used for asymmetric encryption + * + * @return KeyPair + */ + public static KeyPair generateKeyPair() throws NoSuchAlgorithmException { + KeyPairGenerator keyGen = KeyPairGenerator.getInstance(RSA); + keyGen.initialize(2048, new SecureRandom()); + return keyGen.generateKeyPair(); + } + + /** + * Generates key for symmetric encryption + * + * @return byte[] byteArray of key + */ + public static byte[] generateKey() { + KeyGenerator keyGenerator; + try { + keyGenerator = KeyGenerator.getInstance(AES); + keyGenerator.init(128); + + return keyGenerator.generateKey().getEncoded(); + } catch (NoSuchAlgorithmException e) { + Log_OC.e(TAG, e.getMessage()); + } + + return null; + } + + /** + * Generates key for symmetric encryption + * + * @return String String base64 encoded key + */ + public static String generateKeyString() { + return EncryptionUtils.encodeBytesToBase64String(generateKey()); + } + + public static byte[] randomBytes(int size) { + SecureRandom random = new SecureRandom(); + final byte[] iv = new byte[size]; + random.nextBytes(iv); + + return iv; + } + + /** + * Generate a SHA512 with appended salt + * + * @param token token to be hashed + * @return SHA512 with appended salt, delimiter HASH_DELIMITER + */ + public static String generateSHA512(String token) { + String salt = EncryptionUtils.encodeBytesToBase64String(EncryptionUtils.randomBytes(EncryptionUtils.saltLength)); + + return generateSHA512(token, salt); + } + + /** + * Generate a SHA512 with appended salt + * + * @param token token to be hashed + * @return SHA512 with appended salt, delimiter HASH_DELIMITER + */ + public static String generateSHA512(String token, String salt) { + MessageDigest digest; + String hashedToken = ""; + byte[] hash; + try { + digest = MessageDigest.getInstance("SHA-512"); + digest.update(salt.getBytes()); + hash = digest.digest(token.getBytes()); + + StringBuilder stringBuilder = new StringBuilder(); + for (byte hashByte : hash) { + stringBuilder.append(Integer.toString((hashByte & 0xff) + 0x100, 16).substring(1)); + } + + stringBuilder.append(HASH_DELIMITER).append(salt); + + hashedToken = stringBuilder.toString(); + + } catch (NoSuchAlgorithmException e) { + Log_OC.e(TAG, "Generating SHA512 failed", e); + } + + return hashedToken; + } + + public static boolean verifySHA512(String hashWithSalt, String compareToken) { + String salt = hashWithSalt.split("\\" + HASH_DELIMITER)[1]; + + String newHash = generateSHA512(compareToken, salt); + + return hashWithSalt.equals(newHash); + } + + public static String lockFolder(ServerFileInterface parentFile, OwnCloudClient client) throws UploadException { + return lockFolder(parentFile, client, -1); + } + + public static String lockFolder(ServerFileInterface parentFile, OwnCloudClient client, long counter) throws UploadException { + // Lock folder + LockFileRemoteOperation lockFileOperation = new LockFileRemoteOperation(parentFile.getLocalId(), + counter); + RemoteOperationResult lockFileOperationResult = lockFileOperation.execute(client); + + if (lockFileOperationResult.isSuccess() && + !TextUtils.isEmpty(lockFileOperationResult.getResultData())) { + return lockFileOperationResult.getResultData(); + } else if (lockFileOperationResult.getHttpCode() == HttpStatus.SC_FORBIDDEN) { + throw new UploadException("Forbidden! Please try again later.)"); + } else { + throw new UploadException("Could not lock folder"); + } + } + + /** + * @param parentFile file metadata should be retrieved for + * @return Pair: boolean: true: metadata already exists, false: metadata new created + */ + public static Pair retrieveMetadataV1(OCFile parentFile, + OwnCloudClient client, + String privateKey, + String publicKey, + ArbitraryDataProvider arbitraryDataProvider, + User user) + throws UploadException, + InvalidAlgorithmParameterException, NoSuchAlgorithmException, NoSuchPaddingException, BadPaddingException, + IllegalBlockSizeException, InvalidKeyException, InvalidKeySpecException, CertificateException { + long localId = parentFile.getLocalId(); + + GetMetadataRemoteOperation getMetadataOperation = new GetMetadataRemoteOperation(localId); + RemoteOperationResult getMetadataOperationResult = getMetadataOperation.execute(client); + + DecryptedFolderMetadataFileV1 metadata; + + if (getMetadataOperationResult.isSuccess()) { + // decrypt metadata + String serializedEncryptedMetadata = getMetadataOperationResult.getResultData().getMetadata(); + + + EncryptedFolderMetadataFileV1 encryptedFolderMetadata = EncryptionUtils.deserializeJSON( + serializedEncryptedMetadata, new TypeToken<>() { + }); + + return new Pair<>(Boolean.TRUE, decryptFolderMetaData(encryptedFolderMetadata, + privateKey, + arbitraryDataProvider, + user, + localId)); + + } else if (getMetadataOperationResult.getHttpCode() == HttpStatus.SC_NOT_FOUND) { + // TODO extract + // new metadata + metadata = new DecryptedFolderMetadataFileV1(); + metadata.setMetadata(new DecryptedMetadata()); + metadata.getMetadata().setVersion(Double.parseDouble(E2EVersion.V1_2.getValue())); + metadata.getMetadata().setMetadataKeys(new HashMap<>()); + String metadataKey = EncryptionUtils.encodeBytesToBase64String(EncryptionUtils.generateKey()); + String encryptedMetadataKey = EncryptionUtils.encryptStringAsymmetric(metadataKey, publicKey); + metadata.getMetadata().setMetadataKey(encryptedMetadataKey); + + return new Pair<>(Boolean.FALSE, metadata); + } else { + // TODO E2E: error + throw new UploadException("something wrong"); + } + } + + /** + * @param parentFile file metadata should be retrieved for + * @return Pair: boolean: true: metadata already exists, false: metadata new created + */ + public static Pair retrieveMetadata(OCFile parentFile, + OwnCloudClient client, + String privateKey, + String publicKey, + FileDataStorageManager storageManager, + User user, + Context context, + ArbitraryDataProvider arbitraryDataProvider) + throws Throwable { + long localId = parentFile.getLocalId(); + + GetMetadataRemoteOperation getMetadataOperation = new GetMetadataRemoteOperation(localId); + RemoteOperationResult getMetadataOperationResult = getMetadataOperation.execute(client); + + + DecryptedFolderMetadataFile metadata; + + if (getMetadataOperationResult.isSuccess()) { + // decrypt metadata + String serializedEncryptedMetadata = getMetadataOperationResult.getResultData().getMetadata(); + + + EncryptedFolderMetadataFile encryptedFolderMetadata = EncryptionUtils.deserializeJSON( + serializedEncryptedMetadata, new TypeToken<>() { + }); + + return new Pair<>(Boolean.TRUE, + new EncryptionUtilsV2().decryptFolderMetadataFile(encryptedFolderMetadata, + client.getUserId(), + privateKey, + parentFile, + storageManager, + client, + parentFile.getE2eCounter(), + getMetadataOperationResult.getResultData().getSignature(), + user, + context, + arbitraryDataProvider) + ); + + } else if (getMetadataOperationResult.getHttpCode() == HttpStatus.SC_NOT_FOUND || + getMetadataOperationResult.getHttpCode() == HttpStatus.SC_INTERNAL_SERVER_ERROR) { + // new metadata + metadata = new DecryptedFolderMetadataFile(new com.owncloud.android.datamodel.e2e.v2.decrypted.DecryptedMetadata(), + new ArrayList<>(), + new HashMap<>(), + E2EVersion.V2_0.getValue()); + metadata.getUsers().add(new DecryptedUser(client.getUserId(), publicKey, null)); + byte[] metadataKey = EncryptionUtils.generateKey(); + + if (metadataKey == null) { + throw new UploadException("Could not encrypt folder!"); + } + + metadata.getMetadata().setMetadataKey(metadataKey); + metadata.getMetadata().getKeyChecksums().add(new EncryptionUtilsV2().hashMetadataKey(metadataKey)); + + return new Pair<>(Boolean.FALSE, metadata); + } else { + reportE2eError(arbitraryDataProvider, user); + throw new UploadException("something wrong"); + } + } + + public static void uploadMetadata(ServerFileInterface parentFile, + String serializedFolderMetadata, + String token, + OwnCloudClient client, + boolean metadataExists, + E2EVersion version, + String signature, + ArbitraryDataProvider arbitraryDataProvider, + User user) throws UploadException { + RemoteOperationResult uploadMetadataOperationResult; + if (metadataExists) { + // update metadata + if (version == E2EVersion.V2_0) { + uploadMetadataOperationResult = new UpdateMetadataV2RemoteOperation( + parentFile.getRemoteId(), + serializedFolderMetadata, + token, + signature) + .execute(client); + } else { + uploadMetadataOperationResult = new UpdateMetadataRemoteOperation( + parentFile.getLocalId(), + serializedFolderMetadata, + token) + .execute(client); + } + } else { + // store metadata + if (version == E2EVersion.V2_0) { + uploadMetadataOperationResult = new StoreMetadataV2RemoteOperation( + parentFile.getRemoteId(), + serializedFolderMetadata, + token, + signature + ) + .execute(client); + } else { + uploadMetadataOperationResult = new StoreMetadataRemoteOperation( + parentFile.getLocalId(), + serializedFolderMetadata + ) + .execute(client); + } + } + + if (!uploadMetadataOperationResult.isSuccess()) { + reportE2eError(arbitraryDataProvider, user); + throw new UploadException("Storing/updating metadata was not successful"); + } + } + + public static RemoteOperationResult unlockFolder(ServerFileInterface parentFolder, OwnCloudClient client, String token) { + if (token != null) { + return new UnlockFileRemoteOperation(parentFolder.getLocalId(), token).execute(client); + } else { + return new RemoteOperationResult<>(new Exception("No token available")); + } + } + + public static RemoteOperationResult unlockFolderV1(ServerFileInterface parentFolder, OwnCloudClient client, String token) { + if (token != null) { + return new UnlockFileV1RemoteOperation(parentFolder.getLocalId(), token).execute(client); + } else { + return new RemoteOperationResult<>(new Exception("No token available")); + } + } + + public static X509Certificate convertCertFromString(String string) throws CertificateException { + String trimmedCert = string.replace("-----BEGIN CERTIFICATE-----\n", "") + .replace("-----END CERTIFICATE-----\n", ""); + byte[] encodedCert = trimmedCert.getBytes(StandardCharsets.UTF_8); + byte[] decodedCert = org.apache.commons.codec.binary.Base64.decodeBase64(encodedCert); + + CertificateFactory certFactory = CertificateFactory.getInstance("X.509"); + InputStream in = new ByteArrayInputStream(decodedCert); + return (X509Certificate) certFactory.generateCertificate(in); + } + + public static RSAPublicKey convertPublicKeyFromString(String string) throws CertificateException { + return (RSAPublicKey) convertCertFromString(string).getPublicKey(); + } + + public static void removeE2E(ArbitraryDataProvider arbitraryDataProvider, User user) { + // delete stored E2E keys and mnemonic + arbitraryDataProvider.deleteKeyForAccount(user.getAccountName(), EncryptionUtils.PRIVATE_KEY); + arbitraryDataProvider.deleteKeyForAccount(user.getAccountName(), EncryptionUtils.PUBLIC_KEY); + arbitraryDataProvider.deleteKeyForAccount(user.getAccountName(), EncryptionUtils.MNEMONIC); + } + + public static boolean isMatchingKeys(KeyPair keyPair, String publicKeyString) throws CertificateException { + // check key + RSAPrivateCrtKey privateKey = (RSAPrivateCrtKey) keyPair.getPrivate(); + RSAPublicKey publicKey = EncryptionUtils.convertPublicKeyFromString(publicKeyString); + + BigInteger modulusPublic = publicKey.getModulus(); + BigInteger modulusPrivate = privateKey.getModulus(); + + return modulusPrivate.compareTo(modulusPublic) == 0; + } + + public static boolean supportsSecureFiledrop(OCFile file, User user) { + return file.isEncrypted() && + file.isFolder() && + user.getServer().getVersion().isNewerOrEqual(NextcloudVersion.nextcloud_26); + } + + public static String generateChecksum(DecryptedFolderMetadataFileV1 metadataFile, + String mnemonic) throws NoSuchAlgorithmException { + StringBuilder stringBuilder = new StringBuilder(); + stringBuilder.append(mnemonic.replaceAll(" ", "")); + + ArrayList keys = new ArrayList<>(metadataFile.getFiles().keySet()); + Collections.sort(keys); + + for (String key : keys) { + stringBuilder.append(key); + } + + stringBuilder.append(metadataFile.getMetadata().getMetadataKey()); + + // sha256 hash-sum + return sha256(stringBuilder.toString()); + } + + /** + * SHA-256 hash of metadata-key + */ + public static String sha256(String string) throws NoSuchAlgorithmException { + byte[] bytes = MessageDigest + .getInstance("SHA-256") + .digest(string.getBytes(StandardCharsets.UTF_8)); + + return bytesToHex(bytes); + } + + public static String bytesToHex(byte[] bytes) { + StringBuilder result = new StringBuilder(); + for (byte individualByte : bytes) { + result.append(Integer.toString((individualByte & 0xff) + 0x100, 16) + .substring(1)); + } + return result.toString(); + } + + public static void addIdToMigratedIds(long id, + User user, + ArbitraryDataProvider arbitraryDataProvider) { + Gson gson = new Gson(); + String ids = arbitraryDataProvider.getValue(user, MIGRATED_FOLDER_IDS); + + ArrayList arrayList = gson.fromJson(ids, ArrayList.class); + + if (arrayList == null) { + arrayList = new ArrayList<>(); + } + + if (arrayList.contains(id)) { + // nothing to do here + return; + } + + arrayList.add(id); + + String json = gson.toJson(arrayList); + arbitraryDataProvider.storeOrUpdateKeyValue(user.getAccountName(), + MIGRATED_FOLDER_IDS, + json); + } + + public static boolean isFolderMigrated(long id, + User user, + ArbitraryDataProvider arbitraryDataProvider) { + Gson gson = new Gson(); + String ids = arbitraryDataProvider.getValue(user, MIGRATED_FOLDER_IDS); + + ArrayList arrayList = gson.fromJson(ids, new TypeToken>() { + }.getType()); + + if (arrayList == null) { + return false; + } + + return arrayList.contains(id); + } + + public static void reportE2eError(ArbitraryDataProvider arbitraryDataProvider, User user) { + arbitraryDataProvider.incrementValue(user.getAccountName(), ArbitraryDataProvider.E2E_ERRORS); + + if (arbitraryDataProvider.getLongValue(user.getAccountName(), + ArbitraryDataProvider.E2E_ERRORS_TIMESTAMP) == -1L) { + arbitraryDataProvider.storeOrUpdateKeyValue( + user.getAccountName(), + ArbitraryDataProvider.E2E_ERRORS_TIMESTAMP, + System.currentTimeMillis() / 1000 + ); + } + } + + @Nullable + public static Problem readE2eError(ArbitraryDataProvider arbitraryDataProvider, User user) { + int value = arbitraryDataProvider.getIntegerValue(user.getAccountName(), + ArbitraryDataProvider.E2E_ERRORS); + long timestamp = arbitraryDataProvider.getLongValue(user.getAccountName(), + ArbitraryDataProvider.E2E_ERRORS_TIMESTAMP); + + arbitraryDataProvider.deleteKeyForAccount(user.getAccountName(), + ArbitraryDataProvider.E2E_ERRORS); + + arbitraryDataProvider.deleteKeyForAccount(user.getAccountName(), + ArbitraryDataProvider.E2E_ERRORS_TIMESTAMP); + + if (value > 0 && timestamp > 0) { + return new Problem(SendClientDiagnosticRemoteOperation.E2EE_ERRORS, value, timestamp); + } else { + return null; + } + } + + public static String generateUid() { + return UUID.randomUUID().toString().replaceAll("-", ""); + } + + public static String retrievePublicKeyForUser(User user, Context context) { + return new ArbitraryDataProviderImpl(context).getValue(user, PUBLIC_KEY); + } + + public static byte[] generateIV() { + return EncryptionUtils.randomBytes(EncryptionUtils.ivLength); + } + + public static void savePublicKey(User currentUser, + String key, + String user, + ArbitraryDataProvider arbitraryDataProvider) { + arbitraryDataProvider.storeOrUpdateKeyValue(currentUser, + ArbitraryDataProvider.PUBLIC_KEY + user, + key); + } + + public static String getPublicKey(User currentUser, + String user, + ArbitraryDataProvider arbitraryDataProvider) { + return arbitraryDataProvider.getValue(currentUser, ArbitraryDataProvider.PUBLIC_KEY + user); + } +} diff --git a/app/src/ose/AndroidManifest.xml b/app/src/ose/AndroidManifest.xml index 4289641fa..33775680a 100644 --- a/app/src/ose/AndroidManifest.xml +++ b/app/src/ose/AndroidManifest.xml @@ -254,6 +254,16 @@ + + + + -- GitLab From 45c3b5e10f31a049e996decfdede06d52e721a68 Mon Sep 17 00:00:00 2001 From: althafvly Date: Thu, 4 Sep 2025 10:44:51 +0530 Subject: [PATCH 2/9] AM: import /e/ Specific changes from account manager --- app/build.gradle.kts | 10 +- app/proguard-rules-release.pro | 7 + .../android/sso/BinderDependencies.kt | 29 + .../android/sso/InputStreamBinder.java | 232 +-- .../android/sso/OidcTokenRefresher.kt | 132 ++ .../android/utils/AccountManagerUtils.java | 65 + .../services/AccountManagerService.java | 11 +- .../activity/SsoGrantPermissionActivity.java | 216 --- .../ui/activity/SsoGrantPermissionActivity.kt | 86 + .../ui/activity/SsoGrantPermissionEvent.kt | 26 + .../activity/SsoGrantPermissionViewModel.kt | 176 ++ .../android/utils/EncryptionUtils.java | 83 + .../com/owncloud/utils/EncryptionUtils.java | 1587 ----------------- .../at/bitfire/davdroid/db/ServiceDao.kt | 3 + .../at/bitfire/davdroid/network/HttpClient.kt | 10 + .../at/bitfire/davdroid/util/UserIdFetcher.kt | 45 + .../layout/activity_sso_grant_permission.xml | 23 + app/src/ose/AndroidManifest.xml | 15 +- gradle/libs.versions.toml | 10 + 19 files changed, 776 insertions(+), 1990 deletions(-) create mode 100644 app/src/main/java/com/nextcloud/android/sso/BinderDependencies.kt create mode 100644 app/src/main/java/com/nextcloud/android/sso/OidcTokenRefresher.kt create mode 100644 app/src/main/java/com/nextcloud/android/utils/AccountManagerUtils.java delete mode 100644 app/src/main/java/com/owncloud/android/ui/activity/SsoGrantPermissionActivity.java create mode 100644 app/src/main/java/com/owncloud/android/ui/activity/SsoGrantPermissionActivity.kt create mode 100644 app/src/main/java/com/owncloud/android/ui/activity/SsoGrantPermissionEvent.kt create mode 100644 app/src/main/java/com/owncloud/android/ui/activity/SsoGrantPermissionViewModel.kt create mode 100644 app/src/main/java/com/owncloud/android/utils/EncryptionUtils.java delete mode 100644 app/src/main/java/com/owncloud/utils/EncryptionUtils.java create mode 100644 app/src/main/kotlin/at/bitfire/davdroid/util/UserIdFetcher.kt create mode 100644 app/src/main/res/layout/activity_sso_grant_permission.xml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 70193ab13..311c4d367 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -274,7 +274,6 @@ dependencies { implementation(libs.okhttp.base) implementation(libs.okhttp.brotli) implementation(libs.okhttp.logging) - implementation(libs.openid.appauth) implementation(libs.unifiedpush) { // UnifiedPush connector seems to be using a workaround by importing this library. // Will be removed after https://github.com/tink-crypto/tink-java-apps/pull/5 is merged. @@ -288,9 +287,12 @@ dependencies { implementation(libs.commons.lang) // e-Specific dependencies + implementation(libs.appauth) + implementation(libs.android.singlesignon) implementation(libs.androidx.runtime.livedata) implementation(libs.elib) implementation(libs.ez.vcard) + implementation(libs.jackrabbit.webdav) implementation(libs.synctools) { exclude(group="androidx.test") exclude(group = "junit") @@ -298,6 +300,12 @@ dependencies { implementation(libs.ical4j) { exclude(group = "commons-logging", module = "commons-logging") } + implementation(libs.nextcloud.library) { + exclude(group = "org.ogce", module = "xpp3") + } + implementation(libs.commons.httpclient) { + exclude(group = "commons-logging", module = "commons-logging") + } // for tests androidTestImplementation(libs.androidx.arch.core.testing) diff --git a/app/proguard-rules-release.pro b/app/proguard-rules-release.pro index 0288d08ee..d392e5af3 100644 --- a/app/proguard-rules-release.pro +++ b/app/proguard-rules-release.pro @@ -24,3 +24,10 @@ -dontwarn sun.net.spi.nameservice.NameService -dontwarn sun.net.spi.nameservice.NameServiceDescriptor -dontwarn org.xbill.DNS.spi.DnsjavaInetAddressResolverProvider + +-dontwarn edu.umd.cs.findbugs.annotations.SuppressFBWarnings + +-keep class com.nextcloud.android.sso.** { *; } +-keep interface com.nextcloud.android.sso.** { *; } +-keep class org.apache.commons.httpclient.** { *; } +-keep interface org.apache.commons.httpclient.** { *; } diff --git a/app/src/main/java/com/nextcloud/android/sso/BinderDependencies.kt b/app/src/main/java/com/nextcloud/android/sso/BinderDependencies.kt new file mode 100644 index 000000000..2caf3845f --- /dev/null +++ b/app/src/main/java/com/nextcloud/android/sso/BinderDependencies.kt @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2025 e Foundation + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ +package com.nextcloud.android.sso + +import at.bitfire.davdroid.settings.AccountSettings +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@EntryPoint +@InstallIn(SingletonComponent::class) +interface BinderDependencies { + fun accountSettingsFactory(): AccountSettings.Factory +} diff --git a/app/src/main/java/com/nextcloud/android/sso/InputStreamBinder.java b/app/src/main/java/com/nextcloud/android/sso/InputStreamBinder.java index 31e061b57..ea5f2affc 100644 --- a/app/src/main/java/com/nextcloud/android/sso/InputStreamBinder.java +++ b/app/src/main/java/com/nextcloud/android/sso/InputStreamBinder.java @@ -21,13 +21,13 @@ import android.text.TextUtils; import com.nextcloud.android.sso.aidl.IInputStreamService; import com.nextcloud.android.sso.aidl.NextcloudRequest; import com.nextcloud.android.sso.aidl.ParcelFileDescriptorUtil; -import com.nextcloud.client.account.UserAccountManager; +import com.nextcloud.android.utils.AccountManagerUtils; +import com.owncloud.android.utils.EncryptionUtils; import com.owncloud.android.lib.common.OwnCloudAccount; import com.owncloud.android.lib.common.OwnCloudClient; import com.owncloud.android.lib.common.OwnCloudClientManager; import com.owncloud.android.lib.common.OwnCloudClientManagerFactory; -import com.owncloud.android.lib.common.utils.Log_OC; -import com.owncloud.android.utils.EncryptionUtils; +import com.owncloud.android.lib.common.operations.RemoteOperation; import org.apache.commons.httpclient.HttpConnection; import org.apache.commons.httpclient.HttpMethodBase; @@ -58,10 +58,12 @@ import java.io.Serializable; import java.util.Collection; import java.util.List; import java.util.Map; +import java.util.logging.Level; +import java.util.logging.Logger; +import androidx.annotation.NonNull; import androidx.annotation.VisibleForTesting; -import static com.nextcloud.android.sso.Constants.DELIMITER; import static com.nextcloud.android.sso.Constants.EXCEPTION_ACCOUNT_NOT_FOUND; import static com.nextcloud.android.sso.Constants.EXCEPTION_HTTP_REQUEST_FAILED; import static com.nextcloud.android.sso.Constants.EXCEPTION_INVALID_REQUEST_URL; @@ -74,7 +76,6 @@ import static com.nextcloud.android.sso.Constants.SSO_SHARED_PREFERENCE; * Stream binder to pass usable InputStreams across the process boundary in Android. */ public class InputStreamBinder extends IInputStreamService.Stub { - private final static String TAG = "InputStreamBinder"; private static final String CONTENT_TYPE_APPLICATION_JSON = "application/json"; private static final String CHARSET_UTF8 = "UTF-8"; @@ -83,13 +84,12 @@ public class InputStreamBinder extends IInputStreamService.Stub { private static final int HTTP_STATUS_CODE_MULTIPLE_CHOICES = 300; private static final char PATH_SEPARATOR = '/'; - private static final int ZERO_LENGTH = 0; - private Context context; - private UserAccountManager accountManager; + public static final String DELIMITER = "_"; + private final Context context; + private final Logger logger = Logger.getLogger(TAG); - public InputStreamBinder(Context context, UserAccountManager accountManager) { + public InputStreamBinder(Context context) { this.context = context; - this.accountManager = accountManager; } public ParcelFileDescriptor performNextcloudRequestV2(ParcelFileDescriptor input) { @@ -112,7 +112,7 @@ public class InputStreamBinder extends IInputStreamService.Stub { NextcloudRequest request = deserializeObjectAndCloseStream(is); response = processRequestV2(request, requestBodyInputStream); } catch (Exception e) { - Log_OC.e(TAG, "Error during Nextcloud request", e); + logger.log(Level.SEVERE, "Error during Nextcloud request", e); exception = e; } @@ -122,61 +122,22 @@ public class InputStreamBinder extends IInputStreamService.Stub { InputStream resultStream = new java.io.SequenceInputStream(exceptionStream, response.getBody()); return ParcelFileDescriptorUtil.pipeFrom(resultStream, - thread -> Log_OC.d(TAG, "Done sending result"), - response.getMethod()); + thread -> logger.log(Level.INFO, "InputStreamBinder: Done sending result")); } catch (IOException e) { - Log_OC.e(TAG, "Error while sending response back to client app", e); + logger.log(Level.SEVERE, "Error while sending response back to client app", e); } + return null; } public ParcelFileDescriptor performNextcloudRequest(ParcelFileDescriptor input) { - return performNextcloudRequestAndBodyStream(input, null); + return performNextcloudRequestAndBodyStreamV2(input, null); } public ParcelFileDescriptor performNextcloudRequestAndBodyStream( - ParcelFileDescriptor input, - ParcelFileDescriptor requestBodyParcelFileDescriptor) { - // read the input - final InputStream is = new ParcelFileDescriptor.AutoCloseInputStream(input); - - final InputStream requestBodyInputStream = requestBodyParcelFileDescriptor != null ? - new ParcelFileDescriptor.AutoCloseInputStream(requestBodyParcelFileDescriptor) : null; - Exception exception = null; - HttpMethodBase httpMethod = null; - InputStream httpStream = new InputStream() { - @Override - public int read() { - return ZERO_LENGTH; - } - }; - - try { - // Start request and catch exceptions - NextcloudRequest request = deserializeObjectAndCloseStream(is); - httpMethod = processRequest(request, requestBodyInputStream); - httpStream = httpMethod.getResponseBodyAsStream(); - } catch (Exception e) { - Log_OC.e(TAG, "Error during Nextcloud request", e); - exception = e; - } - - try { - // Write exception to the stream followed by the actual network stream - InputStream exceptionStream = serializeObjectToInputStream(exception); - InputStream resultStream; - if (httpStream != null) { - resultStream = new java.io.SequenceInputStream(exceptionStream, httpStream); - } else { - resultStream = exceptionStream; - } - return ParcelFileDescriptorUtil.pipeFrom(resultStream, - thread -> Log_OC.d(TAG, "Done sending result"), - httpMethod); - } catch (IOException e) { - Log_OC.e(TAG, "Error while sending response back to client app", e); - } - return null; + ParcelFileDescriptor input, + ParcelFileDescriptor requestBodyParcelFileDescriptor) { + return performNextcloudRequestAndBodyStreamV2(input, requestBodyParcelFileDescriptor); } private ByteArrayInputStream serializeObjectToInputStreamV2(Exception exception, String headers) { @@ -190,21 +151,12 @@ public class InputStreamBinder extends IInputStreamService.Stub { baosByteArray = baos.toByteArray(); } catch (IOException e) { - Log_OC.e(TAG, "Error while sending response back to client app", e); + logger.log(Level.SEVERE, "Error while sending response back to client app", e); } return new ByteArrayInputStream(baosByteArray); } - private ByteArrayInputStream serializeObjectToInputStream(T obj) throws IOException { - ByteArrayOutputStream baos = new ByteArrayOutputStream(); - ObjectOutputStream oos = new ObjectOutputStream(baos); - oos.writeObject(obj); - oos.flush(); - oos.close(); - return new ByteArrayInputStream(baos.toByteArray()); - } - private T deserializeObjectAndCloseStream(InputStream is) throws IOException, ClassNotFoundException { ObjectInputStream ois = new ObjectInputStream(is); @@ -307,78 +259,26 @@ public class InputStreamBinder extends IInputStreamService.Stub { return method; } - private HttpMethodBase processRequest(final NextcloudRequest request, final InputStream requestBodyInputStream) - throws UnsupportedOperationException, - com.owncloud.android.lib.common.accounts.AccountUtils.AccountNotFoundException, - OperationCanceledException, AuthenticatorException, IOException { - Account account = accountManager.getAccountByName(request.getAccountName()); - if (account == null) { - throw new IllegalStateException(EXCEPTION_ACCOUNT_NOT_FOUND); - } - - // Validate token - if (!isValid(request)) { - throw new IllegalStateException(EXCEPTION_INVALID_TOKEN); - } - - // Validate URL - if (request.getUrl().length() == 0 || request.getUrl().charAt(0) != PATH_SEPARATOR) { - throw new IllegalStateException(EXCEPTION_INVALID_REQUEST_URL, - new IllegalStateException("URL need to start with a /")); - } - - OwnCloudClientManager ownCloudClientManager = OwnCloudClientManagerFactory.getDefaultSingleton(); - OwnCloudAccount ocAccount = new OwnCloudAccount(account, context); - OwnCloudClient client = ownCloudClientManager.getClientFor(ocAccount, context); - - HttpMethodBase method = buildMethod(request, client.getBaseUri(), requestBodyInputStream); - - if (request.getParameterV2() != null && !request.getParameterV2().isEmpty()) { - method.setQueryString(convertListToNVP(request.getParameterV2())); - } else { - method.setQueryString(convertMapToNVP(request.getParameter())); - } - method.addRequestHeader("OCS-APIREQUEST", "true"); - - for (Map.Entry> header : request.getHeader().entrySet()) { - // https://stackoverflow.com/a/3097052 - method.addRequestHeader(header.getKey(), TextUtils.join(",", header.getValue())); - - if ("OCS-APIREQUEST".equalsIgnoreCase(header.getKey())) { - throw new IllegalStateException( - "The 'OCS-APIREQUEST' header will be automatically added by the Nextcloud SSO Library. " + - "Please remove the header before making a request"); - } + /* + * for non ocs/dav requests (nextcloud app: ex: notes app), when OIDC is used, we need to pass an special header. + * We should not pass this header for ocs/dav requests as it can cause session cookie not being used for those request. + * + * These nextcloud app request paths contain `/index.php/apps/` on them. + */ + private boolean shouldAddHeaderForOidcLogin(@NonNull Context context, @NonNull Account account, @NonNull String path) { + boolean isOidcAccount = AccountManagerUtils.isOidcAccount(context, account); + if (!isOidcAccount) { + return false; } - client.setFollowRedirects(request.isFollowRedirects()); - int status = client.executeMethod(method); - - // Check if status code is 2xx --> https://en.wikipedia.org/wiki/List_of_HTTP_status_codes#2xx_Success - if (status >= HTTP_STATUS_CODE_OK && status < HTTP_STATUS_CODE_MULTIPLE_CHOICES) { - return method; - } else { - InputStream inputStream = method.getResponseBodyAsStream(); - String total = "No response body"; - - // If response body is available - if (inputStream != null) { - total = inputStreamToString(inputStream); - Log_OC.e(TAG, total); - } - - method.releaseConnection(); - throw new IllegalStateException(EXCEPTION_HTTP_REQUEST_FAILED, - new IllegalStateException(String.valueOf(status), - new IllegalStateException(total))); - } + return path.contains("/index.php/apps/"); } private Response processRequestV2(final NextcloudRequest request, final InputStream requestBodyInputStream) throws UnsupportedOperationException, com.owncloud.android.lib.common.accounts.AccountUtils.AccountNotFoundException, OperationCanceledException, AuthenticatorException, IOException { - Account account = accountManager.getAccountByName(request.getAccountName()); + Account account = AccountManagerUtils.getAccountByName(context, request.getAccountName()); if (account == null) { throw new IllegalStateException(EXCEPTION_ACCOUNT_NOT_FOUND); } @@ -389,39 +289,48 @@ public class InputStreamBinder extends IInputStreamService.Stub { } // Validate URL - if (request.getUrl().length() == 0 || request.getUrl().charAt(0) != PATH_SEPARATOR) { + if (request.getUrl().isEmpty() || request.getUrl().charAt(0) != PATH_SEPARATOR) { throw new IllegalStateException(EXCEPTION_INVALID_REQUEST_URL, - new IllegalStateException("URL need to start with a /")); + new IllegalStateException("URL need to start with a /")); + } + + if (AccountManagerUtils.isOidcAccount(context, account)) { + // Blocking call + OidcTokenRefresher.refresh(context, account); } - OwnCloudClientManager ownCloudClientManager = OwnCloudClientManagerFactory.getDefaultSingleton(); - OwnCloudAccount ocAccount = new OwnCloudAccount(account, context); - OwnCloudClient client = ownCloudClientManager.getClientFor(ocAccount, context); + final OwnCloudClientManager ownCloudClientManager = OwnCloudClientManagerFactory.getDefaultSingleton(); + final OwnCloudAccount ownCloudAccount = new OwnCloudAccount(account, context); + final OwnCloudClient client = ownCloudClientManager.getClientFor(ownCloudAccount, context, OwnCloudClient.DONT_USE_COOKIES); HttpMethodBase method = buildMethod(request, client.getBaseUri(), requestBodyInputStream); if (request.getParameterV2() != null && !request.getParameterV2().isEmpty()) { method.setQueryString(convertListToNVP(request.getParameterV2())); - } else { - method.setQueryString(convertMapToNVP(request.getParameter())); } - method.addRequestHeader("OCS-APIREQUEST", "true"); - for (Map.Entry> header : request.getHeader().entrySet()) { // https://stackoverflow.com/a/3097052 method.addRequestHeader(header.getKey(), TextUtils.join(",", header.getValue())); + } - if ("OCS-APIREQUEST".equalsIgnoreCase(header.getKey())) { - throw new IllegalStateException( - "The 'OCS-APIREQUEST' header will be automatically added by the Nextcloud SSO Library. " + - "Please remove the header before making a request"); - } + method.setRequestHeader( + RemoteOperation.OCS_API_HEADER, + RemoteOperation.OCS_API_HEADER_VALUE + ); + + if (shouldAddHeaderForOidcLogin(context, account, request.getUrl())) { + method.setRequestHeader( + RemoteOperation.OIDC_LOGIN_WITH_TOKEN, + RemoteOperation.OIDC_LOGIN_WITH_TOKEN_VALUE + ); } - client.setFollowRedirects(request.isFollowRedirects()); + client.setFollowRedirects(true); int status = client.executeMethod(method); + ownCloudClientManager.saveAllClients(context, account.type); + // Check if status code is 2xx --> https://en.wikipedia.org/wiki/List_of_HTTP_status_codes#2xx_Success if (status >= HTTP_STATUS_CODE_OK && status < HTTP_STATUS_CODE_MULTIPLE_CHOICES) { return new Response(method); @@ -432,7 +341,7 @@ public class InputStreamBinder extends IInputStreamService.Stub { // If response body is available if (inputStream != null) { total = inputStreamToString(inputStream); - Log_OC.e(TAG, total); + logger.severe(total); } method.releaseConnection(); @@ -443,19 +352,12 @@ public class InputStreamBinder extends IInputStreamService.Stub { } private boolean isValid(NextcloudRequest request) { - String[] callingPackageNames = context.getPackageManager().getPackagesForUid(Binder.getCallingUid()); + String callingPackageName = context.getPackageManager().getNameForUid(Binder.getCallingUid()); SharedPreferences sharedPreferences = context.getSharedPreferences(SSO_SHARED_PREFERENCE, - Context.MODE_PRIVATE); - for (String callingPackageName : callingPackageNames) { - String hash = sharedPreferences.getString(callingPackageName + DELIMITER + request.getAccountName(), ""); - if (hash.isEmpty()) - continue; - if (validateToken(hash, request.getToken())) { - return true; - } - } - return false; + Context.MODE_PRIVATE); + String hash = sharedPreferences.getString(callingPackageName + DELIMITER + request.getAccountName(), ""); + return validateToken(hash, request.getToken()); } private boolean validateToken(String hash, String token) { @@ -500,20 +402,6 @@ public class InputStreamBinder extends IInputStreamService.Stub { } } - @VisibleForTesting - public static NameValuePair[] convertMapToNVP(Map map) { - final var nvp = new NameValuePair[map.size()]; - int i = 0; - - for (Map.Entry entry : map.entrySet()) { - final var nameValuePair = new NameValuePair(entry.getKey(), entry.getValue()); - nvp[i] = nameValuePair; - i++; - } - - return nvp; - } - @VisibleForTesting public static NameValuePair[] convertListToNVP(Collection list) { NameValuePair[] nvp = new NameValuePair[list.size()]; diff --git a/app/src/main/java/com/nextcloud/android/sso/OidcTokenRefresher.kt b/app/src/main/java/com/nextcloud/android/sso/OidcTokenRefresher.kt new file mode 100644 index 000000000..e4b53f052 --- /dev/null +++ b/app/src/main/java/com/nextcloud/android/sso/OidcTokenRefresher.kt @@ -0,0 +1,132 @@ +/* + * Copyright (C) 2025 e Foundation + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package com.nextcloud.android.sso + +import android.accounts.Account +import android.content.Context +import at.bitfire.davdroid.network.HttpClient.HttpClientEntryPoint +import at.bitfire.davdroid.settings.AccountSettings +import dagger.hilt.android.EntryPointAccessors +import kotlinx.coroutines.runBlocking +import net.openid.appauth.AuthState +import net.openid.appauth.AuthorizationService +import net.openid.appauth.ClientAuthentication +import org.jetbrains.annotations.Blocking +import java.util.logging.Level +import java.util.logging.Logger +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.coroutines.suspendCoroutine + +/** + * Utility for refreshing OpenID Connect (OIDC) tokens in the Android AccountManager. + * + * This object exposes a synchronous, blocking entry point for token refresh requests + * and internally uses coroutines to perform the refresh operation with proper + * callback-to-suspension conversion. + */ +object OidcTokenRefresher { + + val logger: Logger = Logger.getLogger(this.javaClass.name) + + /** + * Refreshes the OIDC token for the given [Account]. + * + * It will: + * 1. Invoke the authorization service to refresh tokens. + * 2. Update AccountManager on successful refresh or log failures. + * + * **Threading:** This method uses [runBlocking] and therefore must **not** be + * called from the Main/UI thread. It is annotated with `@Blocking` to signal + * blocking behavior. + */ + @JvmStatic + @Blocking + fun refresh(context: Context, account: Account) { + runBlocking { + val accountSettingsFactory = EntryPointAccessors.fromApplication( + context.applicationContext, + BinderDependencies::class.java + ).accountSettingsFactory() + + val accountSettings = accountSettingsFactory.create(account) + val credentials = accountSettings.credentials() + val authState = credentials.authState + + if (authState == null) { + logger.log(Level.FINE, "Account: $account has null AuthState, refresh isn't possible.") + return@runBlocking + } + + val authorizationService = + EntryPointAccessors.fromApplication(context, HttpClientEntryPoint::class.java) + .authorizationService() + + val updatedAuthState = runCatching { + refreshAuthState(authorizationService, authState) + }.getOrNull() + + if (updatedAuthState != null) { + updateAndroidAccountManagerAuthState(accountSettings, updatedAuthState) + } else { + logger.warning("Couldn't update AuthState for account: $account") + } + } + } + + /** + * Suspends until the authState has fresh tokens from AuthorizationService. + * + * Internally it bridges the callback-based `performActionWithFreshTokens` + * API into a coroutine suspension using [suspendCoroutine]. On success, it + * resumes with the same [AuthState] instance containing updated tokens. On + * failure, it throws the encountered [Throwable]. + * + * @param authService The [AuthorizationService] to use for token refresh. + * @param authState The current [AuthState] containing existing tokens. + * @param clientAuth [ClientAuthentication] mechanism (e.g., client secret). + * @return The same [AuthState] instance with refreshed tokens. + * @throws Exception if the refresh operation fails. + */ + private suspend fun refreshAuthState( + authService: AuthorizationService, authState: AuthState + ): AuthState { + return suspendCoroutine { continuation -> + authState.performActionWithFreshTokens( + authService, + ) { accessToken, _, authorizationException -> + when { + accessToken != null -> continuation.resume(authState) + authorizationException != null -> continuation.resumeWithException( + authorizationException + ) + } + } + } + } + + /** + * Persists an updated [AuthState] back into the Android AccountManager. + */ + private fun updateAndroidAccountManagerAuthState( + accountSettings: AccountSettings, updatedAuthState: AuthState + ) = accountSettings.credentials( + accountSettings.credentials().copy(authState = updatedAuthState) + ) +} diff --git a/app/src/main/java/com/nextcloud/android/utils/AccountManagerUtils.java b/app/src/main/java/com/nextcloud/android/utils/AccountManagerUtils.java new file mode 100644 index 000000000..faf5ff7a1 --- /dev/null +++ b/app/src/main/java/com/nextcloud/android/utils/AccountManagerUtils.java @@ -0,0 +1,65 @@ +/* + * Copyright MURENA SAS 2023 + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.nextcloud.android.utils; + +import android.accounts.Account; +import android.accounts.AccountManager; +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import at.bitfire.davdroid.Constants; +import at.bitfire.davdroid.R; +import at.bitfire.davdroid.settings.AccountSettings; + +public final class AccountManagerUtils { + + private AccountManagerUtils() { + // utility class -> private constructor + } + + @Nullable + public static Account getAccountByName(@NonNull Context context, String name) { + for (Account account : getAccounts(context)) { + if (account.name.equals(name)) { + return account; + } + } + + return null; + } + + @Nullable + public static boolean isOidcAccount(@NonNull Context context, @NonNull Account account) { + AccountManager accountManager = AccountManager.get(context); + String authState = accountManager.getUserData(account, AccountSettings.KEY_AUTH_STATE); + return authState != null && !authState.trim().isEmpty(); + } + + @NonNull + public static Account[] getAccounts(@NonNull Context context) { + AccountManager accountManager = AccountManager.get(context); + return accountManager.getAccountsByType(getAccountType(context)); + } + + @NonNull + public static String getAccountType(@NonNull Context context) { + return context.getString(R.string.eelo_account_type); + } + +} diff --git a/app/src/main/java/com/owncloud/android/services/AccountManagerService.java b/app/src/main/java/com/owncloud/android/services/AccountManagerService.java index 05d7b54dc..aad07f3eb 100644 --- a/app/src/main/java/com/owncloud/android/services/AccountManagerService.java +++ b/app/src/main/java/com/owncloud/android/services/AccountManagerService.java @@ -11,27 +11,20 @@ import android.content.Intent; import android.os.IBinder; import com.nextcloud.android.sso.InputStreamBinder; -import com.nextcloud.client.account.UserAccountManager; - -import javax.inject.Inject; - -import dagger.android.AndroidInjection; public class AccountManagerService extends Service { private InputStreamBinder mBinder; - @Inject UserAccountManager accountManager; @Override public void onCreate() { super.onCreate(); - AndroidInjection.inject(this); } @Override public IBinder onBind(Intent intent) { - if(mBinder == null) { - mBinder = new InputStreamBinder(getApplicationContext(), accountManager); + if (mBinder == null) { + mBinder = new InputStreamBinder(getApplicationContext()); } return mBinder; } diff --git a/app/src/main/java/com/owncloud/android/ui/activity/SsoGrantPermissionActivity.java b/app/src/main/java/com/owncloud/android/ui/activity/SsoGrantPermissionActivity.java deleted file mode 100644 index 41524cda3..000000000 --- a/app/src/main/java/com/owncloud/android/ui/activity/SsoGrantPermissionActivity.java +++ /dev/null @@ -1,216 +0,0 @@ -/* - * Nextcloud Android client application - * - * @author David Luhmer - * @author Andy Scherzinger - * Copyright (C) 2018 David Luhmer - * Copyright (C) 2018 Andy Scherzinger - * - * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only - */ - -package com.owncloud.android.ui.activity; - -import android.accounts.Account; -import android.accounts.AccountManager; -import android.content.ComponentName; -import android.content.Context; -import android.content.Intent; -import android.content.SharedPreferences; -import android.content.pm.ApplicationInfo; -import android.content.pm.PackageManager; -import android.graphics.Typeface; -import android.graphics.drawable.Drawable; -import android.os.Bundle; -import android.text.Spannable; -import android.text.SpannableStringBuilder; -import android.text.style.ForegroundColorSpan; -import android.text.style.StyleSpan; - -import com.google.android.material.dialog.MaterialAlertDialogBuilder; -import com.nextcloud.android.sso.Constants; -import com.nextcloud.utils.extensions.IntentExtensionsKt; -import com.owncloud.android.MainApp; -import com.owncloud.android.R; -import com.owncloud.android.databinding.DialogSsoGrantPermissionBinding; -import com.owncloud.android.lib.common.OwnCloudAccount; -import com.owncloud.android.lib.common.accounts.AccountUtils; -import com.owncloud.android.lib.common.utils.Log_OC; -import com.owncloud.android.utils.EncryptionUtils; -import com.owncloud.android.utils.theme.ViewThemeUtils; - -import java.util.UUID; - -import javax.inject.Inject; - -import androidx.appcompat.app.AlertDialog; - -import static com.nextcloud.android.sso.Constants.DELIMITER; -import static com.nextcloud.android.sso.Constants.EXCEPTION_ACCOUNT_ACCESS_DECLINED; -import static com.nextcloud.android.sso.Constants.EXCEPTION_ACCOUNT_NOT_FOUND; -import static com.nextcloud.android.sso.Constants.NEXTCLOUD_FILES_ACCOUNT; -import static com.nextcloud.android.sso.Constants.NEXTCLOUD_SSO; -import static com.nextcloud.android.sso.Constants.NEXTCLOUD_SSO_EXCEPTION; -import static com.nextcloud.android.sso.Constants.SSO_SHARED_PREFERENCE; - - -/** - * Activity for granting access rights to a Nextcloud account, used for SSO. - */ -public class SsoGrantPermissionActivity extends BaseActivity { - - private static final String TAG = SsoGrantPermissionActivity.class.getCanonicalName(); - - private String packageName; - private Account account; - - @Inject ViewThemeUtils.Factory themeUtilsFactory; - private ViewThemeUtils viewThemeUtils; - - private AlertDialog dialog; - - private DialogSsoGrantPermissionBinding binding; - - public DialogSsoGrantPermissionBinding getBinding() { - return binding; - } - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - - viewThemeUtils = themeUtilsFactory.withDefaultSchemes(); - - binding = DialogSsoGrantPermissionBinding.inflate(getLayoutInflater()); - - ComponentName callingActivity = getCallingActivity(); - - if (callingActivity != null) { - packageName = callingActivity.getPackageName(); - final String appName = getAppNameForPackage(packageName); - account = IntentExtensionsKt.getParcelableArgument(getIntent(), NEXTCLOUD_FILES_ACCOUNT, Account.class); - - if (account != null) { - final SpannableStringBuilder dialogText = makeSpecialPartsBold( - getString(R.string.single_sign_on_request_token, appName, account.name), - appName, - account.name); - binding.permissionText.setText(dialogText); - } - - try { - if (packageName != null) { - Drawable appIcon = getPackageManager().getApplicationIcon(packageName); - binding.appIcon.setImageDrawable(appIcon); - } - } catch (PackageManager.NameNotFoundException e) { - Log_OC.e(TAG, "Error retrieving app icon", e); - } - - MaterialAlertDialogBuilder builder = getMaterialAlertDialogBuilder(); - - builder - .setView(binding.getRoot()) - .setCancelable(false) - .setPositiveButton(R.string.permission_allow, (dialog, which) -> grantPermission()) - .setNegativeButton(R.string.permission_deny, (dialog, which) -> exitFailed()); - - viewThemeUtils.dialog.colorMaterialAlertDialogBackground(this, builder); - - dialog = builder.create(); - dialog.show(); - - Log_OC.v(TAG, "TOKEN-REQUEST: Calling Package: " + packageName); - Log_OC.v(TAG, "TOKEN-REQUEST: App Name: " + appName); - } else { - // Activity was not started using startActivityForResult! - Log_OC.e(TAG, "Calling Package is null"); - setResultAndExit("Request was not executed properly. Use startActivityForResult()"); - } - } - - public MaterialAlertDialogBuilder getMaterialAlertDialogBuilder() { - return new MaterialAlertDialogBuilder(this); - } - - @Override - protected void onStart() { - super.onStart(); - viewThemeUtils.platform.colorTextButtons(dialog.getButton(AlertDialog.BUTTON_POSITIVE), - dialog.getButton(AlertDialog.BUTTON_NEGATIVE)); - } - - private SpannableStringBuilder makeSpecialPartsBold(String text, String... toBeStyledText) { - SpannableStringBuilder ssb = new SpannableStringBuilder(text); - for (String textBlock : toBeStyledText) { - int start = text.indexOf(textBlock); - int end = start + textBlock.length(); - ssb.setSpan(new StyleSpan(Typeface.BOLD), start, end, 0); - ssb.setSpan(new ForegroundColorSpan(getResources().getColor(R.color.text_color)), start, end, - Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); - } - - return ssb; - } - - private void setResultAndExit(String exception) { - Intent data = new Intent(); - data.putExtra(NEXTCLOUD_SSO_EXCEPTION, exception); - setResult(RESULT_CANCELED, data); - finish(); - } - - private String getAppNameForPackage(String pkg) { - final PackageManager pm = getApplicationContext().getPackageManager(); - ApplicationInfo ai = null; - try { - ai = pm.getApplicationInfo(pkg, 0); - } catch (final PackageManager.NameNotFoundException e) { - Log_OC.e(TAG, "Error fetching app name for package", e); - } - return (String) (ai != null ? pm.getApplicationLabel(ai) : "(unknown)"); - } - - private void exitFailed() { - setResultAndExit(EXCEPTION_ACCOUNT_ACCESS_DECLINED); - } - - private void grantPermission() { - // create token - SharedPreferences sharedPreferences = getSharedPreferences(SSO_SHARED_PREFERENCE, Context.MODE_PRIVATE); - String token = UUID.randomUUID().toString().replaceAll("-", ""); - - String hashedTokenWithSalt = EncryptionUtils.generateSHA512(token); - - SharedPreferences.Editor editor = sharedPreferences.edit(); - editor.putString(packageName + DELIMITER + account.name, hashedTokenWithSalt); - editor.apply(); - - String serverUrl; - String userId; - try { - OwnCloudAccount ocAccount = new OwnCloudAccount(account, this); - serverUrl = ocAccount.getBaseUri().toString(); - AccountManager accountManager = AccountManager.get(this); - userId = accountManager.getUserData(account, - com.owncloud.android.lib.common.accounts.AccountUtils.Constants.KEY_USER_ID); - } catch (AccountUtils.AccountNotFoundException e) { - Log_OC.e(TAG, "Account not found"); - setResultAndExit(EXCEPTION_ACCOUNT_NOT_FOUND); - return; - } - - final Bundle result = new Bundle(); - result.putString(AccountManager.KEY_ACCOUNT_NAME, account.name); - result.putString(AccountManager.KEY_ACCOUNT_TYPE, MainApp.getAccountType(this)); - result.putString(AccountManager.KEY_AUTHTOKEN, NEXTCLOUD_SSO); - result.putString(Constants.SSO_USER_ID, userId); - result.putString(Constants.SSO_TOKEN, token); - result.putString(Constants.SSO_SERVER_URL, serverUrl); - - Intent data = new Intent(); - data.putExtra(NEXTCLOUD_SSO, result); - setResult(RESULT_OK, data); - finish(); - } -} diff --git a/app/src/main/java/com/owncloud/android/ui/activity/SsoGrantPermissionActivity.kt b/app/src/main/java/com/owncloud/android/ui/activity/SsoGrantPermissionActivity.kt new file mode 100644 index 000000000..ba791f31d --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/activity/SsoGrantPermissionActivity.kt @@ -0,0 +1,86 @@ +/* + * Copyright MURENA SAS 2024 + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.owncloud.android.ui.activity + +import android.accounts.Account +import android.content.Intent +import android.os.Build +import android.os.Bundle +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.flowWithLifecycle +import androidx.lifecycle.lifecycleScope +import at.bitfire.davdroid.R +import com.nextcloud.android.sso.Constants +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch + +@AndroidEntryPoint +class SsoGrantPermissionActivity : AppCompatActivity() { + + private val viewModel: SsoGrantPermissionViewModel by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_sso_grant_permission) + + lifecycleScope.launch { + viewModel.permissionEvent + .flowWithLifecycle( + lifecycle = lifecycle, + minActiveState = Lifecycle.State.CREATED + ).collectLatest { + when (it) { + is SsoGrantPermissionEvent.PermissionGranted -> setSuccessResult(it.bundle) + is SsoGrantPermissionEvent.PermissionDenied -> setCanceledResult(it.errorMessage) + } + } + } + + validateAccount() + } + + private fun validateAccount() { + val account: Account? = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + intent.getParcelableExtra(Constants.NEXTCLOUD_FILES_ACCOUNT, Account::class.java) + } else { + intent.getParcelableExtra(Constants.NEXTCLOUD_FILES_ACCOUNT) + } + + viewModel.initValidation( + callingActivity = callingActivity, + account = account + ) + } + + private fun setCanceledResult(exception: String) { + val data = Intent() + data.putExtra(Constants.NEXTCLOUD_SSO_EXCEPTION, exception) + setResult(RESULT_CANCELED, data) + finish() + } + + private fun setSuccessResult(result: Bundle) { + val data = Intent() + data.putExtra(Constants.NEXTCLOUD_SSO, result) + setResult(RESULT_OK, data) + finish() + } +} diff --git a/app/src/main/java/com/owncloud/android/ui/activity/SsoGrantPermissionEvent.kt b/app/src/main/java/com/owncloud/android/ui/activity/SsoGrantPermissionEvent.kt new file mode 100644 index 000000000..2e69754c8 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/activity/SsoGrantPermissionEvent.kt @@ -0,0 +1,26 @@ +/* + * Copyright MURENA SAS 2024 + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.owncloud.android.ui.activity + +import android.os.Bundle + +sealed class SsoGrantPermissionEvent { + + data class PermissionGranted(val bundle: Bundle) : SsoGrantPermissionEvent() + + data class PermissionDenied(val errorMessage: String) : SsoGrantPermissionEvent() +} diff --git a/app/src/main/java/com/owncloud/android/ui/activity/SsoGrantPermissionViewModel.kt b/app/src/main/java/com/owncloud/android/ui/activity/SsoGrantPermissionViewModel.kt new file mode 100644 index 000000000..e3a31c453 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/activity/SsoGrantPermissionViewModel.kt @@ -0,0 +1,176 @@ +/* + * Copyright MURENA SAS 2024 + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.owncloud.android.ui.activity + +import android.accounts.Account +import android.accounts.AccountManager +import android.content.ComponentName +import android.content.Context +import android.os.Bundle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import at.bitfire.davdroid.R +import at.bitfire.davdroid.db.AppDatabase +import at.bitfire.davdroid.util.UserIdFetcher +import com.nextcloud.android.sso.Constants +import com.nextcloud.android.sso.InputStreamBinder +import com.owncloud.android.utils.EncryptionUtils +import com.owncloud.android.lib.common.OwnCloudAccount +import com.owncloud.android.lib.common.accounts.AccountUtils +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.launch +import java.util.UUID +import java.util.logging.Level +import java.util.logging.Logger +import javax.inject.Inject +import androidx.core.content.edit + +@HiltViewModel +class SsoGrantPermissionViewModel @Inject constructor( + @ApplicationContext private val context: Context, + private val database: AppDatabase, +) : ViewModel() { + + val logger: Logger = Logger.getLogger(this.javaClass.name) + + private val acceptedAccountTypes = listOf(context.getString(R.string.eelo_account_type)) + private val acceptedPackages = listOf("foundation.e.notes") + + private val _permissionEvent = MutableSharedFlow() + val permissionEvent = _permissionEvent.asSharedFlow() + + fun initValidation(callingActivity: ComponentName?, account: Account?) { + viewModelScope.launch(Dispatchers.IO) { + val packageName = getCallingPackageName(callingActivity) ?: return@launch + validate(packageName, account) + } + } + + private suspend fun emitPermissionDeniedEvent(message: String) { + _permissionEvent.emit( + SsoGrantPermissionEvent.PermissionDenied( + errorMessage = message + ) + ) + } + + private suspend fun getCallingPackageName(callingActivity: ComponentName?): String? { + if (callingActivity != null) { + return callingActivity.packageName + } + + logger.log(Level.SEVERE, "SsoGrantPermissionViewModel: Calling Package is null") + emitPermissionDeniedEvent(Constants.EXCEPTION_ACCOUNT_ACCESS_DECLINED) + return null + } + + private suspend fun validate(packageName: String?, account: Account?) { + if (!isValidRequest(packageName, account)) { + logger.log(Level.SEVERE, "SsoGrantPermissionViewModel: Invalid request") + emitPermissionDeniedEvent(Constants.EXCEPTION_ACCOUNT_ACCESS_DECLINED) + } + + val serverUrl = getServerUrl(account!!) ?: return + + val token = UUID.randomUUID().toString().replace("-".toRegex(), "") + val userId = getUserId(account) + + saveToken( + token = token, + accountName = account.name, + packageName = packageName!! + ) + + passSuccessfulData( + account = account, + token = token, + userId = userId, + serverUrl = serverUrl + ) + + } + + private fun isValidRequest(packageName: String?, account: Account?): Boolean { + if (packageName == null || account == null) { + return false + } + + return acceptedPackages.contains(packageName) && acceptedAccountTypes.contains(account.type) + } + + private suspend fun getServerUrl(account: Account): String? { + try { + val ocAccount = OwnCloudAccount(account, context) + return ocAccount.baseUri.toString() + } catch (e: AccountUtils.AccountNotFoundException) { + logger.log(Level.SEVERE, "SsoGrantPermissionViewModel: Account not found") + emitPermissionDeniedEvent(Constants.EXCEPTION_ACCOUNT_NOT_FOUND) + } + + return null + } + + private fun getUserId(account: Account): String { + val accountManager = AccountManager.get(context) + val userId = accountManager.getUserData(account, AccountUtils.Constants.KEY_USER_ID) + + if (!userId.isNullOrBlank()) { + return userId + } + + val principalUrl = + database.serviceDao().getByAccountName(account.name)?.principal?.toString() + ?: return account.name + + return UserIdFetcher.fetch(principalUrl) ?: account.name + } + + private fun saveToken(token: String, accountName: String, packageName: String) { + val hashedTokenWithSalt = EncryptionUtils.generateSHA512(token) + val sharedPreferences = + context.getSharedPreferences(Constants.SSO_SHARED_PREFERENCE, Context.MODE_PRIVATE) + sharedPreferences.edit { + putString(packageName + InputStreamBinder.DELIMITER + accountName, hashedTokenWithSalt) + } + } + + private suspend fun passSuccessfulData( + account: Account, + token: String, + userId: String, + serverUrl: String + ) { + val result = Bundle().apply { + putString(AccountManager.KEY_ACCOUNT_NAME, account.name) + putString(AccountManager.KEY_ACCOUNT_TYPE, account.type) + putString(AccountManager.KEY_AUTHTOKEN, Constants.NEXTCLOUD_SSO) + putString(Constants.SSO_USER_ID, userId) + putString(Constants.SSO_TOKEN, token) + putString(Constants.SSO_SERVER_URL, serverUrl) + } + + _permissionEvent.emit( + SsoGrantPermissionEvent.PermissionGranted( + bundle = result + ) + ) + } +} diff --git a/app/src/main/java/com/owncloud/android/utils/EncryptionUtils.java b/app/src/main/java/com/owncloud/android/utils/EncryptionUtils.java new file mode 100644 index 000000000..3b4aef112 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/utils/EncryptionUtils.java @@ -0,0 +1,83 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2017 Tobias Kaminsky + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.utils; + +import android.util.Base64; + +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.util.logging.Level; +import java.util.logging.Logger; + +public final class EncryptionUtils { + private static final Logger logger = Logger.getLogger("EncryptionUtils"); + + public static final int saltLength = 40; + + private static final String HASH_DELIMITER = "$"; + + private EncryptionUtils() { + // utility class -> private constructor + } + + /** + * Generate a SHA512 with appended salt + * + * @param token token to be hashed + * @return SHA512 with appended salt, delimiter HASH_DELIMITER + */ + public static String generateSHA512(String token) { + String salt = EncryptionUtils.encodeBytesToBase64String(EncryptionUtils.randomBytes(EncryptionUtils.saltLength)); + + return generateSHA512(token, salt); + } + + /** + * Generate a SHA512 with appended salt + * + * @param token token to be hashed + * @return SHA512 with appended salt, delimiter HASH_DELIMITER + */ + public static String generateSHA512(String token, String salt) { + MessageDigest digest; + String hashedToken = ""; + byte[] hash; + try { + digest = MessageDigest.getInstance("SHA-512"); + digest.update(salt.getBytes()); + hash = digest.digest(token.getBytes()); + + StringBuilder stringBuilder = new StringBuilder(); + for (byte hashByte : hash) { + stringBuilder.append(Integer.toString((hashByte & 0xff) + 0x100, 16).substring(1)); + } + + stringBuilder.append(HASH_DELIMITER).append(salt); + + hashedToken = stringBuilder.toString(); + + } catch (NoSuchAlgorithmException e) { + logger.log(Level.SEVERE, "Generating SHA512 failed", e); + } + + return hashedToken; + } + + public static String encodeBytesToBase64String(byte[] bytes) { + return Base64.encodeToString(bytes, Base64.NO_WRAP); + } + + public static byte[] randomBytes(int size) { + SecureRandom random = new SecureRandom(); + final byte[] iv = new byte[size]; + random.nextBytes(iv); + + return iv; + } +} diff --git a/app/src/main/java/com/owncloud/utils/EncryptionUtils.java b/app/src/main/java/com/owncloud/utils/EncryptionUtils.java deleted file mode 100644 index de6f21b5e..000000000 --- a/app/src/main/java/com/owncloud/utils/EncryptionUtils.java +++ /dev/null @@ -1,1587 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2017 Tobias Kaminsky - * SPDX-FileCopyrightText: 2017 Nextcloud GmbH - * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only - */ -package com.owncloud.android.utils; - -import android.content.Context; -import android.text.TextUtils; -import android.util.Base64; -import android.util.Pair; - -import com.google.common.collect.Lists; -import com.google.gson.Gson; -import com.google.gson.GsonBuilder; -import com.google.gson.reflect.TypeToken; -import com.nextcloud.client.account.User; -import com.owncloud.android.R; -import com.owncloud.android.datamodel.ArbitraryDataProvider; -import com.owncloud.android.datamodel.ArbitraryDataProviderImpl; -import com.owncloud.android.datamodel.EncryptedFiledrop; -import com.owncloud.android.datamodel.FileDataStorageManager; -import com.owncloud.android.datamodel.OCFile; -import com.owncloud.android.datamodel.e2e.v1.decrypted.DecryptedFile; -import com.owncloud.android.datamodel.e2e.v1.decrypted.DecryptedFolderMetadataFileV1; -import com.owncloud.android.datamodel.e2e.v1.decrypted.DecryptedMetadata; -import com.owncloud.android.datamodel.e2e.v1.encrypted.EncryptedFile; -import com.owncloud.android.datamodel.e2e.v1.encrypted.EncryptedFolderMetadataFileV1; -import com.owncloud.android.datamodel.e2e.v2.decrypted.DecryptedFolderMetadataFile; -import com.owncloud.android.datamodel.e2e.v2.decrypted.DecryptedUser; -import com.owncloud.android.datamodel.e2e.v2.encrypted.EncryptedFolderMetadataFile; -import com.owncloud.android.datamodel.e2e.v2.encrypted.EncryptedMetadata; -import com.owncloud.android.lib.common.OwnCloudClient; -import com.owncloud.android.lib.common.operations.RemoteOperationResult; -import com.owncloud.android.lib.common.utils.Log_OC; -import com.owncloud.android.lib.resources.e2ee.GetMetadataRemoteOperation; -import com.owncloud.android.lib.resources.e2ee.LockFileRemoteOperation; -import com.owncloud.android.lib.resources.e2ee.MetadataResponse; -import com.owncloud.android.lib.resources.e2ee.StoreMetadataRemoteOperation; -import com.owncloud.android.lib.resources.e2ee.StoreMetadataV2RemoteOperation; -import com.owncloud.android.lib.resources.e2ee.UnlockFileRemoteOperation; -import com.owncloud.android.lib.resources.e2ee.UnlockFileV1RemoteOperation; -import com.owncloud.android.lib.resources.e2ee.UpdateMetadataRemoteOperation; -import com.owncloud.android.lib.resources.e2ee.UpdateMetadataV2RemoteOperation; -import com.owncloud.android.lib.resources.files.model.ServerFileInterface; -import com.owncloud.android.lib.resources.status.E2EVersion; -import com.owncloud.android.lib.resources.status.NextcloudVersion; -import com.owncloud.android.lib.resources.status.OCCapability; -import com.owncloud.android.lib.resources.status.Problem; -import com.owncloud.android.lib.resources.status.SendClientDiagnosticRemoteOperation; -import com.owncloud.android.operations.UploadException; -import com.owncloud.android.utils.theme.CapabilityUtils; - -import org.apache.commons.httpclient.HttpStatus; - -import java.io.BufferedReader; -import java.io.ByteArrayInputStream; -import java.io.File; -import java.io.FileInputStream; -import java.io.FileOutputStream; -import java.io.IOException; -import java.io.InputStream; -import java.io.InputStreamReader; -import java.math.BigInteger; -import java.nio.charset.StandardCharsets; -import java.security.InvalidAlgorithmParameterException; -import java.security.InvalidKeyException; -import java.security.Key; -import java.security.KeyFactory; -import java.security.KeyPair; -import java.security.KeyPairGenerator; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import java.security.PrivateKey; -import java.security.PublicKey; -import java.security.SecureRandom; -import java.security.cert.CertificateException; -import java.security.cert.CertificateFactory; -import java.security.cert.X509Certificate; -import java.security.interfaces.RSAPrivateCrtKey; -import java.security.interfaces.RSAPublicKey; -import java.security.spec.InvalidKeySpecException; -import java.security.spec.InvalidParameterSpecException; -import java.security.spec.PKCS8EncodedKeySpec; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.UUID; - -import javax.crypto.BadPaddingException; -import javax.crypto.Cipher; -import javax.crypto.CipherOutputStream; -import javax.crypto.IllegalBlockSizeException; -import javax.crypto.KeyGenerator; -import javax.crypto.NoSuchPaddingException; -import javax.crypto.spec.GCMParameterSpec; -import javax.crypto.spec.IvParameterSpec; -import javax.crypto.spec.SecretKeySpec; - -import androidx.annotation.Nullable; -import androidx.annotation.VisibleForTesting; -import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; - -/** - * Utils for encryption - */ -public final class EncryptionUtils { - private static final String TAG = EncryptionUtils.class.getSimpleName(); - - public static final String PUBLIC_KEY = "PUBLIC_KEY"; - public static final String PRIVATE_KEY = "PRIVATE_KEY"; - public static final String MNEMONIC = "MNEMONIC"; - public static final int ivLength = 16; - public static final int saltLength = 40; - public static final String ivDelimiter = "|"; // not base64 encoded - public static final String ivDelimiterOld = "fA=="; // "|" base64 encoded - - private static final char HASH_DELIMITER = '$'; - private static final String AES_CIPHER = "AES/GCM/NoPadding"; - private static final String AES = "AES"; - public static final String RSA_CIPHER = "RSA/ECB/OAEPWithSHA-256AndMGF1Padding"; - public static final String RSA = "RSA"; - @VisibleForTesting - public static final String MIGRATED_FOLDER_IDS = "MIGRATED_FOLDER_IDS"; - - private EncryptionUtils() { - // utility class -> private constructor - } - - /* - JSON - */ - - public static T deserializeJSON(String json, TypeToken type, boolean excludeTransient) { - if (excludeTransient) { - return new Gson().fromJson(json, type.getType()); - } else { - return new GsonBuilder().excludeFieldsWithModifiers(0).create().fromJson(json, type.getType()); - } - } - - public static T deserializeJSON(String json, TypeToken type) { - return deserializeJSON(json, type, false); - } - - public static String serializeJSON(Object data, boolean excludeTransient) { - if (excludeTransient) { - return new GsonBuilder() - .disableHtmlEscaping() - .create() - .toJson(data); - } else { - return new GsonBuilder() - .disableHtmlEscaping() - .excludeFieldsWithModifiers(0) - .create() - .toJson(data); - } - } - - public static void removeFileFromMetadata(String fileName, DecryptedFolderMetadataFileV1 metadata) { - metadata.getFiles().remove(fileName); - } - - public static String serializeJSON(Object data) { - return serializeJSON(data, false); - } - - /* - METADATA - */ - - /** - * Encrypt folder metaData V1 - * - * @param decryptedFolderMetadata folder metaData to encrypt - * @return EncryptedFolderMetadataFile encrypted folder metadata - */ - public static EncryptedFolderMetadataFileV1 encryptFolderMetadata( - DecryptedFolderMetadataFileV1 decryptedFolderMetadata, - String publicKey, - long parentId, - User user, - ArbitraryDataProvider arbitraryDataProvider - ) - throws NoSuchAlgorithmException, InvalidKeyException, - InvalidAlgorithmParameterException, NoSuchPaddingException, BadPaddingException, - IllegalBlockSizeException, CertificateException { - - HashMap files = new HashMap<>(); - HashMap filesdrop = new HashMap<>(); - EncryptedFolderMetadataFileV1 encryptedFolderMetadata = new EncryptedFolderMetadataFileV1(decryptedFolderMetadata - .getMetadata(), - files, - filesdrop); - - // set new metadata key - byte[] metadataKeyBytes = EncryptionUtils.generateKey(); - String encryptedMetadataKey = EncryptionUtils.encryptStringAsymmetric( - EncryptionUtils.encodeBytesToBase64String(metadataKeyBytes), - publicKey); - encryptedFolderMetadata.getMetadata().setMetadataKey(encryptedMetadataKey); - - // store that this folder has been migrated - addIdToMigratedIds(parentId, user, arbitraryDataProvider); - - // Encrypt each file in "files" - for (Map.Entry entry : decryptedFolderMetadata - .getFiles().entrySet()) { - String key = entry.getKey(); - DecryptedFile decryptedFile = entry.getValue(); - - EncryptedFolderMetadataFileV1.EncryptedFile encryptedFile = new EncryptedFolderMetadataFileV1.EncryptedFile(); - encryptedFile.setInitializationVector(decryptedFile.getInitializationVector()); - encryptedFile.setAuthenticationTag(decryptedFile.getAuthenticationTag()); - - // encrypt - String dataJson = EncryptionUtils.serializeJSON(decryptedFile.getEncrypted()); - encryptedFile.setEncrypted(EncryptionUtils.encryptStringSymmetricAsString(dataJson, metadataKeyBytes)); - - files.put(key, encryptedFile); - } - - // set checksum - String mnemonic = arbitraryDataProvider.getValue(user.getAccountName(), EncryptionUtils.MNEMONIC).trim(); - String checksum = EncryptionUtils.generateChecksum(decryptedFolderMetadata, mnemonic); - encryptedFolderMetadata.getMetadata().setChecksum(checksum); - - return encryptedFolderMetadata; - } - - /** - * normally done on server only internal test - */ - @VisibleForTesting - public static void encryptFileDropFiles(DecryptedFolderMetadataFileV1 decryptedFolderMetadata, - EncryptedFolderMetadataFileV1 encryptedFolderMetadata, - String cert) throws NoSuchAlgorithmException, NoSuchPaddingException, - InvalidKeyException, BadPaddingException, IllegalBlockSizeException, CertificateException, - InvalidAlgorithmParameterException { - final Map filesdrop = encryptedFolderMetadata.getFiledrop(); - for (Map.Entry entry : decryptedFolderMetadata - .getFiledrop().entrySet()) { - String key = entry.getKey(); - DecryptedFile decryptedFile = entry.getValue(); - - byte[] byt = generateKey(); - String metadataKey0 = encodeBytesToBase64String(byt); - String enc = encryptStringAsymmetric(metadataKey0, cert); - - String dataJson = EncryptionUtils.serializeJSON(decryptedFile.getEncrypted()); - - String encJson = encryptStringSymmetricAsString(dataJson, byt); - - int delimiterPosition = encJson.lastIndexOf(ivDelimiter); - String encryptedInitializationVector = encJson.substring(delimiterPosition + ivDelimiter.length()); - String encodedCryptedBytes = encJson.substring(0, delimiterPosition); - - - byte[] bytes = decodeStringToBase64Bytes(encodedCryptedBytes); - - // check authentication tag - byte[] extractedAuthenticationTag = Arrays.copyOfRange(bytes, - bytes.length - (128 / 8), - bytes.length); - - String encryptedTag = encodeBytesToBase64String(extractedAuthenticationTag); - - EncryptedFiledrop encryptedFile = new EncryptedFiledrop(encodedCryptedBytes, - decryptedFile.getInitializationVector(), - decryptedFile.getAuthenticationTag(), - enc, - encryptedTag, - encryptedInitializationVector); - - filesdrop.put(key, encryptedFile); - } - } - - /* - * decrypt folder metaData V1 with private key - */ - public static DecryptedFolderMetadataFileV1 decryptFolderMetaData(EncryptedFolderMetadataFileV1 encryptedFolderMetadata, - String privateKey, - ArbitraryDataProvider arbitraryDataProvider, - User user, - long remoteId) - throws NoSuchAlgorithmException, InvalidKeyException, - InvalidAlgorithmParameterException, NoSuchPaddingException, BadPaddingException, - IllegalBlockSizeException, InvalidKeySpecException { - - HashMap files = new HashMap<>(); - DecryptedFolderMetadataFileV1 decryptedFolderMetadata = new DecryptedFolderMetadataFileV1( - encryptedFolderMetadata.getMetadata(), files); - - byte[] decryptedMetadataKey = null; - - String encryptedMetadataKey = decryptedFolderMetadata.getMetadata().getMetadataKey(); - - if (encryptedMetadataKey != null) { - decryptedMetadataKey = decodeStringToBase64Bytes( - decryptStringAsymmetric(encryptedMetadataKey, privateKey)); - } - - if (encryptedFolderMetadata.getFiles() != null) { - for (Map.Entry entry : encryptedFolderMetadata - .getFiles().entrySet()) { - String key = entry.getKey(); - EncryptedFolderMetadataFileV1.EncryptedFile encryptedFile = entry.getValue(); - - DecryptedFile decryptedFile = new DecryptedFile(); - decryptedFile.setInitializationVector(encryptedFile.getInitializationVector()); - decryptedFile.setMetadataKey(encryptedFile.getMetadataKey()); - decryptedFile.setAuthenticationTag(encryptedFile.getAuthenticationTag()); - - if (decryptedMetadataKey == null) { - decryptedMetadataKey = EncryptionUtils.decodeStringToBase64Bytes( - decryptStringAsymmetric(decryptedFolderMetadata.getMetadata() - .getMetadataKeys().get(encryptedFile.getMetadataKey()), - privateKey)); - } - - // decrypt - String dataJson = EncryptionUtils.decryptStringSymmetric(encryptedFile.getEncrypted(), decryptedMetadataKey); - decryptedFile.setEncrypted(EncryptionUtils.deserializeJSON(dataJson, - new TypeToken<>() { - })); - - files.put(key, decryptedFile); - } - } - - // verify checksum - String mnemonic = arbitraryDataProvider.getValue(user.getAccountName(), EncryptionUtils.MNEMONIC).trim(); - String checksum = EncryptionUtils.generateChecksum(decryptedFolderMetadata, mnemonic); - String decryptedFolderChecksum = decryptedFolderMetadata.getMetadata().getChecksum(); - - if (TextUtils.isEmpty(decryptedFolderChecksum) && - isFolderMigrated(remoteId, user, arbitraryDataProvider)) { - reportE2eError(arbitraryDataProvider, user); - throw new IllegalStateException("Possible downgrade attack detected!"); - } - - if (!TextUtils.isEmpty(decryptedFolderChecksum) && !decryptedFolderChecksum.equals(checksum)) { - reportE2eError(arbitraryDataProvider, user); - throw new IllegalStateException("Wrong checksum!"); - } - - Map fileDrop = encryptedFolderMetadata.getFiledrop(); - - if (fileDrop != null) { - for (Map.Entry entry : fileDrop.entrySet()) { - String key = entry.getKey(); - EncryptedFiledrop encryptedFile = entry.getValue(); - - // decrypt key - String encryptedKey = decryptStringAsymmetric(encryptedFile.getEncryptedKey(), - privateKey); - - // decrypt encrypted blob with key - String decryptedData = decryptStringSymmetricAsString( - encryptedFile.getEncrypted(), - decodeStringToBase64Bytes(encryptedKey), - decodeStringToBase64Bytes(encryptedFile.getEncryptedInitializationVector()), - decodeStringToBase64Bytes(encryptedFile.getEncryptedTag()), - arbitraryDataProvider, - user - ); - - DecryptedFile decryptedFile = new DecryptedFile(); - decryptedFile.setInitializationVector(encryptedFile.getInitializationVector()); - decryptedFile.setAuthenticationTag(encryptedFile.getAuthenticationTag()); - - - decryptedFile.setEncrypted(EncryptionUtils.deserializeJSON(decryptedData, - new TypeToken<>() { - })); - - files.put(key, decryptedFile); - - // remove from filedrop - fileDrop.remove(key); - } - } - - return decryptedFolderMetadata; - } - - /** - * Download metadata (v1 or v2) for folder and decrypt it - * - * @return decrypted v2 metadata or null - */ - @SuppressFBWarnings("URV") - public static @Nullable - Object - downloadFolderMetadata(OCFile folder, - OwnCloudClient client, - Context context, - User user - ) { - RemoteOperationResult getMetadataOperationResult = new GetMetadataRemoteOperation(folder.getLocalId()) - .execute(client); - - if (!getMetadataOperationResult.isSuccess()) { - return null; - } - - OCCapability capability = CapabilityUtils.getCapability(context); - - // decrypt metadata - EncryptionUtilsV2 encryptionUtilsV2 = new EncryptionUtilsV2(); - String serializedEncryptedMetadata = getMetadataOperationResult.getResultData().getMetadata(); - - E2EVersion version = determinateVersion(serializedEncryptedMetadata); - - switch (version) { - case UNKNOWN: - Log_OC.e(TAG, "Unknown e2e state"); - return null; - - case V1_0, V1_1, V1_2: - ArbitraryDataProvider arbitraryDataProvider = new ArbitraryDataProviderImpl(context); - String privateKey = arbitraryDataProvider.getValue(user.getAccountName(), EncryptionUtils.PRIVATE_KEY); - String publicKey = arbitraryDataProvider.getValue(user.getAccountName(), EncryptionUtils.PUBLIC_KEY); - EncryptedFolderMetadataFileV1 encryptedFolderMetadata = EncryptionUtils.deserializeJSON( - serializedEncryptedMetadata, new TypeToken<>() { - }); - - try { - DecryptedFolderMetadataFileV1 v1 = decryptFolderMetaData(encryptedFolderMetadata, - privateKey, - arbitraryDataProvider, - user, - folder.getLocalId()); - - if (capability.getEndToEndEncryptionApiVersion().compareTo(E2EVersion.V2_0) >= 0) { - new EncryptionUtilsV2().migrateV1ToV2andUpload( - v1, - client.getUserId(), - publicKey, - folder, - new FileDataStorageManager(user, context.getContentResolver()), - client, - user, - context - ); - } else { - return v1; - } - } catch (Exception e) { - // TODO do not crash, but show meaningful error - Log_OC.e(TAG, "Could not decrypt metadata for " + folder.getDecryptedFileName(), e); - return null; - } - - case V2_0: - return encryptionUtilsV2.parseAnyMetadata(getMetadataOperationResult.getResultData(), - user, - client, - context, - folder); - } - return null; - } - - public static E2EVersion determinateVersion(String metadata) { - try { - EncryptedFolderMetadataFileV1 v1 = EncryptionUtils.deserializeJSON( - metadata, - new TypeToken<>() { - }); - - double version = v1.getMetadata().getVersion(); - - if (version == 1.0) { - return E2EVersion.V1_0; - } else if (version == 1.1) { - return E2EVersion.V1_1; - } else if (version == 1.2) { - return E2EVersion.V1_2; - } else { - throw new IllegalStateException("Unknown version"); - } - } catch (Exception e) { - EncryptedFolderMetadataFile v2 = EncryptionUtils.deserializeJSON( - metadata, - new TypeToken<>() { - }); - - if (v2 != null) { - if ("2.0".equals(v2.getVersion()) || "2".equals(v2.getVersion())) { - return E2EVersion.V2_0; - } - } else { - return E2EVersion.UNKNOWN; - } - } - - return E2EVersion.UNKNOWN; - } - - /* - BASE 64 - */ - @SuppressFBWarnings({"DM", "MDM"}) - public static byte[] encodeStringToBase64Bytes(String string) { - try { - return Base64.encode(string.getBytes(), Base64.NO_WRAP); - } catch (Exception e) { - return new byte[0]; - } - } - - @SuppressFBWarnings({"DM", "MDM"}) - public static String decodeBase64BytesToString(byte[] bytes) { - try { - return new String(Base64.decode(bytes, Base64.NO_WRAP)); - } catch (Exception e) { - return ""; - } - } - - public static String encodeBytesToBase64String(byte[] bytes) { - return Base64.encodeToString(bytes, Base64.NO_WRAP); - } - - @SuppressFBWarnings({"DM", "MDM"}) - public static String encodeStringToBase64String(String string) { - return Base64.encodeToString(string.getBytes(), Base64.NO_WRAP); - } - - @SuppressFBWarnings({"DM", "MDM"}) - public static String decodeBase64StringToString(String string) { - return new String(Base64.decode(string, Base64.NO_WRAP)); - } - - public static byte[] decodeStringToBase64Bytes(String string) { - return Base64.decode(string, Base64.NO_WRAP); - } - - public static EncryptedFile encryptFile(String accountName, File file, Cipher cipher) throws InvalidParameterSpecException, IOException { - File tempEncryptedFolder = FileDataStorageManager.createTempEncryptedFolder(accountName); - File tempEncryptedFile = File.createTempFile(file.getName(), null, tempEncryptedFolder); - encryptFileWithGivenCipher(file, tempEncryptedFile, cipher); - String authenticationTagString = getAuthenticationTag(cipher); - return new EncryptedFile(tempEncryptedFile, authenticationTagString); - } - - public static String getAuthenticationTag(Cipher cipher) throws InvalidParameterSpecException { - byte[] authenticationTag = cipher.getParameters().getParameterSpec(GCMParameterSpec.class).getIV(); - return encodeBytesToBase64String(authenticationTag); - } - - public static Cipher getCipher(int mode, byte[] encryptionKeyBytes, byte[] iv) throws NoSuchPaddingException, NoSuchAlgorithmException, InvalidAlgorithmParameterException, InvalidKeyException { - Cipher cipher = Cipher.getInstance(AES_CIPHER); - Key key = new SecretKeySpec(encryptionKeyBytes, AES); - GCMParameterSpec spec = new GCMParameterSpec(128, iv); - cipher.init(mode, key, spec); - return cipher; - } - - public static void encryptFileWithGivenCipher(File inputFile, File encryptedFile, Cipher cipher) { - try (FileInputStream inputStream = new FileInputStream(inputFile); - FileOutputStream fileOutputStream = new FileOutputStream(encryptedFile); - CipherOutputStream outputStream = new CipherOutputStream(fileOutputStream, cipher)) { - byte[] buffer = new byte[4096]; - int bytesRead; - - while ((bytesRead = inputStream.read(buffer)) != -1) { - outputStream.write(buffer, 0, bytesRead); - } - - outputStream.close(); - inputStream.close(); - - Log_OC.d(TAG, encryptedFile.getName() + "encrypted successfully"); - } catch (IOException exception) { - Log_OC.d(TAG, "Error caught at encryptFileWithGivenCipher(): " + exception.getLocalizedMessage()); - } - } - - public static void decryptFile(Cipher cipher, - File encryptedFile, - File decryptedFile, - String authenticationTag, - ArbitraryDataProvider arbitraryDataProvider, - User user) { - try (FileInputStream inputStream = new FileInputStream(encryptedFile); - FileOutputStream outputStream = new FileOutputStream(decryptedFile)) { - - byte[] buffer = new byte[4096]; - int bytesRead; - while ((bytesRead = inputStream.read(buffer)) != -1) { - byte[] output = cipher.update(buffer, 0, bytesRead); - if (output != null) { - outputStream.write(output); - } - } - byte[] output = cipher.doFinal(); - if (output != null) { - outputStream.write(output); - } - inputStream.close(); - outputStream.close(); - - if (!getAuthenticationTag(cipher).equals(authenticationTag)) { - reportE2eError(arbitraryDataProvider, user); - throw new SecurityException("Tag not correct"); - } - - Log_OC.d(TAG, encryptedFile.getName() + "decrypted successfully"); - } catch (IOException | BadPaddingException | IllegalBlockSizeException | InvalidParameterSpecException | - SecurityException exception) { - Log_OC.d(TAG, "Error caught at decryptFile(): " + exception.getLocalizedMessage()); - } - } - - /** - * Encrypt string with RSA algorithm, ECB mode, OAEPWithSHA-256AndMGF1 padding Asymmetric encryption, with private - * and public key - * - * @param string String to encrypt - * @param cert contains public key in it - * @return encrypted string - */ - public static String encryptStringAsymmetric(String string, String cert) - throws NoSuchAlgorithmException, - NoSuchPaddingException, InvalidKeyException, - BadPaddingException, IllegalBlockSizeException, - CertificateException { - - Cipher cipher = Cipher.getInstance(RSA_CIPHER); - - String trimmedCert = cert.replace("-----BEGIN CERTIFICATE-----\n", "") - .replace("-----END CERTIFICATE-----\n", ""); - byte[] encodedCert = trimmedCert.getBytes(StandardCharsets.UTF_8); - byte[] decodedCert = org.apache.commons.codec.binary.Base64.decodeBase64(encodedCert); - - CertificateFactory certFactory = CertificateFactory.getInstance("X.509"); - InputStream in = new ByteArrayInputStream(decodedCert); - X509Certificate certificate = (X509Certificate) certFactory.generateCertificate(in); - PublicKey realPublicKey = certificate.getPublicKey(); - - cipher.init(Cipher.ENCRYPT_MODE, realPublicKey); - - byte[] bytes = encodeStringToBase64Bytes(string); - byte[] cryptedBytes = cipher.doFinal(bytes); - - return encodeBytesToBase64String(cryptedBytes); - } - - public static String encryptStringAsymmetricV2(byte[] bytes, String cert) - throws NoSuchAlgorithmException, - NoSuchPaddingException, InvalidKeyException, - BadPaddingException, IllegalBlockSizeException, - CertificateException { - - Cipher cipher = Cipher.getInstance(RSA_CIPHER); - - String trimmedCert = cert.replace("-----BEGIN CERTIFICATE-----\n", "") - .replace("-----END CERTIFICATE-----\n", ""); - byte[] encodedCert = trimmedCert.getBytes(StandardCharsets.UTF_8); - byte[] decodedCert = org.apache.commons.codec.binary.Base64.decodeBase64(encodedCert); - - CertificateFactory certFactory = CertificateFactory.getInstance("X.509"); - InputStream in = new ByteArrayInputStream(decodedCert); - X509Certificate certificate = (X509Certificate) certFactory.generateCertificate(in); - PublicKey realPublicKey = certificate.getPublicKey(); - - cipher.init(Cipher.ENCRYPT_MODE, realPublicKey); - - byte[] cryptedBytes = cipher.doFinal(bytes); - - return encodeBytesToBase64String(cryptedBytes); - } - - public static String encryptStringAsymmetric(String string, PublicKey publicKey) throws NoSuchPaddingException, - NoSuchAlgorithmException, InvalidKeyException, BadPaddingException, IllegalBlockSizeException { - Cipher cipher = Cipher.getInstance(RSA_CIPHER); - cipher.init(Cipher.ENCRYPT_MODE, publicKey); - - byte[] bytes = encodeStringToBase64Bytes(string); - byte[] cryptedBytes = cipher.doFinal(bytes); - - return encodeBytesToBase64String(cryptedBytes); - } - - - /** - * Decrypt string with RSA algorithm, ECB mode, OAEPWithSHA-256AndMGF1 padding Asymmetric encryption, with private - * and public key - * - * @param string string to decrypt - * @param privateKeyString private key - * @return decrypted string - */ - public static String decryptStringAsymmetric(String string, String privateKeyString) - throws NoSuchAlgorithmException, - NoSuchPaddingException, InvalidKeyException, - BadPaddingException, IllegalBlockSizeException, - InvalidKeySpecException { - - Cipher cipher = Cipher.getInstance(RSA_CIPHER); - - byte[] privateKeyBytes = decodeStringToBase64Bytes(privateKeyString); - PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(privateKeyBytes); - KeyFactory kf = KeyFactory.getInstance(RSA); - PrivateKey privateKey = kf.generatePrivate(keySpec); - - cipher.init(Cipher.DECRYPT_MODE, privateKey); - - byte[] bytes = decodeStringToBase64Bytes(string); - byte[] encodedBytes = cipher.doFinal(bytes); - - return decodeBase64BytesToString(encodedBytes); - } - - public static byte[] decryptStringAsymmetricAsBytes(String string, String privateKeyString) - throws NoSuchAlgorithmException, - NoSuchPaddingException, InvalidKeyException, - BadPaddingException, IllegalBlockSizeException, - InvalidKeySpecException { - - Cipher cipher = Cipher.getInstance(RSA_CIPHER); - - byte[] privateKeyBytes = decodeStringToBase64Bytes(privateKeyString); - PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(privateKeyBytes); - KeyFactory kf = KeyFactory.getInstance(RSA); - PrivateKey privateKey = kf.generatePrivate(keySpec); - - cipher.init(Cipher.DECRYPT_MODE, privateKey); - - byte[] bytes = decodeStringToBase64Bytes(string); - - return cipher.doFinal(bytes); - } - - public static byte[] decryptStringAsymmetricV2(String string, String privateKeyString) - throws NoSuchAlgorithmException, - NoSuchPaddingException, InvalidKeyException, - BadPaddingException, IllegalBlockSizeException, - InvalidKeySpecException { - - Cipher cipher = Cipher.getInstance(RSA_CIPHER); - - byte[] privateKeyBytes = decodeStringToBase64Bytes(privateKeyString); - PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(privateKeyBytes); - KeyFactory kf = KeyFactory.getInstance(RSA); - PrivateKey privateKey = kf.generatePrivate(keySpec); - - cipher.init(Cipher.DECRYPT_MODE, privateKey); - - byte[] bytes; - try { - bytes = decodeStringToBase64Bytes(string); - } catch (Exception e) { - bytes = encodeStringToBase64Bytes(string); - } - - return cipher.doFinal(bytes); - } - - /** - * Decrypt string with RSA algorithm, ECB mode, OAEPWithSHA-256AndMGF1 padding Asymmetric encryption, with private - * and public key - * - * @param string string to decrypt - * @param privateKey private key - * @return decrypted string - */ - public static String decryptStringAsymmetric(String string, PrivateKey privateKey) throws NoSuchPaddingException, NoSuchAlgorithmException, InvalidKeyException, BadPaddingException, IllegalBlockSizeException { - Cipher cipher = Cipher.getInstance(RSA_CIPHER); - cipher.init(Cipher.DECRYPT_MODE, privateKey); - - byte[] bytes = decodeStringToBase64Bytes(string); - byte[] encodedBytes = cipher.doFinal(bytes); - - return decodeBase64BytesToString(encodedBytes); - } - - /** - * Decrypt string with AES/GCM/NoPadding - * - * @param string string to decrypt - * @param encryptionKeyBytes key from metadata - * @return decrypted string - */ - public static String encryptStringSymmetricAsString(String string, byte[] encryptionKeyBytes) - throws NoSuchPaddingException, - InvalidKeyException, - NoSuchAlgorithmException, - IllegalBlockSizeException, - BadPaddingException, - InvalidAlgorithmParameterException { - EncryptedMetadata metadata = encryptStringSymmetric(string, encryptionKeyBytes, ivDelimiter); - - return metadata.getCiphertext(); - } - - @VisibleForTesting - public static String encryptStringSymmetricAsStringOld(String string, byte[] encryptionKeyBytes) - throws NoSuchPaddingException, - InvalidKeyException, - NoSuchAlgorithmException, - IllegalBlockSizeException, - BadPaddingException, - InvalidAlgorithmParameterException { - EncryptedMetadata metadata = encryptStringSymmetric(string, encryptionKeyBytes, ivDelimiterOld); - - return metadata.getCiphertext(); - } - - // /** -// * Encrypt string with AES/GCM/NoPadding -// * -// * @param string string to encrypt -// * @param encryptionKeyBytes key from metadata -// * @return decrypted string -// */ -// private static String encryptStringSymmetric(String string, -// byte[] encryptionKeyBytes, -// String delimiter) -// throws NoSuchAlgorithmException, -// InvalidAlgorithmParameterException, -// NoSuchPaddingException, -// InvalidKeyException, -// BadPaddingException, -// IllegalBlockSizeException { -// -// Cipher cipher = Cipher.getInstance(AES_CIPHER); -// byte[] iv = randomBytes(ivLength); -// -// Key key = new SecretKeySpec(encryptionKeyBytes, AES); -// GCMParameterSpec spec = new GCMParameterSpec(128, iv); -// cipher.init(Cipher.ENCRYPT_MODE, key, spec); -// -// byte[] bytes = encodeStringToBase64Bytes(string); -// byte[] cryptedBytes = cipher.doFinal(bytes); -// -// String encodedCryptedBytes = encodeBytesToBase64String(cryptedBytes); -// String encodedIV = encodeBytesToBase64String(iv); -// -// return encodedCryptedBytes + delimiter + encodedIV; -// } - public static String decryptStringSymmetricAsString(String string, - byte[] encryptionKeyBytes, - byte[] iv, - byte[] authenticationTag, - ArbitraryDataProvider arbitraryDataProvider, - User user - ) throws InvalidAlgorithmParameterException, NoSuchPaddingException, IllegalBlockSizeException, NoSuchAlgorithmException, BadPaddingException, InvalidKeyException { - return decryptStringSymmetricAsString( - decodeStringToBase64Bytes(string), - encryptionKeyBytes, - iv, - authenticationTag, - false, - arbitraryDataProvider, - user); - } - - public static String decryptStringSymmetricAsString(String string, - byte[] encryptionKeyBytes, - byte[] iv, - byte[] authenticationTag, - boolean fileDropV2, - ArbitraryDataProvider arbitraryDataProvider, - User user) throws InvalidAlgorithmParameterException, NoSuchPaddingException, IllegalBlockSizeException, NoSuchAlgorithmException, BadPaddingException, InvalidKeyException { - - return decryptStringSymmetricAsString( - decodeStringToBase64Bytes(string), - encryptionKeyBytes, - iv, - authenticationTag, - fileDropV2, - arbitraryDataProvider, - user); - } - - public static String decryptStringSymmetricAsString(byte[] bytes, - byte[] encryptionKeyBytes, - byte[] iv, - byte[] authenticationTag, - boolean fileDropV2, - ArbitraryDataProvider arbitraryDataProvider, - User user) - throws NoSuchPaddingException, - NoSuchAlgorithmException, - InvalidAlgorithmParameterException, - InvalidKeyException, - IllegalBlockSizeException, - BadPaddingException { - Cipher cipher = Cipher.getInstance(AES_CIPHER); - Key key = new SecretKeySpec(encryptionKeyBytes, AES); - GCMParameterSpec spec = new GCMParameterSpec(128, iv); - cipher.init(Cipher.DECRYPT_MODE, key, spec); - - - // check authentication tag - byte[] extractedAuthenticationTag = Arrays.copyOfRange(bytes, - bytes.length - (128 / 8), - bytes.length); - - if (!Arrays.equals(extractedAuthenticationTag, authenticationTag)) { - reportE2eError(arbitraryDataProvider, user); - throw new SecurityException("Tag not correct"); - } - - byte[] encodedBytes = cipher.doFinal(bytes); - - if (fileDropV2) { - return new EncryptionUtilsV2().gZipDecompress(encodedBytes); - } else { - return decodeBase64BytesToString(encodedBytes); - } - } - - public static EncryptedMetadata encryptStringSymmetric( - String string, - byte[] encryptionKeyBytes, - String delimiter) throws InvalidAlgorithmParameterException, NoSuchPaddingException, IllegalBlockSizeException, NoSuchAlgorithmException, BadPaddingException, InvalidKeyException { - - byte[] bytes = encodeStringToBase64Bytes(string); - - return encryptStringSymmetric(bytes, encryptionKeyBytes, delimiter); - } - - /** - * Encrypt string with AES/GCM/NoPadding - * - * @param bytes byte array - * @param encryptionKeyBytes key from metadata - * @return decrypted string - */ - public static EncryptedMetadata encryptStringSymmetric( - byte[] bytes, - byte[] encryptionKeyBytes, - String delimiter) - throws NoSuchAlgorithmException, - InvalidAlgorithmParameterException, - NoSuchPaddingException, - InvalidKeyException, - BadPaddingException, - IllegalBlockSizeException { - - Cipher cipher = Cipher.getInstance(AES_CIPHER); - byte[] iv = randomBytes(ivLength); - - Key key = new SecretKeySpec(encryptionKeyBytes, AES); - GCMParameterSpec spec = new GCMParameterSpec(128, iv); - cipher.init(Cipher.ENCRYPT_MODE, key, spec); - - byte[] cryptedBytes = cipher.doFinal(bytes); - - String encodedCryptedBytes = encodeBytesToBase64String(cryptedBytes); - String encodedIV = encodeBytesToBase64String(iv); - String authenticationTag = encodeBytesToBase64String(Arrays.copyOfRange(cryptedBytes, - cryptedBytes.length - (128 / 8), - cryptedBytes.length)); - - return new EncryptedMetadata(encodedCryptedBytes + delimiter + encodedIV, encodedIV, authenticationTag); - } - - /** - * Decrypt string with RSA algorithm, ECB mode, OAEPWithSHA-256AndMGF1 padding Asymmetric encryption, with private - * and public key - * - * @param string string to decrypt - * @param encryptionKeyBytes key from metadata - * @return decrypted string - */ - public static String decryptStringSymmetric(String string, byte[] encryptionKeyBytes) - throws NoSuchAlgorithmException, - InvalidAlgorithmParameterException, NoSuchPaddingException, InvalidKeyException, - BadPaddingException, IllegalBlockSizeException { - - Cipher cipher = Cipher.getInstance(AES_CIPHER); - - String ivString; - int delimiterPosition = string.lastIndexOf(ivDelimiter); - - if (delimiterPosition == -1) { - // backward compatibility - delimiterPosition = string.lastIndexOf(ivDelimiterOld); - ivString = string.substring(delimiterPosition + ivDelimiterOld.length()); - } else { - ivString = string.substring(delimiterPosition + ivDelimiter.length()); - } - - String cipherString = string.substring(0, delimiterPosition); - - byte[] iv = new IvParameterSpec(decodeStringToBase64Bytes(ivString)).getIV(); - - Key key = new SecretKeySpec(encryptionKeyBytes, AES); - - GCMParameterSpec spec = new GCMParameterSpec(128, iv); - cipher.init(Cipher.DECRYPT_MODE, key, spec); - - byte[] bytes = decodeStringToBase64Bytes(cipherString); - byte[] encodedBytes = cipher.doFinal(bytes); - - return decodeBase64BytesToString(encodedBytes); - } - - /** - * Decrypt string with AES/GCM/NoPadding - * - * @param string string to decrypt - * @param encryptionKeyBytes key from metadata - * @param authenticationTag auth tag to check - * @return decrypted string - */ - public static byte[] decryptStringSymmetric(String string, - byte[] encryptionKeyBytes, - String authenticationTag, - String ivString) - throws NoSuchAlgorithmException, - InvalidAlgorithmParameterException, NoSuchPaddingException, InvalidKeyException, - BadPaddingException, IllegalBlockSizeException { - - Cipher cipher = Cipher.getInstance(AES_CIPHER); - - int delimiterPosition = string.lastIndexOf(ivDelimiter); - - String cipherString; - if (delimiterPosition == -1) { - cipherString = string; - } else { - cipherString = string.substring(0, delimiterPosition); - } - - byte[] iv = new IvParameterSpec(decodeStringToBase64Bytes(ivString)).getIV(); - - Key key = new SecretKeySpec(encryptionKeyBytes, AES); - - GCMParameterSpec spec = new GCMParameterSpec(128, iv); - cipher.init(Cipher.DECRYPT_MODE, key, spec); - - byte[] bytes = decodeStringToBase64Bytes(cipherString); - - // check authentication tag - if (authenticationTag != null) { - byte[] authenticationTagBytes = decodeStringToBase64Bytes(authenticationTag); - byte[] extractedAuthenticationTag = Arrays.copyOfRange(bytes, - bytes.length - (128 / 8), - bytes.length); - - if (!Arrays.equals(extractedAuthenticationTag, authenticationTagBytes)) { - throw new SecurityException("Tag not correct"); - } - } - - return cipher.doFinal(bytes); - } - - public static String privateKeyToPEM(PrivateKey privateKey) { - String privateKeyString = encodeBytesToBase64String(privateKey.getEncoded()); - - return "-----BEGIN PRIVATE KEY-----\n" + privateKeyString.replaceAll("(.{65})", "$1\n") - + "\n-----END PRIVATE KEY-----"; - } - - public static PrivateKey PEMtoPrivateKey(String pem) throws NoSuchAlgorithmException, InvalidKeySpecException { - byte[] privateKeyBytes = EncryptionUtils.decodeStringToBase64Bytes(pem); - PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(privateKeyBytes); - KeyFactory kf = KeyFactory.getInstance(EncryptionUtils.RSA); - return kf.generatePrivate(keySpec); - } - - /* - Helper - */ - - public static ArrayList getRandomWords(int count, Context context) throws IOException { - InputStream ins = context.getResources().openRawResource(R.raw.encryption_key_words); - - InputStreamReader inputStreamReader = new InputStreamReader(ins); - - BufferedReader bufferedReader = new BufferedReader(inputStreamReader); - - List lines = new ArrayList<>(); - String line; - while ((line = bufferedReader.readLine()) != null) { - lines.add(line); - } - - SecureRandom random = new SecureRandom(); - - ArrayList outputLines = Lists.newArrayListWithCapacity(count); - for (int i = 0; i < count; i++) { - int randomLine = random.nextInt(lines.size()); - outputLines.add(lines.get(randomLine)); - } - - return outputLines; - } - - /** - * Generates private/public key pair, used for asymmetric encryption - * - * @return KeyPair - */ - public static KeyPair generateKeyPair() throws NoSuchAlgorithmException { - KeyPairGenerator keyGen = KeyPairGenerator.getInstance(RSA); - keyGen.initialize(2048, new SecureRandom()); - return keyGen.generateKeyPair(); - } - - /** - * Generates key for symmetric encryption - * - * @return byte[] byteArray of key - */ - public static byte[] generateKey() { - KeyGenerator keyGenerator; - try { - keyGenerator = KeyGenerator.getInstance(AES); - keyGenerator.init(128); - - return keyGenerator.generateKey().getEncoded(); - } catch (NoSuchAlgorithmException e) { - Log_OC.e(TAG, e.getMessage()); - } - - return null; - } - - /** - * Generates key for symmetric encryption - * - * @return String String base64 encoded key - */ - public static String generateKeyString() { - return EncryptionUtils.encodeBytesToBase64String(generateKey()); - } - - public static byte[] randomBytes(int size) { - SecureRandom random = new SecureRandom(); - final byte[] iv = new byte[size]; - random.nextBytes(iv); - - return iv; - } - - /** - * Generate a SHA512 with appended salt - * - * @param token token to be hashed - * @return SHA512 with appended salt, delimiter HASH_DELIMITER - */ - public static String generateSHA512(String token) { - String salt = EncryptionUtils.encodeBytesToBase64String(EncryptionUtils.randomBytes(EncryptionUtils.saltLength)); - - return generateSHA512(token, salt); - } - - /** - * Generate a SHA512 with appended salt - * - * @param token token to be hashed - * @return SHA512 with appended salt, delimiter HASH_DELIMITER - */ - public static String generateSHA512(String token, String salt) { - MessageDigest digest; - String hashedToken = ""; - byte[] hash; - try { - digest = MessageDigest.getInstance("SHA-512"); - digest.update(salt.getBytes()); - hash = digest.digest(token.getBytes()); - - StringBuilder stringBuilder = new StringBuilder(); - for (byte hashByte : hash) { - stringBuilder.append(Integer.toString((hashByte & 0xff) + 0x100, 16).substring(1)); - } - - stringBuilder.append(HASH_DELIMITER).append(salt); - - hashedToken = stringBuilder.toString(); - - } catch (NoSuchAlgorithmException e) { - Log_OC.e(TAG, "Generating SHA512 failed", e); - } - - return hashedToken; - } - - public static boolean verifySHA512(String hashWithSalt, String compareToken) { - String salt = hashWithSalt.split("\\" + HASH_DELIMITER)[1]; - - String newHash = generateSHA512(compareToken, salt); - - return hashWithSalt.equals(newHash); - } - - public static String lockFolder(ServerFileInterface parentFile, OwnCloudClient client) throws UploadException { - return lockFolder(parentFile, client, -1); - } - - public static String lockFolder(ServerFileInterface parentFile, OwnCloudClient client, long counter) throws UploadException { - // Lock folder - LockFileRemoteOperation lockFileOperation = new LockFileRemoteOperation(parentFile.getLocalId(), - counter); - RemoteOperationResult lockFileOperationResult = lockFileOperation.execute(client); - - if (lockFileOperationResult.isSuccess() && - !TextUtils.isEmpty(lockFileOperationResult.getResultData())) { - return lockFileOperationResult.getResultData(); - } else if (lockFileOperationResult.getHttpCode() == HttpStatus.SC_FORBIDDEN) { - throw new UploadException("Forbidden! Please try again later.)"); - } else { - throw new UploadException("Could not lock folder"); - } - } - - /** - * @param parentFile file metadata should be retrieved for - * @return Pair: boolean: true: metadata already exists, false: metadata new created - */ - public static Pair retrieveMetadataV1(OCFile parentFile, - OwnCloudClient client, - String privateKey, - String publicKey, - ArbitraryDataProvider arbitraryDataProvider, - User user) - throws UploadException, - InvalidAlgorithmParameterException, NoSuchAlgorithmException, NoSuchPaddingException, BadPaddingException, - IllegalBlockSizeException, InvalidKeyException, InvalidKeySpecException, CertificateException { - long localId = parentFile.getLocalId(); - - GetMetadataRemoteOperation getMetadataOperation = new GetMetadataRemoteOperation(localId); - RemoteOperationResult getMetadataOperationResult = getMetadataOperation.execute(client); - - DecryptedFolderMetadataFileV1 metadata; - - if (getMetadataOperationResult.isSuccess()) { - // decrypt metadata - String serializedEncryptedMetadata = getMetadataOperationResult.getResultData().getMetadata(); - - - EncryptedFolderMetadataFileV1 encryptedFolderMetadata = EncryptionUtils.deserializeJSON( - serializedEncryptedMetadata, new TypeToken<>() { - }); - - return new Pair<>(Boolean.TRUE, decryptFolderMetaData(encryptedFolderMetadata, - privateKey, - arbitraryDataProvider, - user, - localId)); - - } else if (getMetadataOperationResult.getHttpCode() == HttpStatus.SC_NOT_FOUND) { - // TODO extract - // new metadata - metadata = new DecryptedFolderMetadataFileV1(); - metadata.setMetadata(new DecryptedMetadata()); - metadata.getMetadata().setVersion(Double.parseDouble(E2EVersion.V1_2.getValue())); - metadata.getMetadata().setMetadataKeys(new HashMap<>()); - String metadataKey = EncryptionUtils.encodeBytesToBase64String(EncryptionUtils.generateKey()); - String encryptedMetadataKey = EncryptionUtils.encryptStringAsymmetric(metadataKey, publicKey); - metadata.getMetadata().setMetadataKey(encryptedMetadataKey); - - return new Pair<>(Boolean.FALSE, metadata); - } else { - // TODO E2E: error - throw new UploadException("something wrong"); - } - } - - /** - * @param parentFile file metadata should be retrieved for - * @return Pair: boolean: true: metadata already exists, false: metadata new created - */ - public static Pair retrieveMetadata(OCFile parentFile, - OwnCloudClient client, - String privateKey, - String publicKey, - FileDataStorageManager storageManager, - User user, - Context context, - ArbitraryDataProvider arbitraryDataProvider) - throws Throwable { - long localId = parentFile.getLocalId(); - - GetMetadataRemoteOperation getMetadataOperation = new GetMetadataRemoteOperation(localId); - RemoteOperationResult getMetadataOperationResult = getMetadataOperation.execute(client); - - - DecryptedFolderMetadataFile metadata; - - if (getMetadataOperationResult.isSuccess()) { - // decrypt metadata - String serializedEncryptedMetadata = getMetadataOperationResult.getResultData().getMetadata(); - - - EncryptedFolderMetadataFile encryptedFolderMetadata = EncryptionUtils.deserializeJSON( - serializedEncryptedMetadata, new TypeToken<>() { - }); - - return new Pair<>(Boolean.TRUE, - new EncryptionUtilsV2().decryptFolderMetadataFile(encryptedFolderMetadata, - client.getUserId(), - privateKey, - parentFile, - storageManager, - client, - parentFile.getE2eCounter(), - getMetadataOperationResult.getResultData().getSignature(), - user, - context, - arbitraryDataProvider) - ); - - } else if (getMetadataOperationResult.getHttpCode() == HttpStatus.SC_NOT_FOUND || - getMetadataOperationResult.getHttpCode() == HttpStatus.SC_INTERNAL_SERVER_ERROR) { - // new metadata - metadata = new DecryptedFolderMetadataFile(new com.owncloud.android.datamodel.e2e.v2.decrypted.DecryptedMetadata(), - new ArrayList<>(), - new HashMap<>(), - E2EVersion.V2_0.getValue()); - metadata.getUsers().add(new DecryptedUser(client.getUserId(), publicKey, null)); - byte[] metadataKey = EncryptionUtils.generateKey(); - - if (metadataKey == null) { - throw new UploadException("Could not encrypt folder!"); - } - - metadata.getMetadata().setMetadataKey(metadataKey); - metadata.getMetadata().getKeyChecksums().add(new EncryptionUtilsV2().hashMetadataKey(metadataKey)); - - return new Pair<>(Boolean.FALSE, metadata); - } else { - reportE2eError(arbitraryDataProvider, user); - throw new UploadException("something wrong"); - } - } - - public static void uploadMetadata(ServerFileInterface parentFile, - String serializedFolderMetadata, - String token, - OwnCloudClient client, - boolean metadataExists, - E2EVersion version, - String signature, - ArbitraryDataProvider arbitraryDataProvider, - User user) throws UploadException { - RemoteOperationResult uploadMetadataOperationResult; - if (metadataExists) { - // update metadata - if (version == E2EVersion.V2_0) { - uploadMetadataOperationResult = new UpdateMetadataV2RemoteOperation( - parentFile.getRemoteId(), - serializedFolderMetadata, - token, - signature) - .execute(client); - } else { - uploadMetadataOperationResult = new UpdateMetadataRemoteOperation( - parentFile.getLocalId(), - serializedFolderMetadata, - token) - .execute(client); - } - } else { - // store metadata - if (version == E2EVersion.V2_0) { - uploadMetadataOperationResult = new StoreMetadataV2RemoteOperation( - parentFile.getRemoteId(), - serializedFolderMetadata, - token, - signature - ) - .execute(client); - } else { - uploadMetadataOperationResult = new StoreMetadataRemoteOperation( - parentFile.getLocalId(), - serializedFolderMetadata - ) - .execute(client); - } - } - - if (!uploadMetadataOperationResult.isSuccess()) { - reportE2eError(arbitraryDataProvider, user); - throw new UploadException("Storing/updating metadata was not successful"); - } - } - - public static RemoteOperationResult unlockFolder(ServerFileInterface parentFolder, OwnCloudClient client, String token) { - if (token != null) { - return new UnlockFileRemoteOperation(parentFolder.getLocalId(), token).execute(client); - } else { - return new RemoteOperationResult<>(new Exception("No token available")); - } - } - - public static RemoteOperationResult unlockFolderV1(ServerFileInterface parentFolder, OwnCloudClient client, String token) { - if (token != null) { - return new UnlockFileV1RemoteOperation(parentFolder.getLocalId(), token).execute(client); - } else { - return new RemoteOperationResult<>(new Exception("No token available")); - } - } - - public static X509Certificate convertCertFromString(String string) throws CertificateException { - String trimmedCert = string.replace("-----BEGIN CERTIFICATE-----\n", "") - .replace("-----END CERTIFICATE-----\n", ""); - byte[] encodedCert = trimmedCert.getBytes(StandardCharsets.UTF_8); - byte[] decodedCert = org.apache.commons.codec.binary.Base64.decodeBase64(encodedCert); - - CertificateFactory certFactory = CertificateFactory.getInstance("X.509"); - InputStream in = new ByteArrayInputStream(decodedCert); - return (X509Certificate) certFactory.generateCertificate(in); - } - - public static RSAPublicKey convertPublicKeyFromString(String string) throws CertificateException { - return (RSAPublicKey) convertCertFromString(string).getPublicKey(); - } - - public static void removeE2E(ArbitraryDataProvider arbitraryDataProvider, User user) { - // delete stored E2E keys and mnemonic - arbitraryDataProvider.deleteKeyForAccount(user.getAccountName(), EncryptionUtils.PRIVATE_KEY); - arbitraryDataProvider.deleteKeyForAccount(user.getAccountName(), EncryptionUtils.PUBLIC_KEY); - arbitraryDataProvider.deleteKeyForAccount(user.getAccountName(), EncryptionUtils.MNEMONIC); - } - - public static boolean isMatchingKeys(KeyPair keyPair, String publicKeyString) throws CertificateException { - // check key - RSAPrivateCrtKey privateKey = (RSAPrivateCrtKey) keyPair.getPrivate(); - RSAPublicKey publicKey = EncryptionUtils.convertPublicKeyFromString(publicKeyString); - - BigInteger modulusPublic = publicKey.getModulus(); - BigInteger modulusPrivate = privateKey.getModulus(); - - return modulusPrivate.compareTo(modulusPublic) == 0; - } - - public static boolean supportsSecureFiledrop(OCFile file, User user) { - return file.isEncrypted() && - file.isFolder() && - user.getServer().getVersion().isNewerOrEqual(NextcloudVersion.nextcloud_26); - } - - public static String generateChecksum(DecryptedFolderMetadataFileV1 metadataFile, - String mnemonic) throws NoSuchAlgorithmException { - StringBuilder stringBuilder = new StringBuilder(); - stringBuilder.append(mnemonic.replaceAll(" ", "")); - - ArrayList keys = new ArrayList<>(metadataFile.getFiles().keySet()); - Collections.sort(keys); - - for (String key : keys) { - stringBuilder.append(key); - } - - stringBuilder.append(metadataFile.getMetadata().getMetadataKey()); - - // sha256 hash-sum - return sha256(stringBuilder.toString()); - } - - /** - * SHA-256 hash of metadata-key - */ - public static String sha256(String string) throws NoSuchAlgorithmException { - byte[] bytes = MessageDigest - .getInstance("SHA-256") - .digest(string.getBytes(StandardCharsets.UTF_8)); - - return bytesToHex(bytes); - } - - public static String bytesToHex(byte[] bytes) { - StringBuilder result = new StringBuilder(); - for (byte individualByte : bytes) { - result.append(Integer.toString((individualByte & 0xff) + 0x100, 16) - .substring(1)); - } - return result.toString(); - } - - public static void addIdToMigratedIds(long id, - User user, - ArbitraryDataProvider arbitraryDataProvider) { - Gson gson = new Gson(); - String ids = arbitraryDataProvider.getValue(user, MIGRATED_FOLDER_IDS); - - ArrayList arrayList = gson.fromJson(ids, ArrayList.class); - - if (arrayList == null) { - arrayList = new ArrayList<>(); - } - - if (arrayList.contains(id)) { - // nothing to do here - return; - } - - arrayList.add(id); - - String json = gson.toJson(arrayList); - arbitraryDataProvider.storeOrUpdateKeyValue(user.getAccountName(), - MIGRATED_FOLDER_IDS, - json); - } - - public static boolean isFolderMigrated(long id, - User user, - ArbitraryDataProvider arbitraryDataProvider) { - Gson gson = new Gson(); - String ids = arbitraryDataProvider.getValue(user, MIGRATED_FOLDER_IDS); - - ArrayList arrayList = gson.fromJson(ids, new TypeToken>() { - }.getType()); - - if (arrayList == null) { - return false; - } - - return arrayList.contains(id); - } - - public static void reportE2eError(ArbitraryDataProvider arbitraryDataProvider, User user) { - arbitraryDataProvider.incrementValue(user.getAccountName(), ArbitraryDataProvider.E2E_ERRORS); - - if (arbitraryDataProvider.getLongValue(user.getAccountName(), - ArbitraryDataProvider.E2E_ERRORS_TIMESTAMP) == -1L) { - arbitraryDataProvider.storeOrUpdateKeyValue( - user.getAccountName(), - ArbitraryDataProvider.E2E_ERRORS_TIMESTAMP, - System.currentTimeMillis() / 1000 - ); - } - } - - @Nullable - public static Problem readE2eError(ArbitraryDataProvider arbitraryDataProvider, User user) { - int value = arbitraryDataProvider.getIntegerValue(user.getAccountName(), - ArbitraryDataProvider.E2E_ERRORS); - long timestamp = arbitraryDataProvider.getLongValue(user.getAccountName(), - ArbitraryDataProvider.E2E_ERRORS_TIMESTAMP); - - arbitraryDataProvider.deleteKeyForAccount(user.getAccountName(), - ArbitraryDataProvider.E2E_ERRORS); - - arbitraryDataProvider.deleteKeyForAccount(user.getAccountName(), - ArbitraryDataProvider.E2E_ERRORS_TIMESTAMP); - - if (value > 0 && timestamp > 0) { - return new Problem(SendClientDiagnosticRemoteOperation.E2EE_ERRORS, value, timestamp); - } else { - return null; - } - } - - public static String generateUid() { - return UUID.randomUUID().toString().replaceAll("-", ""); - } - - public static String retrievePublicKeyForUser(User user, Context context) { - return new ArbitraryDataProviderImpl(context).getValue(user, PUBLIC_KEY); - } - - public static byte[] generateIV() { - return EncryptionUtils.randomBytes(EncryptionUtils.ivLength); - } - - public static void savePublicKey(User currentUser, - String key, - String user, - ArbitraryDataProvider arbitraryDataProvider) { - arbitraryDataProvider.storeOrUpdateKeyValue(currentUser, - ArbitraryDataProvider.PUBLIC_KEY + user, - key); - } - - public static String getPublicKey(User currentUser, - String user, - ArbitraryDataProvider arbitraryDataProvider) { - return arbitraryDataProvider.getValue(currentUser, ArbitraryDataProvider.PUBLIC_KEY + user); - } -} diff --git a/app/src/main/kotlin/at/bitfire/davdroid/db/ServiceDao.kt b/app/src/main/kotlin/at/bitfire/davdroid/db/ServiceDao.kt index c4938e77b..94046ebf8 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/db/ServiceDao.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/db/ServiceDao.kt @@ -16,6 +16,9 @@ interface ServiceDao { @Query("SELECT * FROM service WHERE accountName=:accountName AND type=:type") suspend fun getByAccountAndType(accountName: String, @ServiceType type: String): Service? + @Query("SELECT * FROM service WHERE accountName=:accountName") + suspend fun getByAccountName(accountName: String): Service? + @Query("SELECT * FROM service WHERE accountName=:accountName AND type=:type") fun getByAccountAndTypeFlow(accountName: String, @ServiceType type: String): Flow diff --git a/app/src/main/kotlin/at/bitfire/davdroid/network/HttpClient.kt b/app/src/main/kotlin/at/bitfire/davdroid/network/HttpClient.kt index 71d5057f4..7c4e3aa3d 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/network/HttpClient.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/network/HttpClient.kt @@ -18,10 +18,14 @@ import at.bitfire.davdroid.settings.Settings import at.bitfire.davdroid.settings.SettingsManager import at.bitfire.davdroid.ui.ForegroundTracker import com.google.common.net.HttpHeaders +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.withContext import net.openid.appauth.AuthState +import net.openid.appauth.AuthorizationService import okhttp3.Authenticator import okhttp3.Cache import okhttp3.ConnectionSpec @@ -50,6 +54,12 @@ class HttpClient( okHttpClient.cache?.close() } + @EntryPoint + @InstallIn(SingletonComponent::class) + interface HttpClientEntryPoint { + fun authorizationService(): AuthorizationService + fun settingsManager(): SettingsManager + } // builder diff --git a/app/src/main/kotlin/at/bitfire/davdroid/util/UserIdFetcher.kt b/app/src/main/kotlin/at/bitfire/davdroid/util/UserIdFetcher.kt new file mode 100644 index 000000000..16c7bf8a9 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/util/UserIdFetcher.kt @@ -0,0 +1,45 @@ +/* + * Copyright MURENA SAS 2024 + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package at.bitfire.davdroid.util + +object UserIdFetcher { + + /** + * retrieve the userId from caldav/carddav nextcloud principal url. + * example: if the url is: https://abc.com/remote.php/dav/principals/users/xyz/, then this function will return xyz. + * + * this function will return null, if + * - `/users/` part is missing + */ + fun fetch(principalUrl: String): String? { + val usersPart = "/users/" + + var userId: String? = null + if (principalUrl.contains(usersPart, ignoreCase = true)) { + userId = principalUrl.split(usersPart, ignoreCase = true)[1] + if (userId.endsWith("/")) { + userId = userId.dropLast(1) + } + + if (userId.isBlank()) { + userId = null + } + } + + return userId + } +} diff --git a/app/src/main/res/layout/activity_sso_grant_permission.xml b/app/src/main/res/layout/activity_sso_grant_permission.xml new file mode 100644 index 000000000..054f061d9 --- /dev/null +++ b/app/src/main/res/layout/activity_sso_grant_permission.xml @@ -0,0 +1,23 @@ + + + + + + + diff --git a/app/src/ose/AndroidManifest.xml b/app/src/ose/AndroidManifest.xml index 33775680a..0342aecd0 100644 --- a/app/src/ose/AndroidManifest.xml +++ b/app/src/ose/AndroidManifest.xml @@ -14,6 +14,11 @@ + + + + @@ -254,18 +259,18 @@ + + - - - \ No newline at end of file + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 176addc3c..7a0abe2f6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -48,11 +48,16 @@ commons-codec = { strictly = "1.17.1" } commons-lang = { strictly = "3.15.0" } # --- e-Specific dependencies --- +appauth = "e8ca08e3" elib = "0.0.1-alpha11" ezVcard = "0.12.1" ical4j = "3.2.19" synctools = "58bc6752" runtimeLivedata = "1.8.3" +jackrabbitWebdav = "2.13.5" +commonsHttpclient = "3.1" +androidSinglesignon = "dff59d8d" +nextcloudLibrary = "2303db7b" [libraries] android-desugaring = { module = "com.android.tools:desugar_jdk_libs_nio", version.ref = "android-desugaring" } @@ -116,11 +121,16 @@ unifiedpush = { module = "org.unifiedpush.android:connector", version.ref = "uni unifiedpush-fcm = { module = "org.unifiedpush.android:embedded-fcm-distributor", version.ref = "unifiedpush-fcm" } # --- e-Specific dependencies --- +appauth = { module = "foundation.e:appauth", version.ref = "appauth" } +android-singlesignon = { module = "foundation.e:Android-SingleSignOn", version.ref = "androidSinglesignon" } elib = { module = "foundation.e:elib", version.ref = "elib" } ez-vcard = { module = "com.googlecode.ez-vcard:ez-vcard", version.ref = "ezVcard" } ical4j = { module = "org.mnode.ical4j:ical4j", version.ref = "ical4j" } synctools = { module = "foundation.e:synctools", version.ref = "synctools" } androidx-runtime-livedata = { module = "androidx.compose.runtime:runtime-livedata", version.ref = "runtimeLivedata" } +commons-httpclient = { module = "commons-httpclient:commons-httpclient", version.ref = "commonsHttpclient" } +jackrabbit-webdav = { module = "org.apache.jackrabbit:jackrabbit-webdav", version.ref = "jackrabbitWebdav" } +nextcloud-library = { module = "foundation.e:nextcloud-library", version.ref = "nextcloudLibrary" } [plugins] android-application = { id = "com.android.application", version.ref = "android-agp" } -- GitLab From c834a9c4ec671c190407eb630d9d489fd954a3b6 Mon Sep 17 00:00:00 2001 From: althafvly Date: Thu, 4 Sep 2025 11:32:38 +0530 Subject: [PATCH 3/9] AM: Switch to kotlin and support for eDrive --- .../android/sso/InputStreamBinder.java | 415 ----------------- .../android/sso/OidcTokenRefresher.kt | 132 ------ .../nextcloud/android/sso/PatchMethod.java | 100 ---- .../nextcloud/android/sso/PlainHeader.java | 43 -- .../com/nextcloud/android/sso/Response.java | 59 --- .../android/utils/AccountManagerUtils.java | 65 --- .../services/AccountManagerService.java | 37 -- .../android/utils/EncryptionUtils.java | 83 ---- .../at/bitfire/davdroid/network/HttpClient.kt | 10 - .../davdroid/network/UserAgentInterceptor.kt | 3 +- .../davdroid/repository/AccountRepository.kt | 6 +- .../davdroid/settings/AccountSettings.kt | 13 +- .../davdroid/sync/worker/BaseSyncWorker.kt | 2 +- .../ui/account/AccountSettingsModel.kt | 3 +- .../android/sso/BinderDependencies.kt | 2 + .../android/sso/InputStreamBinder.kt | 426 ++++++++++++++++++ .../com/nextcloud/android/sso/PatchMethod.kt | 49 ++ .../com/nextcloud/android/sso/Response.kt | 44 ++ .../android/utils/EncryptionUtils.kt | 88 ++++ .../services/AccountManagerService.kt} | 25 +- .../ui/activity/SsoGrantPermissionActivity.kt | 71 ++- .../activity/SsoGrantPermissionViewModel.kt | 105 ++--- .../e/accountmanager/auth/AccountReceiver.kt | 13 +- .../e/accountmanager/network/OAuthMurena.kt | 48 +- .../e/accountmanager/utils/AccountHelper.kt | 43 +- .../e/accountmanager/utils}/UserIdFetcher.kt | 8 +- .../layout/activity_sso_grant_permission.xml | 23 - 27 files changed, 834 insertions(+), 1082 deletions(-) delete mode 100644 app/src/main/java/com/nextcloud/android/sso/InputStreamBinder.java delete mode 100644 app/src/main/java/com/nextcloud/android/sso/OidcTokenRefresher.kt delete mode 100644 app/src/main/java/com/nextcloud/android/sso/PatchMethod.java delete mode 100644 app/src/main/java/com/nextcloud/android/sso/PlainHeader.java delete mode 100644 app/src/main/java/com/nextcloud/android/sso/Response.java delete mode 100644 app/src/main/java/com/nextcloud/android/utils/AccountManagerUtils.java delete mode 100644 app/src/main/java/com/owncloud/android/services/AccountManagerService.java delete mode 100644 app/src/main/java/com/owncloud/android/utils/EncryptionUtils.java rename app/src/main/{java => kotlin}/com/nextcloud/android/sso/BinderDependencies.kt (90%) create mode 100644 app/src/main/kotlin/com/nextcloud/android/sso/InputStreamBinder.kt create mode 100644 app/src/main/kotlin/com/nextcloud/android/sso/PatchMethod.kt create mode 100644 app/src/main/kotlin/com/nextcloud/android/sso/Response.kt create mode 100644 app/src/main/kotlin/com/nextcloud/android/utils/EncryptionUtils.kt rename app/src/main/{java/com/owncloud/android/ui/activity/SsoGrantPermissionEvent.kt => kotlin/com/owncloud/android/services/AccountManagerService.kt} (52%) rename app/src/main/{java => kotlin}/com/owncloud/android/ui/activity/SsoGrantPermissionActivity.kt (51%) rename app/src/main/{java => kotlin}/com/owncloud/android/ui/activity/SsoGrantPermissionViewModel.kt (65%) rename app/src/main/kotlin/{at/bitfire/davdroid/util => foundation/e/accountmanager/utils}/UserIdFetcher.kt (90%) delete mode 100644 app/src/main/res/layout/activity_sso_grant_permission.xml diff --git a/app/src/main/java/com/nextcloud/android/sso/InputStreamBinder.java b/app/src/main/java/com/nextcloud/android/sso/InputStreamBinder.java deleted file mode 100644 index ea5f2affc..000000000 --- a/app/src/main/java/com/nextcloud/android/sso/InputStreamBinder.java +++ /dev/null @@ -1,415 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2019 David Luhmer - * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only - * - * More information here: https://github.com/abeluck/android-streams-ipc - */ -package com.nextcloud.android.sso; - -import android.accounts.Account; -import android.accounts.AuthenticatorException; -import android.accounts.OperationCanceledException; -import android.content.Context; -import android.content.SharedPreferences; -import android.net.Uri; -import android.os.Binder; -import android.os.ParcelFileDescriptor; -import android.text.TextUtils; - -import com.nextcloud.android.sso.aidl.IInputStreamService; -import com.nextcloud.android.sso.aidl.NextcloudRequest; -import com.nextcloud.android.sso.aidl.ParcelFileDescriptorUtil; -import com.nextcloud.android.utils.AccountManagerUtils; -import com.owncloud.android.utils.EncryptionUtils; -import com.owncloud.android.lib.common.OwnCloudAccount; -import com.owncloud.android.lib.common.OwnCloudClient; -import com.owncloud.android.lib.common.OwnCloudClientManager; -import com.owncloud.android.lib.common.OwnCloudClientManagerFactory; -import com.owncloud.android.lib.common.operations.RemoteOperation; - -import org.apache.commons.httpclient.HttpConnection; -import org.apache.commons.httpclient.HttpMethodBase; -import org.apache.commons.httpclient.HttpState; -import org.apache.commons.httpclient.NameValuePair; -import org.apache.commons.httpclient.methods.DeleteMethod; -import org.apache.commons.httpclient.methods.GetMethod; -import org.apache.commons.httpclient.methods.HeadMethod; -import org.apache.commons.httpclient.methods.InputStreamRequestEntity; -import org.apache.commons.httpclient.methods.PostMethod; -import org.apache.commons.httpclient.methods.PutMethod; -import org.apache.commons.httpclient.methods.RequestEntity; -import org.apache.commons.httpclient.methods.StringRequestEntity; -import org.apache.jackrabbit.webdav.DavConstants; -import org.apache.jackrabbit.webdav.client.methods.MkColMethod; -import org.apache.jackrabbit.webdav.client.methods.PropFindMethod; -import org.apache.jackrabbit.webdav.property.DavPropertyNameSet; - -import java.io.BufferedReader; -import java.io.ByteArrayInputStream; -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.io.InputStream; -import java.io.InputStreamReader; -import java.io.ObjectInputStream; -import java.io.ObjectOutputStream; -import java.io.Serializable; -import java.util.Collection; -import java.util.List; -import java.util.Map; -import java.util.logging.Level; -import java.util.logging.Logger; - -import androidx.annotation.NonNull; -import androidx.annotation.VisibleForTesting; - -import static com.nextcloud.android.sso.Constants.EXCEPTION_ACCOUNT_NOT_FOUND; -import static com.nextcloud.android.sso.Constants.EXCEPTION_HTTP_REQUEST_FAILED; -import static com.nextcloud.android.sso.Constants.EXCEPTION_INVALID_REQUEST_URL; -import static com.nextcloud.android.sso.Constants.EXCEPTION_INVALID_TOKEN; -import static com.nextcloud.android.sso.Constants.EXCEPTION_UNSUPPORTED_METHOD; -import static com.nextcloud.android.sso.Constants.SSO_SHARED_PREFERENCE; - - -/** - * Stream binder to pass usable InputStreams across the process boundary in Android. - */ -public class InputStreamBinder extends IInputStreamService.Stub { - private final static String TAG = "InputStreamBinder"; - private static final String CONTENT_TYPE_APPLICATION_JSON = "application/json"; - private static final String CHARSET_UTF8 = "UTF-8"; - - private static final int HTTP_STATUS_CODE_OK = 200; - private static final int HTTP_STATUS_CODE_MULTIPLE_CHOICES = 300; - - private static final char PATH_SEPARATOR = '/'; - public static final String DELIMITER = "_"; - private final Context context; - private final Logger logger = Logger.getLogger(TAG); - - public InputStreamBinder(Context context) { - this.context = context; - } - - public ParcelFileDescriptor performNextcloudRequestV2(ParcelFileDescriptor input) { - return performNextcloudRequestAndBodyStreamV2(input, null); - } - - public ParcelFileDescriptor performNextcloudRequestAndBodyStreamV2( - ParcelFileDescriptor input, - ParcelFileDescriptor requestBodyParcelFileDescriptor) { - // read the input - final InputStream is = new ParcelFileDescriptor.AutoCloseInputStream(input); - - final InputStream requestBodyInputStream = requestBodyParcelFileDescriptor != null ? - new ParcelFileDescriptor.AutoCloseInputStream(requestBodyParcelFileDescriptor) : null; - Exception exception = null; - Response response = new Response(); - - try { - // Start request and catch exceptions - NextcloudRequest request = deserializeObjectAndCloseStream(is); - response = processRequestV2(request, requestBodyInputStream); - } catch (Exception e) { - logger.log(Level.SEVERE, "Error during Nextcloud request", e); - exception = e; - } - - try { - // Write exception to the stream followed by the actual network stream - InputStream exceptionStream = serializeObjectToInputStreamV2(exception, response.getPlainHeadersString()); - InputStream resultStream = new java.io.SequenceInputStream(exceptionStream, response.getBody()); - - return ParcelFileDescriptorUtil.pipeFrom(resultStream, - thread -> logger.log(Level.INFO, "InputStreamBinder: Done sending result")); - } catch (IOException e) { - logger.log(Level.SEVERE, "Error while sending response back to client app", e); - } - - return null; - } - - public ParcelFileDescriptor performNextcloudRequest(ParcelFileDescriptor input) { - return performNextcloudRequestAndBodyStreamV2(input, null); - } - - public ParcelFileDescriptor performNextcloudRequestAndBodyStream( - ParcelFileDescriptor input, - ParcelFileDescriptor requestBodyParcelFileDescriptor) { - return performNextcloudRequestAndBodyStreamV2(input, requestBodyParcelFileDescriptor); - } - - private ByteArrayInputStream serializeObjectToInputStreamV2(Exception exception, String headers) { - byte[] baosByteArray = new byte[0]; - try (ByteArrayOutputStream baos = new ByteArrayOutputStream(); - ObjectOutputStream oos = new ObjectOutputStream(baos)) { - oos.writeObject(exception); - oos.writeObject(headers); - oos.flush(); - oos.close(); - - baosByteArray = baos.toByteArray(); - } catch (IOException e) { - logger.log(Level.SEVERE, "Error while sending response back to client app", e); - } - - return new ByteArrayInputStream(baosByteArray); - } - - private T deserializeObjectAndCloseStream(InputStream is) throws IOException, - ClassNotFoundException { - ObjectInputStream ois = new ObjectInputStream(is); - T result = (T) ois.readObject(); - is.close(); - ois.close(); - return result; - } - - public class NCPropFindMethod extends PropFindMethod { - NCPropFindMethod(String uri, int propfindType, int depth) throws IOException { - super(uri, propfindType, new DavPropertyNameSet(), depth); - } - - @Override - protected void processResponseBody(HttpState httpState, HttpConnection httpConnection) { - // Do not process the response body here. Instead pass it on to client app. - } - } - - private HttpMethodBase buildMethod(NextcloudRequest request, Uri baseUri, InputStream requestBodyInputStream) - throws IOException { - String requestUrl = baseUri + request.getUrl(); - HttpMethodBase method; - switch (request.getMethod()) { - case "GET": - method = new GetMethod(requestUrl); - break; - - case "POST": - method = new PostMethod(requestUrl); - if (requestBodyInputStream != null) { - RequestEntity requestEntity = new InputStreamRequestEntity(requestBodyInputStream); - ((PostMethod) method).setRequestEntity(requestEntity); - } else if (request.getRequestBody() != null) { - StringRequestEntity requestEntity = new StringRequestEntity( - request.getRequestBody(), - CONTENT_TYPE_APPLICATION_JSON, - CHARSET_UTF8); - ((PostMethod) method).setRequestEntity(requestEntity); - } - break; - - case "PATCH": - method = new PatchMethod(requestUrl); - if (requestBodyInputStream != null) { - RequestEntity requestEntity = new InputStreamRequestEntity(requestBodyInputStream); - ((PatchMethod) method).setRequestEntity(requestEntity); - } else if (request.getRequestBody() != null) { - StringRequestEntity requestEntity = new StringRequestEntity( - request.getRequestBody(), - CONTENT_TYPE_APPLICATION_JSON, - CHARSET_UTF8); - ((PatchMethod) method).setRequestEntity(requestEntity); - } - break; - - case "PUT": - method = new PutMethod(requestUrl); - if (requestBodyInputStream != null) { - RequestEntity requestEntity = new InputStreamRequestEntity(requestBodyInputStream); - ((PutMethod) method).setRequestEntity(requestEntity); - } else if (request.getRequestBody() != null) { - StringRequestEntity requestEntity = new StringRequestEntity( - request.getRequestBody(), - CONTENT_TYPE_APPLICATION_JSON, - CHARSET_UTF8); - ((PutMethod) method).setRequestEntity(requestEntity); - } - break; - - case "DELETE": - method = new DeleteMethod(requestUrl); - break; - - case "PROPFIND": - method = new NCPropFindMethod(requestUrl, DavConstants.PROPFIND_ALL_PROP, DavConstants.DEPTH_1); - if (request.getRequestBody() != null) { - //text/xml; charset=UTF-8 is taken from XmlRequestEntity... Should be application/xml - StringRequestEntity requestEntity = new StringRequestEntity( - request.getRequestBody(), - "text/xml; charset=UTF-8", - CHARSET_UTF8); - ((PropFindMethod) method).setRequestEntity(requestEntity); - } - break; - - case "MKCOL": - method = new MkColMethod(requestUrl); - break; - - case "HEAD": - method = new HeadMethod(requestUrl); - break; - - default: - throw new UnsupportedOperationException(EXCEPTION_UNSUPPORTED_METHOD); - - } - return method; - } - - /* - * for non ocs/dav requests (nextcloud app: ex: notes app), when OIDC is used, we need to pass an special header. - * We should not pass this header for ocs/dav requests as it can cause session cookie not being used for those request. - * - * These nextcloud app request paths contain `/index.php/apps/` on them. - */ - private boolean shouldAddHeaderForOidcLogin(@NonNull Context context, @NonNull Account account, @NonNull String path) { - boolean isOidcAccount = AccountManagerUtils.isOidcAccount(context, account); - if (!isOidcAccount) { - return false; - } - - return path.contains("/index.php/apps/"); - } - - private Response processRequestV2(final NextcloudRequest request, final InputStream requestBodyInputStream) - throws UnsupportedOperationException, - com.owncloud.android.lib.common.accounts.AccountUtils.AccountNotFoundException, - OperationCanceledException, AuthenticatorException, IOException { - Account account = AccountManagerUtils.getAccountByName(context, request.getAccountName()); - if (account == null) { - throw new IllegalStateException(EXCEPTION_ACCOUNT_NOT_FOUND); - } - - // Validate token - if (!isValid(request)) { - throw new IllegalStateException(EXCEPTION_INVALID_TOKEN); - } - - // Validate URL - if (request.getUrl().isEmpty() || request.getUrl().charAt(0) != PATH_SEPARATOR) { - throw new IllegalStateException(EXCEPTION_INVALID_REQUEST_URL, - new IllegalStateException("URL need to start with a /")); - } - - if (AccountManagerUtils.isOidcAccount(context, account)) { - // Blocking call - OidcTokenRefresher.refresh(context, account); - } - - final OwnCloudClientManager ownCloudClientManager = OwnCloudClientManagerFactory.getDefaultSingleton(); - final OwnCloudAccount ownCloudAccount = new OwnCloudAccount(account, context); - final OwnCloudClient client = ownCloudClientManager.getClientFor(ownCloudAccount, context, OwnCloudClient.DONT_USE_COOKIES); - - HttpMethodBase method = buildMethod(request, client.getBaseUri(), requestBodyInputStream); - - if (request.getParameterV2() != null && !request.getParameterV2().isEmpty()) { - method.setQueryString(convertListToNVP(request.getParameterV2())); - } - - for (Map.Entry> header : request.getHeader().entrySet()) { - // https://stackoverflow.com/a/3097052 - method.addRequestHeader(header.getKey(), TextUtils.join(",", header.getValue())); - } - - method.setRequestHeader( - RemoteOperation.OCS_API_HEADER, - RemoteOperation.OCS_API_HEADER_VALUE - ); - - if (shouldAddHeaderForOidcLogin(context, account, request.getUrl())) { - method.setRequestHeader( - RemoteOperation.OIDC_LOGIN_WITH_TOKEN, - RemoteOperation.OIDC_LOGIN_WITH_TOKEN_VALUE - ); - } - - client.setFollowRedirects(true); - int status = client.executeMethod(method); - - ownCloudClientManager.saveAllClients(context, account.type); - - // Check if status code is 2xx --> https://en.wikipedia.org/wiki/List_of_HTTP_status_codes#2xx_Success - if (status >= HTTP_STATUS_CODE_OK && status < HTTP_STATUS_CODE_MULTIPLE_CHOICES) { - return new Response(method); - } else { - InputStream inputStream = method.getResponseBodyAsStream(); - String total = "No response body"; - - // If response body is available - if (inputStream != null) { - total = inputStreamToString(inputStream); - logger.severe(total); - } - - method.releaseConnection(); - throw new IllegalStateException(EXCEPTION_HTTP_REQUEST_FAILED, - new IllegalStateException(String.valueOf(status), - new IllegalStateException(total))); - } - } - - private boolean isValid(NextcloudRequest request) { - String callingPackageName = context.getPackageManager().getNameForUid(Binder.getCallingUid()); - - SharedPreferences sharedPreferences = context.getSharedPreferences(SSO_SHARED_PREFERENCE, - Context.MODE_PRIVATE); - String hash = sharedPreferences.getString(callingPackageName + DELIMITER + request.getAccountName(), ""); - return validateToken(hash, request.getToken()); - } - - private boolean validateToken(String hash, String token) { - if (!hash.contains("$")) { - throw new IllegalStateException(EXCEPTION_INVALID_TOKEN); - } - - String salt = hash.split("\\$")[1]; // TODO extract "$" - - String newHash = EncryptionUtils.generateSHA512(token, salt); - - // As discussed with Lukas R. at the Nextcloud Conf 2018, always compare whole strings - // and don't exit prematurely if the string does not match anymore to prevent timing-attacks - return isEqual(hash.getBytes(), newHash.getBytes()); - } - - // Taken from http://codahale.com/a-lesson-in-timing-attacks/ - private static boolean isEqual(byte[] a, byte[] b) { - if (a.length != b.length) { - return false; - } - - int result = 0; - for (int i = 0; i < a.length; i++) { - result |= a[i] ^ b[i]; - } - return result == 0; - } - - private static String inputStreamToString(InputStream inputStream) { - try { - StringBuilder total = new StringBuilder(); - BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream)); - String line = reader.readLine(); - while (line != null) { - total.append(line).append('\n'); - line = reader.readLine(); - } - return total.toString(); - } catch (Exception e) { - return e.getMessage(); - } - } - - @VisibleForTesting - public static NameValuePair[] convertListToNVP(Collection list) { - NameValuePair[] nvp = new NameValuePair[list.size()]; - int i = 0; - for (QueryParam pair : list) { - nvp[i] = new NameValuePair(pair.key, pair.value); - i++; - } - return nvp; - } -} diff --git a/app/src/main/java/com/nextcloud/android/sso/OidcTokenRefresher.kt b/app/src/main/java/com/nextcloud/android/sso/OidcTokenRefresher.kt deleted file mode 100644 index e4b53f052..000000000 --- a/app/src/main/java/com/nextcloud/android/sso/OidcTokenRefresher.kt +++ /dev/null @@ -1,132 +0,0 @@ -/* - * Copyright (C) 2025 e Foundation - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - * - */ - -package com.nextcloud.android.sso - -import android.accounts.Account -import android.content.Context -import at.bitfire.davdroid.network.HttpClient.HttpClientEntryPoint -import at.bitfire.davdroid.settings.AccountSettings -import dagger.hilt.android.EntryPointAccessors -import kotlinx.coroutines.runBlocking -import net.openid.appauth.AuthState -import net.openid.appauth.AuthorizationService -import net.openid.appauth.ClientAuthentication -import org.jetbrains.annotations.Blocking -import java.util.logging.Level -import java.util.logging.Logger -import kotlin.coroutines.resume -import kotlin.coroutines.resumeWithException -import kotlin.coroutines.suspendCoroutine - -/** - * Utility for refreshing OpenID Connect (OIDC) tokens in the Android AccountManager. - * - * This object exposes a synchronous, blocking entry point for token refresh requests - * and internally uses coroutines to perform the refresh operation with proper - * callback-to-suspension conversion. - */ -object OidcTokenRefresher { - - val logger: Logger = Logger.getLogger(this.javaClass.name) - - /** - * Refreshes the OIDC token for the given [Account]. - * - * It will: - * 1. Invoke the authorization service to refresh tokens. - * 2. Update AccountManager on successful refresh or log failures. - * - * **Threading:** This method uses [runBlocking] and therefore must **not** be - * called from the Main/UI thread. It is annotated with `@Blocking` to signal - * blocking behavior. - */ - @JvmStatic - @Blocking - fun refresh(context: Context, account: Account) { - runBlocking { - val accountSettingsFactory = EntryPointAccessors.fromApplication( - context.applicationContext, - BinderDependencies::class.java - ).accountSettingsFactory() - - val accountSettings = accountSettingsFactory.create(account) - val credentials = accountSettings.credentials() - val authState = credentials.authState - - if (authState == null) { - logger.log(Level.FINE, "Account: $account has null AuthState, refresh isn't possible.") - return@runBlocking - } - - val authorizationService = - EntryPointAccessors.fromApplication(context, HttpClientEntryPoint::class.java) - .authorizationService() - - val updatedAuthState = runCatching { - refreshAuthState(authorizationService, authState) - }.getOrNull() - - if (updatedAuthState != null) { - updateAndroidAccountManagerAuthState(accountSettings, updatedAuthState) - } else { - logger.warning("Couldn't update AuthState for account: $account") - } - } - } - - /** - * Suspends until the authState has fresh tokens from AuthorizationService. - * - * Internally it bridges the callback-based `performActionWithFreshTokens` - * API into a coroutine suspension using [suspendCoroutine]. On success, it - * resumes with the same [AuthState] instance containing updated tokens. On - * failure, it throws the encountered [Throwable]. - * - * @param authService The [AuthorizationService] to use for token refresh. - * @param authState The current [AuthState] containing existing tokens. - * @param clientAuth [ClientAuthentication] mechanism (e.g., client secret). - * @return The same [AuthState] instance with refreshed tokens. - * @throws Exception if the refresh operation fails. - */ - private suspend fun refreshAuthState( - authService: AuthorizationService, authState: AuthState - ): AuthState { - return suspendCoroutine { continuation -> - authState.performActionWithFreshTokens( - authService, - ) { accessToken, _, authorizationException -> - when { - accessToken != null -> continuation.resume(authState) - authorizationException != null -> continuation.resumeWithException( - authorizationException - ) - } - } - } - } - - /** - * Persists an updated [AuthState] back into the Android AccountManager. - */ - private fun updateAndroidAccountManagerAuthState( - accountSettings: AccountSettings, updatedAuthState: AuthState - ) = accountSettings.credentials( - accountSettings.credentials().copy(authState = updatedAuthState) - ) -} diff --git a/app/src/main/java/com/nextcloud/android/sso/PatchMethod.java b/app/src/main/java/com/nextcloud/android/sso/PatchMethod.java deleted file mode 100644 index 34c5e8b24..000000000 --- a/app/src/main/java/com/nextcloud/android/sso/PatchMethod.java +++ /dev/null @@ -1,100 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2021 Timo Triebensky - * SPDX-FileCopyrightText: 2021 Nextcloud GmbH - * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only - * - * More information here: https://github.com/abeluck/android-streams-ipc - */ -package com.nextcloud.android.sso; - -import org.apache.commons.httpclient.methods.ByteArrayRequestEntity; -import org.apache.commons.httpclient.methods.EntityEnclosingMethod; -import org.apache.commons.httpclient.methods.PostMethod; -import org.apache.commons.httpclient.methods.RequestEntity; -import org.apache.commons.httpclient.util.EncodingUtil; - -import java.util.Vector; - -public class PatchMethod extends PostMethod { - - /** - * The buffered request body consisting of NameValuePairs. - */ - private Vector params = new Vector(); - - /** - * No-arg constructor. - */ - public PatchMethod() { - super(); - } - - /** - * Constructor specifying a URI. - * - * @param uri either an absolute or relative URI - */ - public PatchMethod(String uri) { - super(uri); - } - - /** - * Returns "PATCH". - * - * @return "PATCH" - * @since 2.0 - */ - @Override - public String getName() { - return "PATCH"; - } - - /** - * Returns true if there is a request body to be sent. - * - * @return boolean - * @since 2.0beta1 - */ - protected boolean hasRequestContent() { - if (!this.params.isEmpty()) { - return true; - } else { - return super.hasRequestContent(); - } - } - - /** - * Clears request body. - * - * @since 2.0beta1 - */ - protected void clearRequestBody() { - this.params.clear(); - super.clearRequestBody(); - } - - /** - * Generates a request entity from the patch parameters, if present. Calls {@link - * EntityEnclosingMethod#generateRequestBody()} if parameters have not been set. - * - * @since 3.0 - */ - protected RequestEntity generateRequestEntity() { - if (!this.params.isEmpty()) { - // Use a ByteArrayRequestEntity instead of a StringRequestEntity. - // This is to avoid potential encoding issues. Form url encoded strings - // are ASCII by definition but the content type may not be. Treating the content - // as bytes allows us to keep the current charset without worrying about how - // this charset will effect the encoding of the form url encoded string. - String content = EncodingUtil.formUrlEncode(getParameters(), getRequestCharSet()); - return new ByteArrayRequestEntity( - EncodingUtil.getAsciiBytes(content), - FORM_URL_ENCODED_CONTENT_TYPE - ); - } else { - return super.generateRequestEntity(); - } - } -} diff --git a/app/src/main/java/com/nextcloud/android/sso/PlainHeader.java b/app/src/main/java/com/nextcloud/android/sso/PlainHeader.java deleted file mode 100644 index 07c23c6b4..000000000 --- a/app/src/main/java/com/nextcloud/android/sso/PlainHeader.java +++ /dev/null @@ -1,43 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2019 Tobias Kaminsky - * SPDX-FileCopyrightText: 2019 Nextcloud GmbH - * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only - */ -package com.nextcloud.android.sso; - -import java.io.IOException; -import java.io.ObjectInputStream; -import java.io.ObjectOutputStream; -import java.io.Serializable; - -public class PlainHeader implements Serializable { - private static final long serialVersionUID = 3284979177401282512L; - - private String name; - private String value; - - PlainHeader(String name, String value) { - this.name = name; - this.value = value; - } - - private void writeObject(ObjectOutputStream oos) throws IOException { - oos.writeObject(name); - oos.writeObject(value); - } - - private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException { - name = (String) in.readObject(); - value = (String) in.readObject(); - } - - public String getName() { - return this.name; - } - - public String getValue() { - return this.value; - } -} diff --git a/app/src/main/java/com/nextcloud/android/sso/Response.java b/app/src/main/java/com/nextcloud/android/sso/Response.java deleted file mode 100644 index 2402b8b1d..000000000 --- a/app/src/main/java/com/nextcloud/android/sso/Response.java +++ /dev/null @@ -1,59 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2019 Tobias Kaminsky - * SPDX-FileCopyrightText: 2019 Nextcloud GmbH - * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only - */ -package com.nextcloud.android.sso; - -import com.google.gson.Gson; - -import org.apache.commons.httpclient.Header; -import org.apache.commons.httpclient.HttpMethodBase; - -import java.io.IOException; -import java.io.InputStream; -import java.util.ArrayList; -import java.util.List; - -public class Response { - private InputStream body; - private Header[] headers; - private HttpMethodBase method; - - public Response() { - headers = new Header[0]; - body = new InputStream() { - @Override - public int read() { - return 0; - } - }; - } - - public Response(HttpMethodBase methodBase) throws IOException { - this.method = methodBase; - this.body = methodBase.getResponseBodyAsStream(); - this.headers = methodBase.getResponseHeaders(); - } - - public String getPlainHeadersString() { - List arrayList = new ArrayList<>(headers.length); - - for (Header header : headers) { - arrayList.add(new PlainHeader(header.getName(), header.getValue())); - } - - Gson gson = new Gson(); - return gson.toJson(arrayList); - } - - public InputStream getBody() { - return this.body; - } - - public HttpMethodBase getMethod() { - return method; - } -} diff --git a/app/src/main/java/com/nextcloud/android/utils/AccountManagerUtils.java b/app/src/main/java/com/nextcloud/android/utils/AccountManagerUtils.java deleted file mode 100644 index faf5ff7a1..000000000 --- a/app/src/main/java/com/nextcloud/android/utils/AccountManagerUtils.java +++ /dev/null @@ -1,65 +0,0 @@ -/* - * Copyright MURENA SAS 2023 - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package com.nextcloud.android.utils; - -import android.accounts.Account; -import android.accounts.AccountManager; -import android.content.Context; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import at.bitfire.davdroid.Constants; -import at.bitfire.davdroid.R; -import at.bitfire.davdroid.settings.AccountSettings; - -public final class AccountManagerUtils { - - private AccountManagerUtils() { - // utility class -> private constructor - } - - @Nullable - public static Account getAccountByName(@NonNull Context context, String name) { - for (Account account : getAccounts(context)) { - if (account.name.equals(name)) { - return account; - } - } - - return null; - } - - @Nullable - public static boolean isOidcAccount(@NonNull Context context, @NonNull Account account) { - AccountManager accountManager = AccountManager.get(context); - String authState = accountManager.getUserData(account, AccountSettings.KEY_AUTH_STATE); - return authState != null && !authState.trim().isEmpty(); - } - - @NonNull - public static Account[] getAccounts(@NonNull Context context) { - AccountManager accountManager = AccountManager.get(context); - return accountManager.getAccountsByType(getAccountType(context)); - } - - @NonNull - public static String getAccountType(@NonNull Context context) { - return context.getString(R.string.eelo_account_type); - } - -} diff --git a/app/src/main/java/com/owncloud/android/services/AccountManagerService.java b/app/src/main/java/com/owncloud/android/services/AccountManagerService.java deleted file mode 100644 index aad07f3eb..000000000 --- a/app/src/main/java/com/owncloud/android/services/AccountManagerService.java +++ /dev/null @@ -1,37 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2017 David Luhmer - * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only - */ -package com.owncloud.android.services; - -import android.app.Service; -import android.content.Intent; -import android.os.IBinder; - -import com.nextcloud.android.sso.InputStreamBinder; - -public class AccountManagerService extends Service { - - private InputStreamBinder mBinder; - - @Override - public void onCreate() { - super.onCreate(); - } - - @Override - public IBinder onBind(Intent intent) { - if (mBinder == null) { - mBinder = new InputStreamBinder(getApplicationContext()); - } - return mBinder; - } - - @Override - public boolean onUnbind(Intent intent) { - return super.onUnbind(intent); - } - -} diff --git a/app/src/main/java/com/owncloud/android/utils/EncryptionUtils.java b/app/src/main/java/com/owncloud/android/utils/EncryptionUtils.java deleted file mode 100644 index 3b4aef112..000000000 --- a/app/src/main/java/com/owncloud/android/utils/EncryptionUtils.java +++ /dev/null @@ -1,83 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2017 Tobias Kaminsky - * SPDX-FileCopyrightText: 2017 Nextcloud GmbH - * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only - */ -package com.owncloud.android.utils; - -import android.util.Base64; - -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import java.security.SecureRandom; -import java.util.logging.Level; -import java.util.logging.Logger; - -public final class EncryptionUtils { - private static final Logger logger = Logger.getLogger("EncryptionUtils"); - - public static final int saltLength = 40; - - private static final String HASH_DELIMITER = "$"; - - private EncryptionUtils() { - // utility class -> private constructor - } - - /** - * Generate a SHA512 with appended salt - * - * @param token token to be hashed - * @return SHA512 with appended salt, delimiter HASH_DELIMITER - */ - public static String generateSHA512(String token) { - String salt = EncryptionUtils.encodeBytesToBase64String(EncryptionUtils.randomBytes(EncryptionUtils.saltLength)); - - return generateSHA512(token, salt); - } - - /** - * Generate a SHA512 with appended salt - * - * @param token token to be hashed - * @return SHA512 with appended salt, delimiter HASH_DELIMITER - */ - public static String generateSHA512(String token, String salt) { - MessageDigest digest; - String hashedToken = ""; - byte[] hash; - try { - digest = MessageDigest.getInstance("SHA-512"); - digest.update(salt.getBytes()); - hash = digest.digest(token.getBytes()); - - StringBuilder stringBuilder = new StringBuilder(); - for (byte hashByte : hash) { - stringBuilder.append(Integer.toString((hashByte & 0xff) + 0x100, 16).substring(1)); - } - - stringBuilder.append(HASH_DELIMITER).append(salt); - - hashedToken = stringBuilder.toString(); - - } catch (NoSuchAlgorithmException e) { - logger.log(Level.SEVERE, "Generating SHA512 failed", e); - } - - return hashedToken; - } - - public static String encodeBytesToBase64String(byte[] bytes) { - return Base64.encodeToString(bytes, Base64.NO_WRAP); - } - - public static byte[] randomBytes(int size) { - SecureRandom random = new SecureRandom(); - final byte[] iv = new byte[size]; - random.nextBytes(iv); - - return iv; - } -} diff --git a/app/src/main/kotlin/at/bitfire/davdroid/network/HttpClient.kt b/app/src/main/kotlin/at/bitfire/davdroid/network/HttpClient.kt index 7c4e3aa3d..71d5057f4 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/network/HttpClient.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/network/HttpClient.kt @@ -18,14 +18,10 @@ import at.bitfire.davdroid.settings.Settings import at.bitfire.davdroid.settings.SettingsManager import at.bitfire.davdroid.ui.ForegroundTracker import com.google.common.net.HttpHeaders -import dagger.hilt.EntryPoint -import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext -import dagger.hilt.components.SingletonComponent import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.withContext import net.openid.appauth.AuthState -import net.openid.appauth.AuthorizationService import okhttp3.Authenticator import okhttp3.Cache import okhttp3.ConnectionSpec @@ -54,12 +50,6 @@ class HttpClient( okHttpClient.cache?.close() } - @EntryPoint - @InstallIn(SingletonComponent::class) - interface HttpClientEntryPoint { - fun authorizationService(): AuthorizationService - fun settingsManager(): SettingsManager - } // builder diff --git a/app/src/main/kotlin/at/bitfire/davdroid/network/UserAgentInterceptor.kt b/app/src/main/kotlin/at/bitfire/davdroid/network/UserAgentInterceptor.kt index 081ed2798..ca9ffd467 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/network/UserAgentInterceptor.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/network/UserAgentInterceptor.kt @@ -6,6 +6,7 @@ package at.bitfire.davdroid.network import android.os.Build import at.bitfire.davdroid.BuildConfig +import com.owncloud.android.lib.common.OwnCloudClientManagerFactory import okhttp3.Interceptor import okhttp3.OkHttp import okhttp3.Response @@ -18,7 +19,7 @@ object UserAgentInterceptor: Interceptor { "okhttp/${OkHttp.VERSION}) Android/${Build.VERSION.RELEASE}" init { - userAgent = "/e/OS v2 (Android) Nextcloud-android" + userAgent = OwnCloudClientManagerFactory.getNextCloudUserAgent() Logger.getGlobal().info("Will set User-Agent: $userAgent") } diff --git a/app/src/main/kotlin/at/bitfire/davdroid/repository/AccountRepository.kt b/app/src/main/kotlin/at/bitfire/davdroid/repository/AccountRepository.kt index 0420512b9..314d1c93c 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/repository/AccountRepository.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/repository/AccountRepository.kt @@ -28,7 +28,7 @@ import at.bitfire.vcard4android.GroupMethod import dagger.Lazy import dagger.hilt.android.qualifiers.ApplicationContext import foundation.e.accountmanager.AccountTypes -import foundation.e.accountmanager.pref.AuthStatePrefUtils +import foundation.e.accountmanager.network.OAuthMurena import foundation.e.accountmanager.utils.AccountHelper import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.awaitClose @@ -75,8 +75,8 @@ class AccountRepository @Inject constructor( val account = fromName(accountName, accountType) // create Android account - val userData = AccountSettings.initialUserData(credentials) - AuthStatePrefUtils.saveAuthState(context, account, credentials?.authState?.jsonSerializeString()) + val initialUserData = AccountSettings.initialUserData(credentials) + val userData = OAuthMurena.onCreateAccount(context, initialUserData, account, credentials) logger.log(Level.INFO, "Creating Android account with initial config", arrayOf(account, userData)) if (!SystemAccountUtils.createAccount(context, account, userData, credentials?.password)) diff --git a/app/src/main/kotlin/at/bitfire/davdroid/settings/AccountSettings.kt b/app/src/main/kotlin/at/bitfire/davdroid/settings/AccountSettings.kt index b114c024b..113a0157a 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/settings/AccountSettings.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/settings/AccountSettings.kt @@ -22,9 +22,12 @@ import at.bitfire.vcard4android.GroupMethod import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent import foundation.e.accountmanager.AccountTypes -import foundation.e.accountmanager.pref.AuthStatePrefUtils +import foundation.e.accountmanager.network.OAuthMurena import net.openid.appauth.AuthState import java.util.Collections import java.util.logging.Level @@ -128,11 +131,15 @@ class AccountSettings @AssistedInject constructor( credentials.authState?.let { authState -> updateAuthState(authState) } + + OAuthMurena.onAccountUpdate(accountManager, account) } fun updateAuthState(authState: AuthState) { - accountManager.setAndVerifyUserData(account, KEY_AUTH_STATE, authState.jsonSerializeString()) - AuthStatePrefUtils.saveAuthState(context, account, authState.jsonSerializeString()) + OAuthMurena.newAuthState(authState)?.let { authState -> + accountManager.setAndVerifyUserData(account, KEY_AUTH_STATE, authState.jsonSerializeString()) + OAuthMurena.saveAuthState(context, account, authState) + } } /** diff --git a/app/src/main/kotlin/at/bitfire/davdroid/sync/worker/BaseSyncWorker.kt b/app/src/main/kotlin/at/bitfire/davdroid/sync/worker/BaseSyncWorker.kt index 51d0ca2aa..3cf6d8988 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/sync/worker/BaseSyncWorker.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/sync/worker/BaseSyncWorker.kt @@ -175,7 +175,7 @@ abstract class BaseSyncWorker( } } SyncDataType.EMAIL -> { - AccountHelper.syncMailAccounts(applicationContext) + AccountHelper.notifyMailAccountAdded(applicationContext) return Result.success() } SyncDataType.MEDIA, diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/account/AccountSettingsModel.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/AccountSettingsModel.kt index 84146d0c8..154697149 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/account/AccountSettingsModel.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/AccountSettingsModel.kt @@ -235,11 +235,10 @@ class AccountSettingsModel @AssistedInject constructor( fun updateCredentials(credentials: Credentials) = CoroutineScope(defaultDispatcher).launch { accountSettings.credentials(credentials) - AccountHelper.syncMailAccounts(context) + AccountHelper.notifyMailAccountAdded(context) reload() } - fun updateTimeRangePastDays(days: Int?) = CoroutineScope(defaultDispatcher).launch { accountSettings.setTimeRangePastDays(days) reload() diff --git a/app/src/main/java/com/nextcloud/android/sso/BinderDependencies.kt b/app/src/main/kotlin/com/nextcloud/android/sso/BinderDependencies.kt similarity index 90% rename from app/src/main/java/com/nextcloud/android/sso/BinderDependencies.kt rename to app/src/main/kotlin/com/nextcloud/android/sso/BinderDependencies.kt index 2caf3845f..b1f5e057f 100644 --- a/app/src/main/java/com/nextcloud/android/sso/BinderDependencies.kt +++ b/app/src/main/kotlin/com/nextcloud/android/sso/BinderDependencies.kt @@ -17,6 +17,7 @@ */ package com.nextcloud.android.sso +import at.bitfire.davdroid.network.OAuthInterceptor import at.bitfire.davdroid.settings.AccountSettings import dagger.hilt.EntryPoint import dagger.hilt.InstallIn @@ -26,4 +27,5 @@ import dagger.hilt.components.SingletonComponent @InstallIn(SingletonComponent::class) interface BinderDependencies { fun accountSettingsFactory(): AccountSettings.Factory + fun oAuthInterceptorFactory(): OAuthInterceptor.Factory } diff --git a/app/src/main/kotlin/com/nextcloud/android/sso/InputStreamBinder.kt b/app/src/main/kotlin/com/nextcloud/android/sso/InputStreamBinder.kt new file mode 100644 index 000000000..a2e6d7a64 --- /dev/null +++ b/app/src/main/kotlin/com/nextcloud/android/sso/InputStreamBinder.kt @@ -0,0 +1,426 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2019 David Luhmer + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + * + * More information here: https://github.com/abeluck/android-streams-ipc + */ +package com.nextcloud.android.sso + +import android.accounts.Account +import android.accounts.AccountManager +import android.content.Context +import android.net.Uri +import android.os.ParcelFileDescriptor +import android.text.TextUtils +import at.bitfire.davdroid.network.OAuthInterceptor +import at.bitfire.davdroid.settings.AccountSettings +import com.nextcloud.android.sso.aidl.IInputStreamService +import com.nextcloud.android.sso.aidl.NextcloudRequest +import com.nextcloud.android.sso.aidl.ParcelFileDescriptorUtil +import com.nextcloud.android.utils.EncryptionUtils.generateSHA512 +import com.owncloud.android.lib.common.OwnCloudAccount +import com.owncloud.android.lib.common.OwnCloudClient +import com.owncloud.android.lib.common.OwnCloudClientManagerFactory +import com.owncloud.android.lib.common.operations.RemoteOperation +import dagger.hilt.android.EntryPointAccessors +import foundation.e.accountmanager.AccountTypes +import foundation.e.accountmanager.utils.AccountHelper +import kotlinx.coroutines.runBlocking +import org.apache.commons.httpclient.HttpConnection +import org.apache.commons.httpclient.HttpMethodBase +import org.apache.commons.httpclient.HttpState +import org.apache.commons.httpclient.NameValuePair +import org.apache.commons.httpclient.methods.DeleteMethod +import org.apache.commons.httpclient.methods.GetMethod +import org.apache.commons.httpclient.methods.HeadMethod +import org.apache.commons.httpclient.methods.InputStreamRequestEntity +import org.apache.commons.httpclient.methods.PostMethod +import org.apache.commons.httpclient.methods.PutMethod +import org.apache.commons.httpclient.methods.RequestEntity +import org.apache.commons.httpclient.methods.StringRequestEntity +import org.apache.jackrabbit.webdav.DavConstants +import org.apache.jackrabbit.webdav.client.methods.MkColMethod +import org.apache.jackrabbit.webdav.client.methods.PropFindMethod +import org.apache.jackrabbit.webdav.property.DavPropertyNameSet +import org.jetbrains.annotations.Blocking +import java.io.BufferedReader +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.IOException +import java.io.InputStream +import java.io.InputStreamReader +import java.io.ObjectInputStream +import java.io.ObjectOutputStream +import java.io.SequenceInputStream +import java.io.Serializable +import java.util.logging.Level +import java.util.logging.Logger + + +/** + * Stream binder to pass usable InputStreams across the process boundary in Android. + */ +class InputStreamBinder(private val context: Context) : IInputStreamService.Stub() { + private val logger: Logger = Logger.getLogger(this.javaClass.name) + + private val accountSettingsFactory: AccountSettings.Factory by lazy { + EntryPointAccessors.fromApplication( + context.applicationContext, + BinderDependencies::class.java + ).accountSettingsFactory() + } + + private val oAuthInterceptorFactory: OAuthInterceptor.Factory by lazy { + EntryPointAccessors.fromApplication( + context.applicationContext, + BinderDependencies::class.java + ).oAuthInterceptorFactory() + } + + @Blocking + fun refreshToken(account: Account) = runBlocking { + val accountSettings = accountSettingsFactory.create(account) + + val credentials = accountSettings.credentials() + credentials.authState ?: run { + logger.log(Level.FINE, "No AuthState for $account") + return@runBlocking + } + + val interceptor = oAuthInterceptorFactory.create( + readAuthState = { credentials.authState }, + writeAuthState = { newAuthState -> + accountSettings.credentials(credentials.copy(authState = newAuthState)) + } + ) + + try { + val token = interceptor.provideAccessToken() + logger.log(Level.FINE, "Token refreshed: $token") + } catch (t: Throwable) { + logger.warning("Couldn't update AuthState for account: $account — ${t.message}") + } + } + + override fun performNextcloudRequestV2(input: ParcelFileDescriptor?): ParcelFileDescriptor? { + return performNextcloudRequestAndBodyStreamV2(input, null) + } + + override fun performNextcloudRequestAndBodyStreamV2( + input: ParcelFileDescriptor?, + requestBodyParcelFileDescriptor: ParcelFileDescriptor? + ): ParcelFileDescriptor? { + // read the input + val `is`: InputStream = ParcelFileDescriptor.AutoCloseInputStream(input) + + val requestBodyInputStream: InputStream? = if (requestBodyParcelFileDescriptor != null) ParcelFileDescriptor.AutoCloseInputStream(requestBodyParcelFileDescriptor) else null + var exception: Exception? = null + var response = Response() + + try { + // Start request and catch exceptions + val request = deserializeObjectAndCloseStream(`is`) + response = processRequestV2(request, requestBodyInputStream) + } catch (e: Exception) { + logger.log(Level.SEVERE, "Error during Nextcloud request", e) + exception = e + } + + try { + // Write exception to the stream followed by the actual network stream + val exceptionStream: InputStream = serializeObjectToInputStreamV2(exception, response.plainHeadersString()) + val resultStream: InputStream = SequenceInputStream(exceptionStream, response.body()) + + return ParcelFileDescriptorUtil.pipeFrom( + resultStream + ) { thread: Thread? -> logger.log(Level.WARNING, "Done sending result") } + } catch (e: IOException) { + logger.log(Level.SEVERE, "Error while sending response back to client app", e) + } + return null + } + + override fun performNextcloudRequest(input: ParcelFileDescriptor?): ParcelFileDescriptor? { + return performNextcloudRequestAndBodyStreamV2(input, null) + } + + override fun performNextcloudRequestAndBodyStream( + input: ParcelFileDescriptor?, + requestBodyParcelFileDescriptor: ParcelFileDescriptor? + ): ParcelFileDescriptor? { + return performNextcloudRequestAndBodyStreamV2(input, requestBodyParcelFileDescriptor) + } + + private fun serializeObjectToInputStreamV2(exception: Exception?, headers: String?): ByteArrayInputStream { + var baosByteArray = ByteArray(0) + try { + ByteArrayOutputStream().use { baos -> + ObjectOutputStream(baos).use { oos -> + oos.writeObject(exception) + oos.writeObject(headers) + oos.flush() + oos.close() + baosByteArray = baos.toByteArray() + } + } + } catch (e: IOException) { + logger.log(Level.SEVERE, "Error while sending response back to client app", e) + } + + return ByteArrayInputStream(baosByteArray) + } + + private fun deserializeObjectAndCloseStream(isInput: InputStream): T { + ObjectInputStream(isInput).use { ois -> + @Suppress("UNCHECKED_CAST") + return ois.readObject() as T + } + } + + class NCPropFindMethod internal constructor(uri: String?, propfindType: Int, depth: Int) : PropFindMethod(uri, propfindType, DavPropertyNameSet(), depth) { + override fun processResponseBody(httpState: HttpState?, httpConnection: HttpConnection?) { + // Do not process the response body here. Instead pass it on to client app. + } + } + + private fun buildMethod(request: NextcloudRequest, baseUri: Uri?, requestBodyInputStream: InputStream?): HttpMethodBase { + val requestUrl = baseUri.toString() + request.url + val method: HttpMethodBase + when (request.method) { + "GET" -> method = GetMethod(requestUrl) + "POST" -> { + method = PostMethod(requestUrl) + if (requestBodyInputStream != null) { + val requestEntity: RequestEntity = InputStreamRequestEntity(requestBodyInputStream) + method.setRequestEntity(requestEntity) + } else if (request.requestBody != null) { + val requestEntity = StringRequestEntity( + request.requestBody, + CONTENT_TYPE_APPLICATION_JSON, + CHARSET_UTF8 + ) + method.setRequestEntity(requestEntity) + } + } + + "PATCH" -> { + method = PatchMethod(requestUrl) + if (requestBodyInputStream != null) { + val requestEntity: RequestEntity = InputStreamRequestEntity(requestBodyInputStream) + method.setRequestEntity(requestEntity) + } else if (request.requestBody != null) { + val requestEntity = StringRequestEntity( + request.requestBody, + CONTENT_TYPE_APPLICATION_JSON, + CHARSET_UTF8 + ) + method.setRequestEntity(requestEntity) + } + } + + "PUT" -> { + method = PutMethod(requestUrl) + if (requestBodyInputStream != null) { + val requestEntity: RequestEntity = InputStreamRequestEntity(requestBodyInputStream) + method.setRequestEntity(requestEntity) + } else if (request.requestBody != null) { + val requestEntity = StringRequestEntity( + request.requestBody, + CONTENT_TYPE_APPLICATION_JSON, + CHARSET_UTF8 + ) + method.setRequestEntity(requestEntity) + } + } + + "DELETE" -> method = DeleteMethod(requestUrl) + "PROPFIND" -> { + method = NCPropFindMethod(requestUrl, DavConstants.PROPFIND_ALL_PROP, DavConstants.DEPTH_1) + if (request.requestBody != null) { + //text/xml; charset=UTF-8 is taken from XmlRequestEntity... Should be application/xml + val requestEntity = StringRequestEntity( + request.requestBody, + "text/xml; charset=UTF-8", + CHARSET_UTF8 + ) + (method as PropFindMethod).setRequestEntity(requestEntity) + } + } + + "MKCOL" -> method = MkColMethod(requestUrl) + "HEAD" -> method = HeadMethod(requestUrl) + else -> throw UnsupportedOperationException(Constants.EXCEPTION_UNSUPPORTED_METHOD) + + } + return method + } + + /* + * for non ocs/dav requests (nextcloud app: ex: notes app), when OIDC is used, we need to pass an special header. + * We should not pass this header for ocs/dav requests as it can cause session cookie not being used for those request. + * + * These nextcloud app request paths contain `/index.php/apps/` on them. + */ + private fun shouldAddHeaderForOidcLogin(account: Account, path: String): Boolean { + if (!AccountHelper.isOidcAccount(context, account)) { + return false + } + + return path.contains("/index.php/apps/") + } + + private fun processRequestV2(request: NextcloudRequest, requestBodyInputStream: InputStream?): Response { + val account = AccountHelper.getAccountByName(context, request.accountName) + checkNotNull(account) { Constants.EXCEPTION_ACCOUNT_NOT_FOUND } + + // Validate token + check(isValid(request)) { Constants.EXCEPTION_INVALID_TOKEN } + + // Validate URL + if (request.url.isEmpty() || request.url.firstOrNull() != PATH_SEPARATOR) { + throw IllegalStateException( + Constants.EXCEPTION_INVALID_REQUEST_URL, + IllegalStateException("URL need to start with a /") + ) + } + + if (AccountHelper.isOidcAccount(context, account)) { + // Blocking call + refreshToken(account) + } + + val ownCloudClientManager = OwnCloudClientManagerFactory.getDefaultSingleton() + val ocAccount = OwnCloudAccount(account, context) + val client = ownCloudClientManager.getClientFor(ocAccount, context, OwnCloudClient.DONT_USE_COOKIES) + client.connectionTimeout = 60000 + + val method = buildMethod(request, client.baseUri, requestBodyInputStream) + method.setQueryString(convertListToNVP(request.parameterV2)) + + for (header in request.header.entries) { + // https://stackoverflow.com/a/3097052 + method.addRequestHeader(header.key, TextUtils.join(",", header.value)) + } + + method.setRequestHeader( + RemoteOperation.OCS_API_HEADER, + RemoteOperation.OCS_API_HEADER_VALUE + ) + + if (shouldAddHeaderForOidcLogin(account, request.url)) { + method.setRequestHeader( + RemoteOperation.OIDC_LOGIN_WITH_TOKEN, + RemoteOperation.OIDC_LOGIN_WITH_TOKEN_VALUE + ) + } + + client.isFollowRedirects = request.isFollowRedirects + val status = client.executeMethod(method) + + ownCloudClientManager.saveAllClients(context, account.type) + + // Check if status code is 2xx --> https://en.wikipedia.org/wiki/List_of_HTTP_status_codes#2xx_Success + if (status >= HTTP_STATUS_CODE_OK && status < HTTP_STATUS_CODE_MULTIPLE_CHOICES) { + return Response(method) + } else { + val inputStream = method.getResponseBodyAsStream() + var total: String? = "No response body" + + // If response body is available + if (inputStream != null) { + total = inputStreamToString(inputStream) + logger.log(Level.WARNING, total) + } + + method.releaseConnection() + throw IllegalStateException( + Constants.EXCEPTION_HTTP_REQUEST_FAILED, + IllegalStateException( + status.toString(), + IllegalStateException(total) + ) + ) + } + } + + private fun isValid(request: NextcloudRequest): Boolean { + val callingPackageNames = context.packageManager.getPackagesForUid(getCallingUid()) + + val sharedPreferences = context.getSharedPreferences( + Constants.SSO_SHARED_PREFERENCE, + Context.MODE_PRIVATE + ) + checkNotNull(callingPackageNames) + for (callingPackageName in callingPackageNames) { + val hash: String = sharedPreferences.getString(callingPackageName + DELIMITER + request.accountName, "")!! + if (hash.isEmpty()) continue + if (validateToken(hash, request.token)) { + return true + } + } + return false + } + + private fun validateToken(hash: String, token: String): Boolean { + check(hash.contains("$")) { Constants.EXCEPTION_INVALID_TOKEN } + + val salt = hash.split("\\$".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()[1] // TODO extract "$" + + val newHash = generateSHA512(token, salt) + + // As discussed with Lukas R. at the Nextcloud Conf 2018, always compare whole strings + // and don't exit prematurely if the string does not match anymore to prevent timing-attacks + return isEqual(hash.toByteArray(), newHash.toByteArray()) + } + + // Taken from http://codahale.com/a-lesson-in-timing-attacks/ + private fun isEqual(a: ByteArray, b: ByteArray): Boolean { + if (a.size != b.size) { + return false + } + + var result = 0 + for (i in a.indices) { + result = result or (a[i].toInt() xor b[i].toInt()) + } + return result == 0 + } + + private fun inputStreamToString(inputStream: InputStream?): String? { + try { + val total = StringBuilder() + val reader = BufferedReader(InputStreamReader(inputStream)) + var line = reader.readLine() + while (line != null) { + total.append(line).append('\n') + line = reader.readLine() + } + return total.toString() + } catch (e: Exception) { + return e.message + } + } + + fun convertListToNVP(list: MutableCollection): Array { + val nvp = arrayOfNulls(list.size) + var i = 0 + for (pair in list) { + nvp[i] = NameValuePair(pair.key, pair.value) + i++ + } + return nvp + } + + companion object { + private const val CONTENT_TYPE_APPLICATION_JSON = "application/json" + private const val CHARSET_UTF8 = "UTF-8" + + private const val HTTP_STATUS_CODE_OK = 200 + private const val HTTP_STATUS_CODE_MULTIPLE_CHOICES = 300 + + private const val PATH_SEPARATOR = '/' + const val DELIMITER: String = "_" + } +} diff --git a/app/src/main/kotlin/com/nextcloud/android/sso/PatchMethod.kt b/app/src/main/kotlin/com/nextcloud/android/sso/PatchMethod.kt new file mode 100644 index 000000000..b8a890349 --- /dev/null +++ b/app/src/main/kotlin/com/nextcloud/android/sso/PatchMethod.kt @@ -0,0 +1,49 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2021 Timo Triebensky + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + * + * More information here: https://github.com/abeluck/android-streams-ipc + */ +package com.nextcloud.android.sso + +import org.apache.commons.httpclient.methods.ByteArrayRequestEntity +import org.apache.commons.httpclient.methods.PostMethod +import org.apache.commons.httpclient.methods.RequestEntity +import org.apache.commons.httpclient.util.EncodingUtil +import java.util.Vector + +class PatchMethod : PostMethod { + private val params = Vector() + + constructor() : super() + constructor(uri: String?) : super(uri) + + /** Always returns "PATCH". */ + override fun getName(): String = "PATCH" + + /** True if request body exists. */ + override fun hasRequestContent(): Boolean = + params.isNotEmpty() || super.hasRequestContent() + + /** Clears request body. */ + override fun clearRequestBody() { + params.clear() + super.clearRequestBody() + } + + /** Creates request entity from params or defaults to super. */ + override fun generateRequestEntity(): RequestEntity? { + return if (params.isNotEmpty()) { + val content = EncodingUtil.formUrlEncode(getParameters(), requestCharSet) + ByteArrayRequestEntity( + EncodingUtil.getAsciiBytes(content), + FORM_URL_ENCODED_CONTENT_TYPE + ) + } else { + super.generateRequestEntity() + } + } +} diff --git a/app/src/main/kotlin/com/nextcloud/android/sso/Response.kt b/app/src/main/kotlin/com/nextcloud/android/sso/Response.kt new file mode 100644 index 000000000..7569d888c --- /dev/null +++ b/app/src/main/kotlin/com/nextcloud/android/sso/Response.kt @@ -0,0 +1,44 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2019 Tobias Kaminsky + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.android.sso + +import org.apache.commons.httpclient.Header +import org.apache.commons.httpclient.HttpMethodBase +import org.json.JSONArray +import org.json.JSONObject +import java.io.ByteArrayInputStream +import java.io.InputStream + +class Response { + + private val body: InputStream + private val headers: Array
+ private val method: HttpMethodBase? + + constructor() { + headers = emptyArray() + body = ByteArrayInputStream(ByteArray(0)) // empty stream + method = null + } + + constructor(methodBase: HttpMethodBase) { + method = methodBase + body = methodBase.responseBodyAsStream ?: ByteArrayInputStream(ByteArray(0)) + headers = methodBase.responseHeaders + } + + /** Returns headers as JSON string, or "[]" if invalid. */ + fun plainHeadersString(): String { + if (headers.any { it.name == null || it.value == null }) return "[]" + return JSONArray(headers.map { JSONObject().put("name", it.name).put("value", it.value) }) + .toString() + } + + fun body(): InputStream = body + fun method(): HttpMethodBase? = method +} diff --git a/app/src/main/kotlin/com/nextcloud/android/utils/EncryptionUtils.kt b/app/src/main/kotlin/com/nextcloud/android/utils/EncryptionUtils.kt new file mode 100644 index 000000000..d75564f08 --- /dev/null +++ b/app/src/main/kotlin/com/nextcloud/android/utils/EncryptionUtils.kt @@ -0,0 +1,88 @@ +/* + * Copyright (C) 2025 e Foundation + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ +package com.nextcloud.android.utils + +import android.util.Base64 +import java.security.MessageDigest +import java.security.NoSuchAlgorithmException +import java.security.SecureRandom +import java.util.logging.Level +import java.util.logging.Logger + +object EncryptionUtils { + const val SALT_LENGTH: Int = 40 + + private const val HASH_DELIMITER = "$" + + private val logger: Logger = Logger.getLogger(EncryptionUtils::class.java.getName()) + + /** + * Generate a SHA512 with appended salt + * + * @param token token to be hashed + * @return SHA512 with appended salt, delimiter HASH_DELIMITER + */ + fun generateSHA512(token: String): String { + val salt = encodeBytesToBase64String(randomBytes(SALT_LENGTH)) + + return generateSHA512(token, salt) + } + + /** + * Generate a SHA512 with appended salt + * + * @param token token to be hashed + * @return SHA512 with appended salt, delimiter HASH_DELIMITER + */ + @JvmStatic + fun generateSHA512(token: String, salt: String): String { + val digest: MessageDigest? + var hashedToken = "" + val hash: ByteArray? + try { + digest = MessageDigest.getInstance("SHA-512") + digest.update(salt.toByteArray()) + hash = digest.digest(token.toByteArray()) + + val stringBuilder = StringBuilder() + for (hashByte in hash) { + stringBuilder.append(((hashByte.toInt() and 0xff) + 0x100).toString(16).substring(1)) + } + + stringBuilder.append(HASH_DELIMITER).append(salt) + + hashedToken = stringBuilder.toString() + } catch (e: NoSuchAlgorithmException) { + logger.log(Level.SEVERE, "Generating SHA512 failed", e) + } + + return hashedToken + } + + fun encodeBytesToBase64String(bytes: ByteArray?): String { + return Base64.encodeToString(bytes, Base64.NO_WRAP) + } + + fun randomBytes(size: Int): ByteArray { + val random = SecureRandom() + val iv = ByteArray(size) + random.nextBytes(iv) + + return iv + } +} diff --git a/app/src/main/java/com/owncloud/android/ui/activity/SsoGrantPermissionEvent.kt b/app/src/main/kotlin/com/owncloud/android/services/AccountManagerService.kt similarity index 52% rename from app/src/main/java/com/owncloud/android/ui/activity/SsoGrantPermissionEvent.kt rename to app/src/main/kotlin/com/owncloud/android/services/AccountManagerService.kt index 2e69754c8..1d423e071 100644 --- a/app/src/main/java/com/owncloud/android/ui/activity/SsoGrantPermissionEvent.kt +++ b/app/src/main/kotlin/com/owncloud/android/services/AccountManagerService.kt @@ -1,5 +1,7 @@ /* * Copyright MURENA SAS 2024 + * Copyright (C) 2025 e Foundation + * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or @@ -11,16 +13,23 @@ * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License - * along with this program. If not, see . + * along with this program. If not, see . + * */ +package com.owncloud.android.services -package com.owncloud.android.ui.activity - -import android.os.Bundle - -sealed class SsoGrantPermissionEvent { +import android.app.Service +import android.content.Intent +import android.os.IBinder +import com.nextcloud.android.sso.InputStreamBinder - data class PermissionGranted(val bundle: Bundle) : SsoGrantPermissionEvent() +class AccountManagerService : Service() { + private var binder: InputStreamBinder? = null - data class PermissionDenied(val errorMessage: String) : SsoGrantPermissionEvent() + override fun onBind(intent: Intent?): IBinder? { + if (binder == null) { + binder = InputStreamBinder(applicationContext) + } + return binder + } } diff --git a/app/src/main/java/com/owncloud/android/ui/activity/SsoGrantPermissionActivity.kt b/app/src/main/kotlin/com/owncloud/android/ui/activity/SsoGrantPermissionActivity.kt similarity index 51% rename from app/src/main/java/com/owncloud/android/ui/activity/SsoGrantPermissionActivity.kt rename to app/src/main/kotlin/com/owncloud/android/ui/activity/SsoGrantPermissionActivity.kt index ba791f31d..07f4f4b7f 100644 --- a/app/src/main/java/com/owncloud/android/ui/activity/SsoGrantPermissionActivity.kt +++ b/app/src/main/kotlin/com/owncloud/android/ui/activity/SsoGrantPermissionActivity.kt @@ -1,5 +1,7 @@ /* * Copyright MURENA SAS 2024 + * Copyright (C) 2025 e Foundation + * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or @@ -11,21 +13,27 @@ * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License - * along with this program. If not, see . + * along with this program. If not, see . + * */ - package com.owncloud.android.ui.activity import android.accounts.Account import android.content.Intent import android.os.Build import android.os.Bundle +import androidx.activity.compose.setContent import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity -import androidx.lifecycle.Lifecycle +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.lifecycle.flowWithLifecycle import androidx.lifecycle.lifecycleScope -import at.bitfire.davdroid.R import com.nextcloud.android.sso.Constants import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.flow.collectLatest @@ -38,31 +46,48 @@ class SsoGrantPermissionActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - setContentView(R.layout.activity_sso_grant_permission) + setContent { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.White), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } + + observePermissionEvents() + validateAccount() + } + + private fun observePermissionEvents() { lifecycleScope.launch { viewModel.permissionEvent .flowWithLifecycle( lifecycle = lifecycle, - minActiveState = Lifecycle.State.CREATED - ).collectLatest { - when (it) { - is SsoGrantPermissionEvent.PermissionGranted -> setSuccessResult(it.bundle) - is SsoGrantPermissionEvent.PermissionDenied -> setCanceledResult(it.errorMessage) + minActiveState = androidx.lifecycle.Lifecycle.State.CREATED + ) + .collectLatest { event -> + when (event) { + is SsoGrantPermissionViewModel.SsoGrantPermissionEvent.PermissionGranted -> { + setSuccessResult(event.bundle) + } + is SsoGrantPermissionViewModel.SsoGrantPermissionEvent.PermissionDenied -> { + setCanceledResult(event.errorMessage) + } } } } - - validateAccount() } private fun validateAccount() { - val account: Account? = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - intent.getParcelableExtra(Constants.NEXTCLOUD_FILES_ACCOUNT, Account::class.java) - } else { - intent.getParcelableExtra(Constants.NEXTCLOUD_FILES_ACCOUNT) - } + val account: Account? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + intent.getParcelableExtra(Constants.NEXTCLOUD_FILES_ACCOUNT, Account::class.java) + } else { + intent.getParcelableExtra(Constants.NEXTCLOUD_FILES_ACCOUNT) + } viewModel.initValidation( callingActivity = callingActivity, @@ -71,15 +96,17 @@ class SsoGrantPermissionActivity : AppCompatActivity() { } private fun setCanceledResult(exception: String) { - val data = Intent() - data.putExtra(Constants.NEXTCLOUD_SSO_EXCEPTION, exception) + val data = Intent().apply { + putExtra(Constants.NEXTCLOUD_SSO_EXCEPTION, exception) + } setResult(RESULT_CANCELED, data) finish() } private fun setSuccessResult(result: Bundle) { - val data = Intent() - data.putExtra(Constants.NEXTCLOUD_SSO, result) + val data = Intent().apply { + putExtra(Constants.NEXTCLOUD_SSO, result) + } setResult(RESULT_OK, data) finish() } diff --git a/app/src/main/java/com/owncloud/android/ui/activity/SsoGrantPermissionViewModel.kt b/app/src/main/kotlin/com/owncloud/android/ui/activity/SsoGrantPermissionViewModel.kt similarity index 65% rename from app/src/main/java/com/owncloud/android/ui/activity/SsoGrantPermissionViewModel.kt rename to app/src/main/kotlin/com/owncloud/android/ui/activity/SsoGrantPermissionViewModel.kt index e3a31c453..74d6a2b25 100644 --- a/app/src/main/java/com/owncloud/android/ui/activity/SsoGrantPermissionViewModel.kt +++ b/app/src/main/kotlin/com/owncloud/android/ui/activity/SsoGrantPermissionViewModel.kt @@ -1,5 +1,7 @@ /* * Copyright MURENA SAS 2024 + * Copyright (C) 2025 e Foundation + * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or @@ -11,9 +13,9 @@ * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License - * along with this program. If not, see . + * along with this program. If not, see . + * */ - package com.owncloud.android.ui.activity import android.accounts.Account @@ -23,12 +25,8 @@ import android.content.Context import android.os.Bundle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import at.bitfire.davdroid.R import at.bitfire.davdroid.db.AppDatabase -import at.bitfire.davdroid.util.UserIdFetcher -import com.nextcloud.android.sso.Constants -import com.nextcloud.android.sso.InputStreamBinder -import com.owncloud.android.utils.EncryptionUtils +import com.nextcloud.android.utils.EncryptionUtils import com.owncloud.android.lib.common.OwnCloudAccount import com.owncloud.android.lib.common.accounts.AccountUtils import dagger.hilt.android.lifecycle.HiltViewModel @@ -42,16 +40,24 @@ import java.util.logging.Level import java.util.logging.Logger import javax.inject.Inject import androidx.core.content.edit +import com.nextcloud.android.sso.Constants +import com.nextcloud.android.sso.InputStreamBinder +import foundation.e.accountmanager.AccountTypes +import foundation.e.accountmanager.utils.UserIdFetcher @HiltViewModel class SsoGrantPermissionViewModel @Inject constructor( @ApplicationContext private val context: Context, private val database: AppDatabase, + private val logger: Logger ) : ViewModel() { - val logger: Logger = Logger.getLogger(this.javaClass.name) + sealed class SsoGrantPermissionEvent { + data class PermissionGranted(val bundle: Bundle) : SsoGrantPermissionEvent() + data class PermissionDenied(val errorMessage: String) : SsoGrantPermissionEvent() + } - private val acceptedAccountTypes = listOf(context.getString(R.string.eelo_account_type)) + private val acceptedAccountTypes = listOf(AccountTypes.Murena.accountType) private val acceptedPackages = listOf("foundation.e.notes") private val _permissionEvent = MutableSharedFlow() @@ -65,90 +71,69 @@ class SsoGrantPermissionViewModel @Inject constructor( } private suspend fun emitPermissionDeniedEvent(message: String) { - _permissionEvent.emit( - SsoGrantPermissionEvent.PermissionDenied( - errorMessage = message - ) - ) + _permissionEvent.emit(SsoGrantPermissionEvent.PermissionDenied(message)) } private suspend fun getCallingPackageName(callingActivity: ComponentName?): String? { - if (callingActivity != null) { - return callingActivity.packageName + return callingActivity?.packageName ?: run { + logger.log(Level.SEVERE, "SsoGrantPermissionViewModel: Calling Package is null") + emitPermissionDeniedEvent(Constants.EXCEPTION_ACCOUNT_ACCESS_DECLINED) + null } - - logger.log(Level.SEVERE, "SsoGrantPermissionViewModel: Calling Package is null") - emitPermissionDeniedEvent(Constants.EXCEPTION_ACCOUNT_ACCESS_DECLINED) - return null } private suspend fun validate(packageName: String?, account: Account?) { if (!isValidRequest(packageName, account)) { logger.log(Level.SEVERE, "SsoGrantPermissionViewModel: Invalid request") emitPermissionDeniedEvent(Constants.EXCEPTION_ACCOUNT_ACCESS_DECLINED) + return } val serverUrl = getServerUrl(account!!) ?: return - val token = UUID.randomUUID().toString().replace("-".toRegex(), "") + val token = UUID.randomUUID().toString().replace("-", "") val userId = getUserId(account) - saveToken( - token = token, - accountName = account.name, - packageName = packageName!! - ) - - passSuccessfulData( - account = account, - token = token, - userId = userId, - serverUrl = serverUrl - ) - + saveToken(token, account.name, packageName!!) + passSuccessfulData(account, token, userId, serverUrl) } private fun isValidRequest(packageName: String?, account: Account?): Boolean { - if (packageName == null || account == null) { - return false - } - - return acceptedPackages.contains(packageName) && acceptedAccountTypes.contains(account.type) + return packageName != null && account != null && + acceptedPackages.contains(packageName) && + acceptedAccountTypes.contains(account.type) } private suspend fun getServerUrl(account: Account): String? { - try { + return try { val ocAccount = OwnCloudAccount(account, context) - return ocAccount.baseUri.toString() + ocAccount.baseUri.toString() } catch (e: AccountUtils.AccountNotFoundException) { - logger.log(Level.SEVERE, "SsoGrantPermissionViewModel: Account not found") + logger.log(Level.SEVERE, "SsoGrantPermissionViewModel: Account not found", e) emitPermissionDeniedEvent(Constants.EXCEPTION_ACCOUNT_NOT_FOUND) + null } - - return null } - private fun getUserId(account: Account): String { + private suspend fun getUserId(account: Account): String { val accountManager = AccountManager.get(context) val userId = accountManager.getUserData(account, AccountUtils.Constants.KEY_USER_ID) + if (!userId.isNullOrBlank()) return userId - if (!userId.isNullOrBlank()) { - return userId - } - - val principalUrl = - database.serviceDao().getByAccountName(account.name)?.principal?.toString() - ?: return account.name + val principalUrl = database.serviceDao() + .getByAccountName(account.name)?.principal?.toString() - return UserIdFetcher.fetch(principalUrl) ?: account.name + return principalUrl?.let { UserIdFetcher.fetch(it) } ?: account.name } private fun saveToken(token: String, accountName: String, packageName: String) { - val hashedTokenWithSalt = EncryptionUtils.generateSHA512(token) - val sharedPreferences = - context.getSharedPreferences(Constants.SSO_SHARED_PREFERENCE, Context.MODE_PRIVATE) + val hashedToken = EncryptionUtils.generateSHA512(token) + val sharedPreferences = context.getSharedPreferences( + Constants.SSO_SHARED_PREFERENCE, + Context.MODE_PRIVATE + ) sharedPreferences.edit { - putString(packageName + InputStreamBinder.DELIMITER + accountName, hashedTokenWithSalt) + putString("$packageName${InputStreamBinder.DELIMITER}$accountName", hashedToken) } } @@ -167,10 +152,6 @@ class SsoGrantPermissionViewModel @Inject constructor( putString(Constants.SSO_SERVER_URL, serverUrl) } - _permissionEvent.emit( - SsoGrantPermissionEvent.PermissionGranted( - bundle = result - ) - ) + _permissionEvent.emit(SsoGrantPermissionEvent.PermissionGranted(result)) } } diff --git a/app/src/main/kotlin/foundation/e/accountmanager/auth/AccountReceiver.kt b/app/src/main/kotlin/foundation/e/accountmanager/auth/AccountReceiver.kt index 8603d6117..b5f8c6682 100644 --- a/app/src/main/kotlin/foundation/e/accountmanager/auth/AccountReceiver.kt +++ b/app/src/main/kotlin/foundation/e/accountmanager/auth/AccountReceiver.kt @@ -23,7 +23,6 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import at.bitfire.davdroid.settings.AccountSettings -import at.bitfire.davdroid.settings.AccountSettings.Companion.KEY_AUTH_STATE import foundation.e.accountmanager.AccountTypes import foundation.e.accountmanager.pref.AuthStatePrefUtils import foundation.e.accountmanager.ui.setup.MurenaLogoutActivity @@ -52,8 +51,9 @@ class AccountReceiver : BroadcastReceiver() { murenaAccounts.forEach { account -> logger.log(Level.INFO, "Account change detected for ${account.name}") - val authState = accountManager.getUserData(account, KEY_AUTH_STATE) + val authState = accountManager.getUserData(account, AccountSettings.KEY_AUTH_STATE) AuthStatePrefUtils.saveAuthState(context, account, authState) + AccountHelper.notifyEApps(context, account.name) } } @@ -71,8 +71,7 @@ class AccountReceiver : BroadcastReceiver() { clearOidcSession(context, accountName, accountType) val accountManager = AccountManager.get(context) - val addressBooks = accountManager.getAccountsByType(AccountTypes.Murena.addressBookType) - addressBooks.forEach { + accountManager.getAccountsByType(AccountTypes.Murena.addressBookType).forEach { accountManager.removeAccountExplicitly(it) } } @@ -102,6 +101,10 @@ class AccountReceiver : BroadcastReceiver() { PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) - pendingIntent.sendWithBackgroundLaunchAllowed() + try { + pendingIntent.sendWithBackgroundLaunchAllowed() + } catch (e: PendingIntent.CanceledException) { + logger.log(Level.SEVERE, "Failed to start logout activity", e) + } } } diff --git a/app/src/main/kotlin/foundation/e/accountmanager/network/OAuthMurena.kt b/app/src/main/kotlin/foundation/e/accountmanager/network/OAuthMurena.kt index eb6273e1b..701e7ebe7 100644 --- a/app/src/main/kotlin/foundation/e/accountmanager/network/OAuthMurena.kt +++ b/app/src/main/kotlin/foundation/e/accountmanager/network/OAuthMurena.kt @@ -17,22 +17,33 @@ */ package foundation.e.accountmanager.network +import android.accounts.Account +import android.accounts.AccountManager +import android.content.Context import android.net.Uri +import android.os.Bundle import androidx.core.net.toUri import at.bitfire.davdroid.BuildConfig +import at.bitfire.davdroid.settings.Credentials +import at.bitfire.davdroid.sync.account.setAndVerifyUserData +import foundation.e.accountmanager.pref.AuthStatePrefUtils +import com.owncloud.android.lib.common.accounts.AccountUtils +import foundation.e.accountmanager.AccountTypes import net.openid.appauth.AuthState import net.openid.appauth.AuthorizationRequest import net.openid.appauth.AuthorizationServiceConfiguration import net.openid.appauth.EndSessionRequest import net.openid.appauth.ResponseTypeValues import java.net.URI +import java.util.logging.Level +import java.util.logging.Logger import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException import kotlin.coroutines.suspendCoroutine object OAuthMurena { private val SCOPES = arrayOf("openid", "profile", "email", "offline_access") - const val CLIENT_ID = BuildConfig.MURENA_CLIENT_ID + private const val CLIENT_ID = BuildConfig.MURENA_CLIENT_ID private val DOMAIN: String by lazy { URI(BuildConfig.MURENA_BASE_URL).host } val baseUri = "https://$DOMAIN" @@ -40,6 +51,8 @@ object OAuthMurena { val logoutRedirectUri = "${BuildConfig.MURENA_LOGOUT_REDIRECT_URI}:/redirect".toUri() val discoveryUri = BuildConfig.MURENA_DISCOVERY_END_POINT.toUri() + val logger: Logger = Logger.getLogger(this.javaClass.name) + suspend fun fetchOAuthConfigSuspend(discoveryUrl: Uri): AuthorizationServiceConfiguration = suspendCoroutine { cont -> AuthorizationServiceConfiguration.fetchFromUrl(discoveryUrl) { config, ex -> @@ -70,4 +83,37 @@ object OAuthMurena { authState.idToken?.let { setIdTokenHint(it) } }.build() } + + fun onCreateAccount(context: Context, userData: Bundle, account: Account, credentials: Credentials?): Bundle { + if (account.type != AccountTypes.Murena.accountType) return userData + + saveAuthState(context, account, credentials?.authState) + userData.putString(AccountUtils.Constants.KEY_OC_BASE_URL, baseUri) + userData.putString(AccountUtils.Constants.KEY_DISPLAY_NAME, account.name.substringBefore('@')) + return userData + } + + fun onAccountUpdate(accountManager: AccountManager, account: Account) { + if (account.type != AccountTypes.Murena.accountType) return + + accountManager.setAndVerifyUserData(account, AccountUtils.Constants.KEY_OC_BASE_URL, baseUri) + accountManager.setAndVerifyUserData(account, + AccountUtils.Constants.KEY_DISPLAY_NAME, account.name.substringBefore('@')) + } + + fun saveAuthState(context: Context, account: Account, authState: AuthState?) { + val stateJson = authState?.jsonSerializeString() + AuthStatePrefUtils.saveAuthState(context, account, stateJson) + if (BuildConfig.DEBUG) { + logger.log(Level.INFO, "Saved new authState $stateJson") + } + } + + fun newAuthState(authState: AuthState?): AuthState? = + authState?.apply { + needsTokenRefresh = scopeSet?.contains("offline_access") != true + if (BuildConfig.DEBUG) { + logger.log(Level.INFO, "needsTokenRefresh: $needsTokenRefresh") + } + } } diff --git a/app/src/main/kotlin/foundation/e/accountmanager/utils/AccountHelper.kt b/app/src/main/kotlin/foundation/e/accountmanager/utils/AccountHelper.kt index 9ae52d232..6cc4defef 100644 --- a/app/src/main/kotlin/foundation/e/accountmanager/utils/AccountHelper.kt +++ b/app/src/main/kotlin/foundation/e/accountmanager/utils/AccountHelper.kt @@ -24,7 +24,7 @@ import android.app.PendingIntent import android.content.ComponentName import android.content.Context import android.content.Intent -import android.os.Build +import at.bitfire.davdroid.settings.AccountSettings import foundation.e.accountmanager.AccountTypes import foundation.e.accountmanager.sync.SyncBroadcastReceiver import java.util.concurrent.TimeUnit @@ -32,7 +32,11 @@ import java.util.concurrent.TimeUnit object AccountHelper { private const val MAIL_PACKAGE = "foundation.e.mail" private const val MAIL_RECEIVER_CLASS = "com.fsck.k9.account.AccountSyncReceiver" - private const val ACTION_PREFIX = "foundation.e.accountmanager.account." + private const val MAIL_ACTION_PREFIX = "foundation.e.accountmanager.account." + + private const val DRIVE_PACKAGE_NAME = "foundation.e.drive" + private const val DRIVE_ACTION_ADD_ACCOUNT = "$DRIVE_PACKAGE_NAME.action.ADD_ACCOUNT" + private const val DRIVE_RECEIVER_CLASS = "$DRIVE_PACKAGE_NAME.account.receivers.AccountAddedReceiver" fun getAllAccounts(accountManager: AccountManager): Array { val allAccounts = mutableListOf() @@ -50,11 +54,27 @@ object AccountHelper { return allAccounts.toTypedArray() } - fun syncMailAccounts(context: Context) { + fun isOidcAccount(context: Context, account: Account): Boolean { + val accountManager = AccountManager.get(context) + val authState = accountManager.getUserData(account, AccountSettings.KEY_AUTH_STATE) + return !authState.isNullOrBlank() + } + + fun getAccountByName(context: Context, name: String?): Account? { + val accountManager = AccountManager.get(context) + for (account in accountManager.getAccountsByType(AccountTypes.Murena.accountType)) { + if (account.name == name) { + return account + } + } + return null + } + + fun notifyMailAccountAdded(context: Context) { val intent = Intent() intent.addFlags(Intent.FLAG_INCLUDE_STOPPED_PACKAGES) intent.component = ComponentName(MAIL_PACKAGE, MAIL_RECEIVER_CLASS) - intent.action = ACTION_PREFIX + "create" + intent.action = MAIL_ACTION_PREFIX + "create" context.sendBroadcast(intent) } @@ -74,4 +94,19 @@ object AccountHelper { alarmManager.setExact(AlarmManager.RTC_WAKEUP, triggerTime, pendingIntent) } } + + private fun notifyEDriveAccountAdded(context: Context, name: String) { + val intent = Intent(DRIVE_ACTION_ADD_ACCOUNT).apply { + addFlags(Intent.FLAG_INCLUDE_STOPPED_PACKAGES) + component = ComponentName(DRIVE_PACKAGE_NAME, DRIVE_RECEIVER_CLASS) + putExtra(AccountManager.KEY_ACCOUNT_NAME, name) + putExtra(AccountManager.KEY_ACCOUNT_TYPE, AccountTypes.Murena.accountType) + } + context.sendBroadcast(intent) + } + + fun notifyEApps(context: Context, name: String) { + notifyEDriveAccountAdded(context, name) + notifyMailAccountAdded(context) + } } diff --git a/app/src/main/kotlin/at/bitfire/davdroid/util/UserIdFetcher.kt b/app/src/main/kotlin/foundation/e/accountmanager/utils/UserIdFetcher.kt similarity index 90% rename from app/src/main/kotlin/at/bitfire/davdroid/util/UserIdFetcher.kt rename to app/src/main/kotlin/foundation/e/accountmanager/utils/UserIdFetcher.kt index 16c7bf8a9..fbc9224d7 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/util/UserIdFetcher.kt +++ b/app/src/main/kotlin/foundation/e/accountmanager/utils/UserIdFetcher.kt @@ -1,5 +1,7 @@ /* * Copyright MURENA SAS 2024 + * Copyright (C) 2025 e Foundation + * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or @@ -11,10 +13,10 @@ * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License - * along with this program. If not, see . + * along with this program. If not, see . + * */ - -package at.bitfire.davdroid.util +package foundation.e.accountmanager.utils object UserIdFetcher { diff --git a/app/src/main/res/layout/activity_sso_grant_permission.xml b/app/src/main/res/layout/activity_sso_grant_permission.xml deleted file mode 100644 index 054f061d9..000000000 --- a/app/src/main/res/layout/activity_sso_grant_permission.xml +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - - -- GitLab From 4f063f1348ab2ce6d2bc4bf4d0ecb1ce5cd516ea Mon Sep 17 00:00:00 2001 From: Arnau Mora Date: Wed, 13 Aug 2025 08:55:26 +0200 Subject: [PATCH 4/9] Change "Account doesn't exist" toast message to "Account has been removed" for deleting accounts (#1650) * Finish activity after deleting Signed-off-by: Arnau Mora * Added proper toast for when the account is deleted Signed-off-by: Arnau Mora * Simplify logic * Missing fix --------- Signed-off-by: Arnau Mora --- app/src/main/res/values/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6a07e00f1..94d5919ce 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -238,7 +238,7 @@ Push messages are always encrypted. - Account doesn\'t exist + Account has been removed CardDAV CalDAV Webcal -- GitLab From 4bc38422fe405837d8c687bdc3999fa35b769146 Mon Sep 17 00:00:00 2001 From: althafvly Date: Tue, 26 Aug 2025 19:40:28 +0530 Subject: [PATCH 5/9] AM: Add legacy login for testing --- .../ui/setup/MurenaLegacyLogin.kt | 269 ++++++++++++++++++ .../ui/setup/MurenaLegacyLoginModel.kt | 96 +++++++ .../e/accountmanager/ui/setup/MurenaLogin.kt | 51 ++-- .../e/accountmanager/utils/AccountHelper.kt | 6 + app/src/main/res/values/e_strings.xml | 1 + .../ui/setup/StandardLoginTypesProvider.kt | 11 +- 6 files changed, 405 insertions(+), 29 deletions(-) create mode 100644 app/src/main/kotlin/foundation/e/accountmanager/ui/setup/MurenaLegacyLogin.kt create mode 100644 app/src/main/kotlin/foundation/e/accountmanager/ui/setup/MurenaLegacyLoginModel.kt diff --git a/app/src/main/kotlin/foundation/e/accountmanager/ui/setup/MurenaLegacyLogin.kt b/app/src/main/kotlin/foundation/e/accountmanager/ui/setup/MurenaLegacyLogin.kt new file mode 100644 index 000000000..70f5fed82 --- /dev/null +++ b/app/src/main/kotlin/foundation/e/accountmanager/ui/setup/MurenaLegacyLogin.kt @@ -0,0 +1,269 @@ +/* + * Copyright (C) 2025 e Foundation + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ +package foundation.e.accountmanager.ui.setup + +import android.net.Uri +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AccountCircle +import androidx.compose.material.icons.filled.Folder +import androidx.compose.material.icons.filled.Password +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.core.net.toUri +import androidx.hilt.navigation.compose.hiltViewModel +import at.bitfire.davdroid.R +import at.bitfire.davdroid.ui.composable.Assistant +import at.bitfire.davdroid.ui.composable.PasswordTextField +import at.bitfire.davdroid.ui.setup.LoginInfo +import at.bitfire.davdroid.ui.setup.LoginType +import foundation.e.accountmanager.AccountTypes +import foundation.e.accountmanager.utils.AccountHelper + +object MurenaLegacyLogin : LoginType { + + override val title = R.string.legacy_murena_login + + override val helpUrl: Uri + get() = "https://doc.e.foundation/support-topics".toUri() + + override val accountType: String + get() = AccountTypes.Murena.accountType + + @Composable + override fun LoginScreen( + snackbarHostState: SnackbarHostState, + initialLoginInfo: LoginInfo, + onLogin: (LoginInfo) -> Unit + ) { + val context = LocalContext.current + var showAccountDialog by remember { mutableStateOf(false) } + + if (AccountHelper.alreadyHasAccount(context)) { + showAccountDialog = true + } + + if (showAccountDialog) { + MultipleECloudAccountNotAcceptedDialog { + showAccountDialog = false + } + return + } + + val model: MurenaLegacyLoginModel = hiltViewModel( + creationCallback = { factory: MurenaLegacyLoginModel.Factory -> + factory.create(loginInfo = initialLoginInfo) + } + ) + + val uiState = model.uiState + MurenaLegacyLoginScreen( + url = uiState.url, + onSetUrl = model::setUrl, + username = uiState.username, + onSetUsername = model::setUsername, + password = uiState.password, + onSetPassword = model::setPassword, + canContinue = uiState.canContinue, + onLogin = { + if (uiState.canContinue) + onLogin(uiState.asLoginInfo()) + } + ) + } +} + +@Composable +fun MurenaLegacyLoginScreen( + url: String, + onSetUrl: (String) -> Unit = {}, + username: String, + onSetUsername: (String) -> Unit = {}, + password: String, + onSetPassword: (String) -> Unit = {}, + canContinue: Boolean, + onLogin: () -> Unit = {} +) { + val focusRequester = remember { FocusRequester() } + + Assistant( + nextLabel = stringResource(R.string.login_login), + nextEnabled = canContinue, + onNext = onLogin + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally) { + Spacer(modifier = Modifier.height(40.dp)) + // Murena + e logo (replace with actual logos if available) + Icon( + painter = painterResource(id = R.drawable.ic_murena_logo), + contentDescription = stringResource(R.string.eelo_account_name), + tint = Color.Unspecified // To display original logo colors + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Text( + text = stringResource(R.string.login_eelo_title), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyMedium + ) + + Spacer(modifier = Modifier.height(24.dp)) + + OutlinedTextField( + value = url, + onValueChange = onSetUrl, + label = { Text(stringResource(R.string.login_base_url)) }, + placeholder = { Text("murena.io") }, + singleLine = true, + leadingIcon = { + Icon(Icons.Default.Folder, null) + }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Uri, + imeAction = ImeAction.Next + ), + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester) + ) + + OutlinedTextField( + value = username, + onValueChange = onSetUsername, + label = { Text(stringResource(R.string.login_user_id)) }, + singleLine = true, + leadingIcon = { + Icon(Icons.Default.AccountCircle, null) + }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Email, + imeAction = ImeAction.Next + ), + modifier = Modifier.fillMaxWidth() + ) + + // Suggestion buttons to add domain suffixes + if (!username.contains("@")) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp) + ) { + OutlinedButton( + onClick = { onSetUsername("$username@murena.io") }, + contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp), + shape = RoundedCornerShape(12.dp), + modifier = Modifier + .defaultMinSize(minHeight = 32.dp) + ) { + Text( + text = "@murena.io", + style = MaterialTheme.typography.bodySmall + ) + } + + OutlinedButton( + onClick = { onSetUsername("$username@e.email") }, + contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp), + shape = RoundedCornerShape(12.dp), + modifier = Modifier + .defaultMinSize(minHeight = 32.dp) + ) { + Text( + text = "@e.email", + style = MaterialTheme.typography.bodySmall + ) + } + } + } + + PasswordTextField( + password = password, + onPasswordChange = onSetPassword, + labelText = stringResource(R.string.login_password), + leadingIcon = { + Icon(Icons.Default.Password, null) + }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions { + if (canContinue) onLogin() + }, + modifier = Modifier.fillMaxWidth() + ) + } + } + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } +} + +@Composable +@Preview +fun MurenaLegacyLoginScreen_Preview() { + MurenaLegacyLoginScreen( + url = "", + username = "user", + password = "", + canContinue = false + ) +} diff --git a/app/src/main/kotlin/foundation/e/accountmanager/ui/setup/MurenaLegacyLoginModel.kt b/app/src/main/kotlin/foundation/e/accountmanager/ui/setup/MurenaLegacyLoginModel.kt new file mode 100644 index 000000000..a0ad0839c --- /dev/null +++ b/app/src/main/kotlin/foundation/e/accountmanager/ui/setup/MurenaLegacyLoginModel.kt @@ -0,0 +1,96 @@ +/* + * Copyright (C) 2025 e Foundation + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ +package foundation.e.accountmanager.ui.setup + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import at.bitfire.davdroid.BuildConfig +import at.bitfire.davdroid.settings.Credentials +import at.bitfire.davdroid.ui.setup.LoginInfo +import at.bitfire.davdroid.util.DavUtils.toURIorNull +import at.bitfire.davdroid.util.trimToNull +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.lifecycle.HiltViewModel +import java.net.URI + +@HiltViewModel(assistedFactory = MurenaLegacyLoginModel.Factory::class) +class MurenaLegacyLoginModel @AssistedInject constructor( + @Assisted val initialLoginInfo: LoginInfo +): ViewModel() { + + @AssistedFactory + interface Factory { + fun create(loginInfo: LoginInfo): MurenaLegacyLoginModel + } + + val defaultUrl = "${URI(BuildConfig.MURENA_BASE_URL).host}" + + data class UiState( + val url: String = "", + val username: String = "", + val password: String = "" + ) { + + val urlWithPrefix = + if (url.startsWith("http://") || url.startsWith("https://")) + url + else + "https://$url" + val uri = urlWithPrefix.trim().toURIorNull() + + val canContinue = uri != null && username.isNotEmpty() && password.isNotEmpty() + + fun asLoginInfo(): LoginInfo = + LoginInfo( + baseUri = uri, + credentials = Credentials( + username = username.trimToNull(), + password = password.trimToNull()?.toCharArray() + ) + ) + + } + + var uiState by mutableStateOf(UiState()) + private set + + init { + uiState = UiState( + url = initialLoginInfo.baseUri?.toString()?.removePrefix("https://") ?: defaultUrl, + username = initialLoginInfo.credentials?.username ?: "", + password = initialLoginInfo.credentials?.password?.concatToString() ?: "" + ) + } + + fun setUrl(url: String) { + uiState = uiState.copy(url = url) + } + + fun setUsername(username: String) { + uiState = uiState.copy(username = username) + } + + fun setPassword(password: String) { + uiState = uiState.copy(password = password) + } + +} diff --git a/app/src/main/kotlin/foundation/e/accountmanager/ui/setup/MurenaLogin.kt b/app/src/main/kotlin/foundation/e/accountmanager/ui/setup/MurenaLogin.kt index 651d9a2fb..56a57c8eb 100644 --- a/app/src/main/kotlin/foundation/e/accountmanager/ui/setup/MurenaLogin.kt +++ b/app/src/main/kotlin/foundation/e/accountmanager/ui/setup/MurenaLogin.kt @@ -79,6 +79,7 @@ import at.bitfire.davdroid.ui.setup.LoginInfo import at.bitfire.davdroid.ui.setup.LoginType import foundation.e.accountmanager.AccountTypes import foundation.e.accountmanager.network.OAuthMurena +import foundation.e.accountmanager.utils.AccountHelper import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -95,33 +96,6 @@ object MurenaLogin : LoginType { override val accountType: String get() = AccountTypes.Murena.accountType - @Composable - fun MultipleECloudAccountNotAcceptedDialog(onDismiss: () -> Unit) { - val activity = LocalActivity.current - - AlertDialog( - onDismissRequest = {}, - confirmButton = { - TextButton(onClick = { - activity?.finish() - onDismiss() - }) { - Text(stringResource(id = android.R.string.ok)) - } - }, - text = { - Text(text = stringResource(R.string.multiple_ecloud_account_not_permitted_message)) - }, - tonalElevation = 8.dp - ) - } - - fun alreadyHasAccount(context: Context): Boolean { - val accountManager = AccountManager.get(context) - val accounts = accountManager.getAccountsByType(accountType) - return accounts.isNotEmpty() - } - @Composable override fun LoginScreen( snackbarHostState: SnackbarHostState, @@ -131,7 +105,7 @@ object MurenaLogin : LoginType { val context = LocalContext.current var showAccountDialog by remember { mutableStateOf(false) } - if (alreadyHasAccount(context)) { + if (AccountHelper.alreadyHasAccount(context)) { showAccountDialog = true } @@ -204,6 +178,27 @@ object MurenaLogin : LoginType { } } +@Composable +fun MultipleECloudAccountNotAcceptedDialog(onDismiss: () -> Unit) { + val activity = LocalActivity.current + + AlertDialog( + onDismissRequest = {}, + confirmButton = { + TextButton(onClick = { + activity?.finish() + onDismiss() + }) { + Text(stringResource(id = android.R.string.ok)) + } + }, + text = { + Text(text = stringResource(R.string.multiple_ecloud_account_not_permitted_message)) + }, + tonalElevation = 8.dp + ) +} + @Composable fun MurenaLoginScreen( email: String, diff --git a/app/src/main/kotlin/foundation/e/accountmanager/utils/AccountHelper.kt b/app/src/main/kotlin/foundation/e/accountmanager/utils/AccountHelper.kt index 6cc4defef..8ed152394 100644 --- a/app/src/main/kotlin/foundation/e/accountmanager/utils/AccountHelper.kt +++ b/app/src/main/kotlin/foundation/e/accountmanager/utils/AccountHelper.kt @@ -54,6 +54,12 @@ object AccountHelper { return allAccounts.toTypedArray() } + fun alreadyHasAccount(context: Context): Boolean { + val accountManager = AccountManager.get(context) + val accounts = accountManager.getAccountsByType(AccountTypes.Murena.accountType) + return accounts.isNotEmpty() + } + fun isOidcAccount(context: Context, account: Account): Boolean { val accountManager = AccountManager.get(context) val authState = accountManager.getUserData(account, AccountSettings.KEY_AUTH_STATE) diff --git a/app/src/main/res/values/e_strings.xml b/app/src/main/res/values/e_strings.xml index 5c277d717..e58475778 100644 --- a/app/src/main/res/values/e_strings.xml +++ b/app/src/main/res/values/e_strings.xml @@ -33,4 +33,5 @@ "Account Manager's Privacy Policy" "Privacy Policy" Web Calendar Manager + Legacy Murena.io diff --git a/app/src/ose/kotlin/at/bitfire/davdroid/ui/setup/StandardLoginTypesProvider.kt b/app/src/ose/kotlin/at/bitfire/davdroid/ui/setup/StandardLoginTypesProvider.kt index 0359c0073..87d05f15e 100644 --- a/app/src/ose/kotlin/at/bitfire/davdroid/ui/setup/StandardLoginTypesProvider.kt +++ b/app/src/ose/kotlin/at/bitfire/davdroid/ui/setup/StandardLoginTypesProvider.kt @@ -7,7 +7,9 @@ package at.bitfire.davdroid.ui.setup import android.content.Intent import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable +import at.bitfire.davdroid.BuildConfig import at.bitfire.davdroid.ui.setup.LoginTypesProvider.LoginAction +import foundation.e.accountmanager.ui.setup.MurenaLegacyLogin import foundation.e.accountmanager.ui.setup.MurenaLogin import java.util.logging.Logger import javax.inject.Inject @@ -17,6 +19,13 @@ class StandardLoginTypesProvider @Inject constructor( ) : LoginTypesProvider { companion object { + private val testLoginTypes = + if (BuildConfig.DEBUG) { + listOf( + MurenaLegacyLogin + ) + } else emptyList() + val genericLoginTypes = listOf( UrlLogin, EmailLogin, @@ -28,7 +37,7 @@ class StandardLoginTypesProvider @Inject constructor( FastmailLogin, GoogleLogin, NextcloudLogin - ) + ) + testLoginTypes } override val defaultLoginType = UrlLogin -- GitLab From 41cdf7afa7f5f03f941b3c3a0b8db78fe6776f9a Mon Sep 17 00:00:00 2001 From: althafvly Date: Tue, 26 Aug 2025 22:35:18 +0530 Subject: [PATCH 6/9] AM: check if current token before fresh --- .../davdroid/network/OAuthInterceptor.kt | 8 ++++- .../davdroid/settings/AccountSettings.kt | 6 ++-- .../e/accountmanager/network/OAuthMurena.kt | 10 +------ .../e/accountmanager/utils/AccountHelper.kt | 30 +++++++++++++++++++ 4 files changed, 40 insertions(+), 14 deletions(-) diff --git a/app/src/main/kotlin/at/bitfire/davdroid/network/OAuthInterceptor.kt b/app/src/main/kotlin/at/bitfire/davdroid/network/OAuthInterceptor.kt index 7cb1dd642..cd57286ef 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/network/OAuthInterceptor.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/network/OAuthInterceptor.kt @@ -8,6 +8,8 @@ import at.bitfire.davdroid.BuildConfig import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject +import foundation.e.accountmanager.utils.AccountHelper +import kotlinx.coroutines.runBlocking import net.openid.appauth.AuthState import net.openid.appauth.AuthorizationException import net.openid.appauth.AuthorizationService @@ -69,7 +71,11 @@ class OAuthInterceptor @AssistedInject constructor( // if possible, use cached access token val authState = readAuthState() ?: return null - if (authState.isAuthorized && authState.accessToken != null && !authState.needsTokenRefresh) { + val isValidAccessToken = runBlocking { + !authState.needsTokenRefresh || AccountHelper.isValidAccessToken(authState) + } + + if (authState.isAuthorized && authState.accessToken != null && isValidAccessToken) { if (BuildConfig.DEBUG) // log sensitive information (refresh/access token) only in debug builds logger.log(Level.FINEST, "Using cached AuthState", authState.jsonSerializeString()) return authState.accessToken diff --git a/app/src/main/kotlin/at/bitfire/davdroid/settings/AccountSettings.kt b/app/src/main/kotlin/at/bitfire/davdroid/settings/AccountSettings.kt index 113a0157a..e78cfd197 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/settings/AccountSettings.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/settings/AccountSettings.kt @@ -136,10 +136,8 @@ class AccountSettings @AssistedInject constructor( } fun updateAuthState(authState: AuthState) { - OAuthMurena.newAuthState(authState)?.let { authState -> - accountManager.setAndVerifyUserData(account, KEY_AUTH_STATE, authState.jsonSerializeString()) - OAuthMurena.saveAuthState(context, account, authState) - } + accountManager.setAndVerifyUserData(account, KEY_AUTH_STATE, authState.jsonSerializeString()) + OAuthMurena.saveAuthState(context, account, authState) } /** diff --git a/app/src/main/kotlin/foundation/e/accountmanager/network/OAuthMurena.kt b/app/src/main/kotlin/foundation/e/accountmanager/network/OAuthMurena.kt index 701e7ebe7..169c13092 100644 --- a/app/src/main/kotlin/foundation/e/accountmanager/network/OAuthMurena.kt +++ b/app/src/main/kotlin/foundation/e/accountmanager/network/OAuthMurena.kt @@ -26,9 +26,9 @@ import androidx.core.net.toUri import at.bitfire.davdroid.BuildConfig import at.bitfire.davdroid.settings.Credentials import at.bitfire.davdroid.sync.account.setAndVerifyUserData -import foundation.e.accountmanager.pref.AuthStatePrefUtils import com.owncloud.android.lib.common.accounts.AccountUtils import foundation.e.accountmanager.AccountTypes +import foundation.e.accountmanager.pref.AuthStatePrefUtils import net.openid.appauth.AuthState import net.openid.appauth.AuthorizationRequest import net.openid.appauth.AuthorizationServiceConfiguration @@ -108,12 +108,4 @@ object OAuthMurena { logger.log(Level.INFO, "Saved new authState $stateJson") } } - - fun newAuthState(authState: AuthState?): AuthState? = - authState?.apply { - needsTokenRefresh = scopeSet?.contains("offline_access") != true - if (BuildConfig.DEBUG) { - logger.log(Level.INFO, "needsTokenRefresh: $needsTokenRefresh") - } - } } diff --git a/app/src/main/kotlin/foundation/e/accountmanager/utils/AccountHelper.kt b/app/src/main/kotlin/foundation/e/accountmanager/utils/AccountHelper.kt index 8ed152394..68d168655 100644 --- a/app/src/main/kotlin/foundation/e/accountmanager/utils/AccountHelper.kt +++ b/app/src/main/kotlin/foundation/e/accountmanager/utils/AccountHelper.kt @@ -28,6 +28,13 @@ import at.bitfire.davdroid.settings.AccountSettings import foundation.e.accountmanager.AccountTypes import foundation.e.accountmanager.sync.SyncBroadcastReceiver import java.util.concurrent.TimeUnit +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import net.openid.appauth.AuthState +import java.net.HttpURLConnection +import java.net.URL +import java.util.logging.Level +import java.util.logging.Logger object AccountHelper { private const val MAIL_PACKAGE = "foundation.e.mail" @@ -38,6 +45,8 @@ object AccountHelper { private const val DRIVE_ACTION_ADD_ACCOUNT = "$DRIVE_PACKAGE_NAME.action.ADD_ACCOUNT" private const val DRIVE_RECEIVER_CLASS = "$DRIVE_PACKAGE_NAME.account.receivers.AccountAddedReceiver" + val logger: Logger = Logger.getLogger(this.javaClass.name) + fun getAllAccounts(accountManager: AccountManager): Array { val allAccounts = mutableListOf() for (type in AccountTypes.getAccountTypes()) { @@ -54,6 +63,27 @@ object AccountHelper { return allAccounts.toTypedArray() } + suspend fun isValidAccessToken(authState: AuthState): Boolean = withContext(Dispatchers.IO) { + try { + val endpoint = authState.authorizationServiceConfiguration + ?.discoveryDoc + ?.userinfoEndpoint + ?.toString() + ?: return@withContext false + logger.fine("Checking current access token") + (URL(endpoint).openConnection() as HttpURLConnection).run { + setRequestProperty("Authorization", "Bearer ${authState.accessToken}") + instanceFollowRedirects = false + val valid = responseCode == HttpURLConnection.HTTP_OK + disconnect() + valid + } + } catch (ex: Exception) { + logger.log(Level.SEVERE, "Failed to access userInfo endpoint", ex) + false + } + } + fun alreadyHasAccount(context: Context): Boolean { val accountManager = AccountManager.get(context) val accounts = accountManager.getAccountsByType(AccountTypes.Murena.accountType) -- GitLab From 12b5fd51ebfdb962bdb999f2eaf3dd7840f92ee1 Mon Sep 17 00:00:00 2001 From: althafvly Date: Mon, 1 Sep 2025 20:34:38 +0530 Subject: [PATCH 7/9] AM: Cache cookies to reduce sessions --- app/build.gradle.kts | 1 + .../at/bitfire/davdroid/network/HttpClient.kt | 15 +++- .../davdroid/network/MemoryCookieStore.kt | 13 ++- .../davdroid/repository/AccountRepository.kt | 2 +- .../servicedetection/DavResourceFinder.kt | 6 +- .../davdroid/settings/AccountSettings.kt | 38 +++++++-- .../at/bitfire/davdroid/sync/SyncManager.kt | 12 +++ .../davdroid/sync/worker/BaseSyncWorker.kt | 26 ++++++ .../e/accountmanager/network/CookieParser.kt | 22 +++++ .../e/accountmanager/network/OAuthMurena.kt | 15 +++- .../network/PersistentCookieStore.kt | 80 +++++++++++++++++++ gradle/libs.versions.toml | 1 + 12 files changed, 217 insertions(+), 14 deletions(-) create mode 100644 app/src/main/kotlin/foundation/e/accountmanager/network/CookieParser.kt create mode 100644 app/src/main/kotlin/foundation/e/accountmanager/network/PersistentCookieStore.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 311c4d367..cf9ae86d1 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -306,6 +306,7 @@ dependencies { implementation(libs.commons.httpclient) { exclude(group = "commons-logging", module = "commons-logging") } + implementation(libs.okhttp.urlconnection) // for tests androidTestImplementation(libs.androidx.arch.core.testing) diff --git a/app/src/main/kotlin/at/bitfire/davdroid/network/HttpClient.kt b/app/src/main/kotlin/at/bitfire/davdroid/network/HttpClient.kt index 71d5057f4..cd091450b 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/network/HttpClient.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/network/HttpClient.kt @@ -19,6 +19,8 @@ import at.bitfire.davdroid.settings.SettingsManager import at.bitfire.davdroid.ui.ForegroundTracker import com.google.common.net.HttpHeaders import dagger.hilt.android.qualifiers.ApplicationContext +import foundation.e.accountmanager.network.CookieParser +import foundation.e.accountmanager.network.PersistentCookieStore import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.withContext import net.openid.appauth.AuthState @@ -173,9 +175,12 @@ class HttpClient( accountSettings.credentials() }, updateAuthState = { authState -> - accountSettings.updateAuthState(authState) + accountSettings.updateAuthState(authState, cookieStore) } ) + + cookieStore = PersistentCookieStore.create(context, account, cookieStore) + return this } @@ -312,4 +317,12 @@ class HttpClient( } + fun getCookieAsString(): String { + val cookieJar = okHttpClient.cookieJar + if (cookieJar is CookieParser) { + return cookieJar.cookiesAsString() + } + + return "" + } } \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/network/MemoryCookieStore.kt b/app/src/main/kotlin/at/bitfire/davdroid/network/MemoryCookieStore.kt index 14f9aa6fb..a58a9f754 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/network/MemoryCookieStore.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/network/MemoryCookieStore.kt @@ -4,7 +4,8 @@ package at.bitfire.davdroid.network -import androidx.annotation.VisibleForTesting +import com.owncloud.android.lib.common.accounts.AccountUtils +import foundation.e.accountmanager.network.CookieParser import okhttp3.Cookie import okhttp3.CookieJar import okhttp3.HttpUrl @@ -14,7 +15,7 @@ import java.util.LinkedList * Primitive cookie store that stores cookies in a (volatile) hash map. * Will be sufficient for session cookies. */ -class MemoryCookieStore : CookieJar { +class MemoryCookieStore : CookieJar, CookieParser { data class StorageKey( val domain: String, @@ -78,4 +79,12 @@ class MemoryCookieStore : CookieJar { return cookies } + override fun cookiesAsString(): String { + if (storage.isEmpty()) { + return "" + } + + return storage.values.joinToString(separator = AccountUtils.Constants.OKHTTP_COOKIE_SEPARATOR) + } + } \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/repository/AccountRepository.kt b/app/src/main/kotlin/at/bitfire/davdroid/repository/AccountRepository.kt index 314d1c93c..774fd14b4 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/repository/AccountRepository.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/repository/AccountRepository.kt @@ -76,7 +76,7 @@ class AccountRepository @Inject constructor( // create Android account val initialUserData = AccountSettings.initialUserData(credentials) - val userData = OAuthMurena.onCreateAccount(context, initialUserData, account, credentials) + val userData = OAuthMurena.onCreateAccount(context, initialUserData, account, credentials, config) logger.log(Level.INFO, "Creating Android account with initial config", arrayOf(account, userData)) if (!SystemAccountUtils.createAccount(context, account, userData, credentials?.password)) diff --git a/app/src/main/kotlin/at/bitfire/davdroid/servicedetection/DavResourceFinder.kt b/app/src/main/kotlin/at/bitfire/davdroid/servicedetection/DavResourceFinder.kt index 494d9c115..74ee7552b 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/servicedetection/DavResourceFinder.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/servicedetection/DavResourceFinder.kt @@ -148,7 +148,8 @@ class DavResourceFinder @AssistedInject constructor( cardDAV = cardDavConfig, calDAV = calDavConfig, encountered401 = encountered401, - logs = logBuffer.toString() + logs = logBuffer.toString(), + cookies = httpClient.getCookieAsString() ) } @@ -487,7 +488,8 @@ class DavResourceFinder @AssistedInject constructor( val calDAV: ServiceInfo?, val encountered401: Boolean, - val logs: String + val logs: String, + val cookies: String? = null ) { data class ServiceInfo( diff --git a/app/src/main/kotlin/at/bitfire/davdroid/settings/AccountSettings.kt b/app/src/main/kotlin/at/bitfire/davdroid/settings/AccountSettings.kt index e78cfd197..427cf1088 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/settings/AccountSettings.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/settings/AccountSettings.kt @@ -19,16 +19,16 @@ import at.bitfire.davdroid.sync.account.InvalidAccountException import at.bitfire.davdroid.sync.account.setAndVerifyUserData import at.bitfire.davdroid.util.trimToNull import at.bitfire.vcard4android.GroupMethod +import com.owncloud.android.lib.common.accounts.AccountUtils import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject -import dagger.hilt.EntryPoint -import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext -import dagger.hilt.components.SingletonComponent import foundation.e.accountmanager.AccountTypes +import foundation.e.accountmanager.network.CookieParser import foundation.e.accountmanager.network.OAuthMurena import net.openid.appauth.AuthState +import okhttp3.CookieJar import java.util.Collections import java.util.logging.Level import java.util.logging.Logger @@ -135,9 +135,35 @@ class AccountSettings @AssistedInject constructor( OAuthMurena.onAccountUpdate(accountManager, account) } - fun updateAuthState(authState: AuthState) { + fun updateAuthState(authState: AuthState, cookie: CookieJar? = null) { accountManager.setAndVerifyUserData(account, KEY_AUTH_STATE, authState.jsonSerializeString()) - OAuthMurena.saveAuthState(context, account, authState) + if (cookie is CookieParser) { + OAuthMurena.saveAuthState(context, account, authState, cookie.cookiesAsString()) + } + } + + fun containsPersistentCookie(): Boolean { + return !accountManager.getUserData(account, AccountUtils.Constants.KEY_OKHTTP_COOKIES).isNullOrBlank() + } + + fun noAuthExceptionDetected(): Boolean { + return accountManager.getUserData( + account, + AUTH_EXCEPTION_DETECTED + ).isNullOrBlank() + } + + fun updateAuthExceptionDetectedStatus(detected: Boolean) { + accountManager.setUserData( + account, + AUTH_EXCEPTION_DETECTED, + if (detected) true.toString() else null + ) + } + + fun clearCookie() { + accountManager.setUserData(account, AccountUtils.Constants.KEY_OKHTTP_COOKIES, null) + accountManager.setUserData(account, AccountUtils.Constants.KEY_COOKIES, null) } /** @@ -427,6 +453,8 @@ class AccountSettings @AssistedInject constructor( internal const val SYNC_INTERVAL_MANUALLY = -1L + const val AUTH_EXCEPTION_DETECTED = "auth_exception_detected" + /** Static property to remember which AccountSettings updates/migrations are currently running */ val currentlyUpdating = Collections.synchronizedSet(mutableSetOf()) diff --git a/app/src/main/kotlin/at/bitfire/davdroid/sync/SyncManager.kt b/app/src/main/kotlin/at/bitfire/davdroid/sync/SyncManager.kt index 722f955a4..b9e4fe135 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/sync/SyncManager.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/sync/SyncManager.kt @@ -37,6 +37,7 @@ import at.bitfire.davdroid.repository.DavSyncStatsRepository import at.bitfire.davdroid.resource.LocalCollection import at.bitfire.davdroid.resource.LocalResource import at.bitfire.davdroid.resource.SyncState +import at.bitfire.davdroid.settings.AccountSettings import at.bitfire.davdroid.sync.account.InvalidAccountException import at.bitfire.synctools.storage.LocalStorageException import dagger.hilt.android.qualifiers.ApplicationContext @@ -94,6 +95,9 @@ abstract class SyncManager, out CollectionType: L @Inject lateinit var accountRepository: AccountRepository + @Inject + lateinit var accountSettingsFactory: AccountSettings.Factory + @Inject lateinit var collectionRepository: DavCollectionRepository @@ -735,6 +739,8 @@ abstract class SyncManager, out CollectionType: L private fun handleException(e: Throwable, local: LocalResource<*>?, remote: HttpUrl?) { var message: String val isNetworkAvailable = SystemUtils.isNetworkAvailable(context) + val accountSettings = accountSettingsFactory.create(account) + when (e) { is IOException -> { logger.log(Level.WARNING, "I/O error", e) @@ -750,6 +756,12 @@ abstract class SyncManager, out CollectionType: L syncResult.numAuthExceptions++ } message = context.getString(R.string.sync_error_authentication_failed) + + // persistent session cookie is present. Probably the session is outDated. no need to show the notification + if (accountSettings.containsPersistentCookie() && accountSettings.noAuthExceptionDetected()) { + logger.log(Level.FINE, "Authorization error. Session outDated") + return + } } is HttpException, is DavException -> { diff --git a/app/src/main/kotlin/at/bitfire/davdroid/sync/worker/BaseSyncWorker.kt b/app/src/main/kotlin/at/bitfire/davdroid/sync/worker/BaseSyncWorker.kt index 3cf6d8988..86faa32c3 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/sync/worker/BaseSyncWorker.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/sync/worker/BaseSyncWorker.kt @@ -16,6 +16,7 @@ import androidx.work.WorkInfo import androidx.work.WorkManager import androidx.work.WorkerParameters import at.bitfire.davdroid.R +import at.bitfire.davdroid.network.HttpClient import at.bitfire.davdroid.push.PushNotificationManager import at.bitfire.davdroid.settings.AccountSettings import at.bitfire.davdroid.sync.AddressBookSyncer @@ -37,10 +38,18 @@ import at.bitfire.ical4android.TaskProvider import dagger.Lazy import foundation.e.accountmanager.utils.AccountHelper import kotlinx.coroutines.delay +import okhttp3.Cookie +import okhttp3.CookieJar +import okhttp3.HttpUrl +import okhttp3.JavaNetCookieJar +import okhttp3.OkHttpClient +import java.net.CookieManager +import java.net.CookiePolicy import java.util.Collections import java.util.logging.Level import java.util.logging.Logger import javax.inject.Inject +import javax.inject.Provider abstract class BaseSyncWorker( context: Context, @@ -77,6 +86,8 @@ abstract class BaseSyncWorker( @Inject lateinit var taskSyncer: TaskSyncer.Factory + @Inject + lateinit var httpClientBuilder: Provider override suspend fun doWork(): Result { // ensure we got the required arguments @@ -153,6 +164,9 @@ abstract class BaseSyncWorker( val syncResult = SyncResult() + val accountSettings = accountSettingsFactory.create(account) + val isCookiePresent = accountSettings.containsPersistentCookie() + // What are we going to sync? Select syncer based on authority val syncer = when (dataType) { SyncDataType.CONTACTS -> @@ -195,6 +209,17 @@ abstract class BaseSyncWorker( if (syncResult.hasError()) { val softErrorNotificationTag = "${account.type}-${account.name}-$dataType" + if (isCookiePresent && syncResult.numAuthExceptions > 0) { + // probably the session is outDated. retry without the sessionCookie + val emptyCookie = JavaNetCookieJar(CookieManager().apply { + setCookiePolicy(CookiePolicy.ACCEPT_ALL) + }) + httpClientBuilder.get().setCookieStore(emptyCookie) + accountSettings.clearCookie() + accountSettings.updateAuthExceptionDetectedStatus(detected = true) + return Result.retry() + } + // On soft errors the sync is retried a few times before considered failed if (syncResult.hasSoftError()) { logger.log(Level.WARNING, "Soft error while syncing", syncResult) @@ -243,6 +268,7 @@ abstract class BaseSyncWorker( } } + accountSettings.updateAuthExceptionDetectedStatus(detected = false) logger.log(Level.INFO, "Sync worker succeeded", syncResult) return Result.success(output.build()) } diff --git a/app/src/main/kotlin/foundation/e/accountmanager/network/CookieParser.kt b/app/src/main/kotlin/foundation/e/accountmanager/network/CookieParser.kt new file mode 100644 index 000000000..8bb990523 --- /dev/null +++ b/app/src/main/kotlin/foundation/e/accountmanager/network/CookieParser.kt @@ -0,0 +1,22 @@ +/* + * Copyright (C) 2025 e Foundation + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ +package foundation.e.accountmanager.network + +interface CookieParser { + fun cookiesAsString(): String +} diff --git a/app/src/main/kotlin/foundation/e/accountmanager/network/OAuthMurena.kt b/app/src/main/kotlin/foundation/e/accountmanager/network/OAuthMurena.kt index 169c13092..a42de3b2f 100644 --- a/app/src/main/kotlin/foundation/e/accountmanager/network/OAuthMurena.kt +++ b/app/src/main/kotlin/foundation/e/accountmanager/network/OAuthMurena.kt @@ -24,6 +24,7 @@ import android.net.Uri import android.os.Bundle import androidx.core.net.toUri import at.bitfire.davdroid.BuildConfig +import at.bitfire.davdroid.servicedetection.DavResourceFinder import at.bitfire.davdroid.settings.Credentials import at.bitfire.davdroid.sync.account.setAndVerifyUserData import com.owncloud.android.lib.common.accounts.AccountUtils @@ -84,12 +85,16 @@ object OAuthMurena { }.build() } - fun onCreateAccount(context: Context, userData: Bundle, account: Account, credentials: Credentials?): Bundle { + fun onCreateAccount(context: Context, userData: Bundle, account: Account, credentials: Credentials?, config: DavResourceFinder.Configuration): Bundle { if (account.type != AccountTypes.Murena.accountType) return userData - saveAuthState(context, account, credentials?.authState) + saveAuthState(context, account, credentials?.authState, config.cookies) userData.putString(AccountUtils.Constants.KEY_OC_BASE_URL, baseUri) userData.putString(AccountUtils.Constants.KEY_DISPLAY_NAME, account.name.substringBefore('@')) + config.cookies?.takeIf { it.isNotEmpty() }?.let { cookies -> + userData.putString(AccountUtils.Constants.KEY_OKHTTP_COOKIES, cookies) + } + return userData } @@ -101,11 +106,15 @@ object OAuthMurena { AccountUtils.Constants.KEY_DISPLAY_NAME, account.name.substringBefore('@')) } - fun saveAuthState(context: Context, account: Account, authState: AuthState?) { + fun saveAuthState(context: Context, account: Account, authState: AuthState?, cookie: String? = null) { val stateJson = authState?.jsonSerializeString() AuthStatePrefUtils.saveAuthState(context, account, stateJson) if (BuildConfig.DEBUG) { logger.log(Level.INFO, "Saved new authState $stateJson") } + val accountManager = AccountManager.get(context) + if (cookie != null) { + accountManager.setAndVerifyUserData(account, AccountUtils.Constants.KEY_OKHTTP_COOKIES, cookie) + } } } diff --git a/app/src/main/kotlin/foundation/e/accountmanager/network/PersistentCookieStore.kt b/app/src/main/kotlin/foundation/e/accountmanager/network/PersistentCookieStore.kt new file mode 100644 index 000000000..021281ae3 --- /dev/null +++ b/app/src/main/kotlin/foundation/e/accountmanager/network/PersistentCookieStore.kt @@ -0,0 +1,80 @@ +/* + * Copyright (C) 2025 e Foundation + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ +package foundation.e.accountmanager.network + +import android.accounts.Account +import android.accounts.AccountManager +import android.content.Context +import at.bitfire.davdroid.sync.account.setAndVerifyUserData +import com.owncloud.android.lib.common.accounts.AccountUtils +import foundation.e.accountmanager.AccountTypes +import okhttp3.Cookie +import okhttp3.CookieJar +import okhttp3.HttpUrl + +/** + * Cookie store that persists only cookies for a specific domain. + * Other cookies are kept in memory temporarily for the app session. + */ +class PersistentCookieStore( + context: Context, + private val account: Account +) : CookieJar { + private val accountManager = AccountManager.get(context.applicationContext) + + override fun loadForRequest(url: HttpUrl): List = + getCookies(url).filter { it.matches(url) } + + override fun saveFromResponse(url: HttpUrl, cookies: List) { + if (url.host !in AccountTypes.Murena.whitelistedDomains) return + + val validCookies = cookies.filter { it.expiresAt > System.currentTimeMillis() } + if (validCookies.isEmpty()) return + + val cookieMap = getCookies(url).associateBy { it.name }.toMutableMap() + validCookies.forEach { cookieMap[it.name] = it } + + val serialized = cookieMap.values.joinToString(AccountUtils.Constants.OKHTTP_COOKIE_SEPARATOR) + accountManager.setAndVerifyUserData(account, AccountUtils.Constants.KEY_OKHTTP_COOKIES, serialized) + } + + private fun getCookies(url: HttpUrl): List { + val cookiesString = accountManager.getUserData(account, AccountUtils.Constants.KEY_OKHTTP_COOKIES) ?: return emptyList() + + return cookiesString + .split(AccountUtils.Constants.OKHTTP_COOKIE_SEPARATOR) + .mapNotNull { Cookie.parse(url, it) } + .filter { it.expiresAt > System.currentTimeMillis() } + } + + companion object { + val allowedAccountTypes = listOf(AccountTypes.Murena.accountType, AccountTypes.Murena.addressBookType) + + fun create( + context: Context, + account: Account?, + defaultJar: CookieJar + ): CookieJar { + if (account == null || account.type !in allowedAccountTypes) { + return defaultJar + } + + return PersistentCookieStore(context, account) + } + } +} \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7a0abe2f6..b79cd0edc 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -131,6 +131,7 @@ androidx-runtime-livedata = { module = "androidx.compose.runtime:runtime-livedat commons-httpclient = { module = "commons-httpclient:commons-httpclient", version.ref = "commonsHttpclient" } jackrabbit-webdav = { module = "org.apache.jackrabbit:jackrabbit-webdav", version.ref = "jackrabbitWebdav" } nextcloud-library = { module = "foundation.e:nextcloud-library", version.ref = "nextcloudLibrary" } +okhttp-urlconnection = { module = "com.squareup.okhttp3:okhttp-urlconnection", version.ref = "okhttp" } [plugins] android-application = { id = "com.android.application", version.ref = "android-agp" } -- GitLab From b90d6634fa45492cdc7bdb467d1697271facbf33 Mon Sep 17 00:00:00 2001 From: althafvly Date: Wed, 3 Sep 2025 12:48:30 +0530 Subject: [PATCH 8/9] Browser: Allow Re-OAuth from notification --- .../RefreshCollectionsWorker.kt | 16 +++- .../at/bitfire/davdroid/sync/SyncManager.kt | 7 +- .../davdroid/sync/SyncNotificationManager.kt | 8 +- .../ui/setup/ReOAuthActivity.kt | 95 +++++++++++++++++++ app/src/main/res/values/e_strings.xml | 2 + app/src/ose/AndroidManifest.xml | 4 + 6 files changed, 126 insertions(+), 6 deletions(-) create mode 100644 app/src/main/kotlin/foundation/e/accountmanager/ui/setup/ReOAuthActivity.kt diff --git a/app/src/main/kotlin/at/bitfire/davdroid/servicedetection/RefreshCollectionsWorker.kt b/app/src/main/kotlin/at/bitfire/davdroid/servicedetection/RefreshCollectionsWorker.kt index 1cf93cea8..267b829c2 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/servicedetection/RefreshCollectionsWorker.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/servicedetection/RefreshCollectionsWorker.kt @@ -35,10 +35,11 @@ import at.bitfire.davdroid.ui.account.AccountSettingsActivity import dagger.assisted.Assisted import dagger.assisted.AssistedInject import foundation.e.accountmanager.AccountTypes +import foundation.e.accountmanager.ui.setup.ReOAuthActivity +import foundation.e.accountmanager.utils.AccountHelper import kotlinx.coroutines.flow.map import kotlinx.coroutines.runInterruptible import java.io.IOException -import java.io.InterruptedIOException import java.util.logging.Level import java.util.logging.Logger import kotlin.coroutines.cancellation.CancellationException @@ -191,10 +192,17 @@ class RefreshCollectionsWorker @AssistedInject constructor( } catch (e: UnauthorizedException) { logger.log(Level.SEVERE, "Not authorized (anymore)", e) // notify that we need to re-authenticate in the account settings - val settingsIntent = Intent(applicationContext, AccountSettingsActivity::class.java) - .putExtra(AccountSettingsActivity.EXTRA_ACCOUNT, account) + val isOidcAccount = AccountHelper.isOidcAccount(applicationContext, account) + val (settingsIntent, notificationMessage) = if (isOidcAccount) { + Intent(applicationContext, ReOAuthActivity::class.java) to + applicationContext.getString(R.string.sync_error_authentication_oauth) + } else { + Intent(applicationContext, AccountSettingsActivity::class.java) to + applicationContext.getString(R.string.sync_error_authentication_failed) + } + settingsIntent.putExtra(AccountSettingsActivity.EXTRA_ACCOUNT, account) notifyRefreshError( - applicationContext.getString(R.string.sync_error_authentication_failed), + notificationMessage, settingsIntent ) return Result.failure() diff --git a/app/src/main/kotlin/at/bitfire/davdroid/sync/SyncManager.kt b/app/src/main/kotlin/at/bitfire/davdroid/sync/SyncManager.kt index b9e4fe135..2367436a7 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/sync/SyncManager.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/sync/SyncManager.kt @@ -41,6 +41,7 @@ import at.bitfire.davdroid.settings.AccountSettings import at.bitfire.davdroid.sync.account.InvalidAccountException import at.bitfire.synctools.storage.LocalStorageException import dagger.hilt.android.qualifiers.ApplicationContext +import foundation.e.accountmanager.utils.AccountHelper import foundation.e.accountmanager.utils.SystemUtils import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.coroutineScope @@ -755,7 +756,11 @@ abstract class SyncManager, out CollectionType: L if (isNetworkAvailable) { syncResult.numAuthExceptions++ } - message = context.getString(R.string.sync_error_authentication_failed) + message = if (AccountHelper.isOidcAccount(context, account)) { + context.getString(R.string.sync_error_authentication_oauth) + } else { + context.getString(R.string.sync_error_authentication_failed) + } // persistent session cookie is present. Probably the session is outDated. no need to show the notification if (accountSettings.containsPersistentCookie() && accountSettings.noAuthExceptionDetected()) { diff --git a/app/src/main/kotlin/at/bitfire/davdroid/sync/SyncNotificationManager.kt b/app/src/main/kotlin/at/bitfire/davdroid/sync/SyncNotificationManager.kt index 398a660aa..dabbb37af 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/sync/SyncNotificationManager.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/sync/SyncNotificationManager.kt @@ -33,6 +33,8 @@ import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.qualifiers.ApplicationContext +import foundation.e.accountmanager.ui.setup.ReOAuthActivity +import foundation.e.accountmanager.utils.AccountHelper import okhttp3.HttpUrl import org.dmfs.tasks.contract.TaskContract import java.io.IOException @@ -124,7 +126,11 @@ class SyncNotificationManager @AssistedInject constructor( val contentIntent: Intent var viewItemAction: NotificationCompat.Action? = null if (e is UnauthorizedException) { - contentIntent = Intent(context, AccountSettingsActivity::class.java) + contentIntent = if (AccountHelper.isOidcAccount(context, account)) { + Intent(context, ReOAuthActivity::class.java) + } else { + Intent(context, AccountSettingsActivity::class.java) + } contentIntent.putExtra( AccountSettingsActivity.EXTRA_ACCOUNT, account diff --git a/app/src/main/kotlin/foundation/e/accountmanager/ui/setup/ReOAuthActivity.kt b/app/src/main/kotlin/foundation/e/accountmanager/ui/setup/ReOAuthActivity.kt new file mode 100644 index 000000000..5e3a30688 --- /dev/null +++ b/app/src/main/kotlin/foundation/e/accountmanager/ui/setup/ReOAuthActivity.kt @@ -0,0 +1,95 @@ +/* + * Copyright (C) 2025 e Foundation + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ +package foundation.e.accountmanager.ui.setup + +import android.accounts.Account +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.compose.setContent +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.glance.LocalContext +import androidx.hilt.navigation.compose.hiltViewModel +import at.bitfire.davdroid.ui.AppTheme +import at.bitfire.davdroid.ui.account.AccountSettingsActivity +import at.bitfire.davdroid.ui.account.AccountSettingsModel +import dagger.hilt.android.AndroidEntryPoint +import foundation.e.accountmanager.utils.AccountHelper + +@AndroidEntryPoint +class ReOAuthActivity : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + // Retrieve the Account from the Intent + val account: Account? = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) { + intent.getParcelableExtra(AccountSettingsActivity.EXTRA_ACCOUNT, Account::class.java) + } else { + @Suppress("DEPRECATION") + intent.getParcelableExtra(AccountSettingsActivity.EXTRA_ACCOUNT) + } + + setContent { + AppTheme { + if (account != null) { + OAuthHandlerScreen( + account = account, + onFinished = { finish() } + ) + } else { + finish() + } + } + } + } +} + +@Composable +fun OAuthHandlerScreen( + account: Account, + onFinished: () -> Unit, +) { + val context = LocalContext.current + val model = hiltViewModel { factory: AccountSettingsModel.Factory -> + factory.create(account) + } + + val authRequestContract = rememberLauncherForActivityResult(model.authorizationContract()) { authResponse -> + if (authResponse != null) { + model.authenticate(authResponse) + + // Sync after authenticated + AccountHelper.scheduleSyncWithDelay(context) + } else { + model.authCodeFailed() + } + onFinished() + } + + // Auto-launch immediately, no UI shown + LaunchedEffect(Unit) { + val request = model.newAuthorizationRequest() + if (request != null) { + authRequestContract.launch(request) + } else { + onFinished() + } + } +} diff --git a/app/src/main/res/values/e_strings.xml b/app/src/main/res/values/e_strings.xml index e58475778..8e8adc980 100644 --- a/app/src/main/res/values/e_strings.xml +++ b/app/src/main/res/values/e_strings.xml @@ -34,4 +34,6 @@ "Privacy Policy" Web Calendar Manager Legacy Murena.io + + Authentication issue. Tap to sign in again diff --git a/app/src/ose/AndroidManifest.xml b/app/src/ose/AndroidManifest.xml index 0342aecd0..f6583537c 100644 --- a/app/src/ose/AndroidManifest.xml +++ b/app/src/ose/AndroidManifest.xml @@ -269,6 +269,10 @@ android:exported="true" tools:ignore="ExportedService" /> + + -- GitLab From 7e8ae0a46806472d3c061513fdb721fd3b9cf19d Mon Sep 17 00:00:00 2001 From: althafvly Date: Thu, 4 Sep 2025 15:27:18 +0530 Subject: [PATCH 9/9] AM: Return list from getByAccountName and update getUserId --- .../main/kotlin/at/bitfire/davdroid/db/ServiceDao.kt | 2 +- .../android/ui/activity/SsoGrantPermissionViewModel.kt | 10 +++++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/app/src/main/kotlin/at/bitfire/davdroid/db/ServiceDao.kt b/app/src/main/kotlin/at/bitfire/davdroid/db/ServiceDao.kt index 94046ebf8..23d5214af 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/db/ServiceDao.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/db/ServiceDao.kt @@ -17,7 +17,7 @@ interface ServiceDao { suspend fun getByAccountAndType(accountName: String, @ServiceType type: String): Service? @Query("SELECT * FROM service WHERE accountName=:accountName") - suspend fun getByAccountName(accountName: String): Service? + suspend fun getByAccountName(accountName: String): List @Query("SELECT * FROM service WHERE accountName=:accountName AND type=:type") fun getByAccountAndTypeFlow(accountName: String, @ServiceType type: String): Flow diff --git a/app/src/main/kotlin/com/owncloud/android/ui/activity/SsoGrantPermissionViewModel.kt b/app/src/main/kotlin/com/owncloud/android/ui/activity/SsoGrantPermissionViewModel.kt index 74d6a2b25..940ef1cc4 100644 --- a/app/src/main/kotlin/com/owncloud/android/ui/activity/SsoGrantPermissionViewModel.kt +++ b/app/src/main/kotlin/com/owncloud/android/ui/activity/SsoGrantPermissionViewModel.kt @@ -117,11 +117,15 @@ class SsoGrantPermissionViewModel @Inject constructor( private suspend fun getUserId(account: Account): String { val accountManager = AccountManager.get(context) - val userId = accountManager.getUserData(account, AccountUtils.Constants.KEY_USER_ID) - if (!userId.isNullOrBlank()) return userId + accountManager.getUserData(account, AccountUtils.Constants.KEY_USER_ID) + ?.takeIf { it.isNotBlank() } + ?.let { return it } val principalUrl = database.serviceDao() - .getByAccountName(account.name)?.principal?.toString() + .getByAccountName(account.name) + .firstOrNull() + ?.principal + ?.toString() return principalUrl?.let { UserIdFetcher.fetch(it) } ?: account.name } -- GitLab