diff --git a/.gitignore b/.gitignore index edfb7c624..f5edffcfa 100644 --- a/.gitignore +++ b/.gitignore @@ -178,3 +178,4 @@ src/java/Keepass2AndroidPluginSDK2/build/generated/mockable-Google-Inc.-Google-A /src/MegaTest *.dtbcache.json /src/keepass2android-app/AndroidManifest.xml +*.DS_Store diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 000000000..682a7a532 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,14 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "C#: keepass2android-app Debug", + "type": "dotnet", + "request": "launch", + "projectPath": "${workspaceFolder}/src/keepass2android-app/keepass2android-app.csproj" + } + ] +} \ No newline at end of file diff --git a/src/Kp2aBusinessLogic/IKp2aApp.cs b/src/Kp2aBusinessLogic/IKp2aApp.cs index 60f93d6e2..0c37550ef 100644 --- a/src/Kp2aBusinessLogic/IKp2aApp.cs +++ b/src/Kp2aBusinessLogic/IKp2aApp.cs @@ -167,5 +167,11 @@ int WebDavChunkedUploadSize /// Registers an action that should be executed when the context instance with the given id has been resumed. /// void RegisterPendingActionForContextInstance(int contextInstanceId, ActionOnOperationFinished actionToPerformWhenContextIsResumed); + + /// + /// Use an App level (singletone) AtomicInteger + /// for the request code of pending intent in the credential provider service. + /// + int RequestCodeForCredentialProvider { get; } } } \ No newline at end of file diff --git a/src/Kp2aBusinessLogic/database/edit/CreateDB.cs b/src/Kp2aBusinessLogic/database/edit/CreateDB.cs index 5e03ab73d..3983ac9da 100644 --- a/src/Kp2aBusinessLogic/database/edit/CreateDB.cs +++ b/src/Kp2aBusinessLogic/database/edit/CreateDB.cs @@ -28,6 +28,7 @@ namespace keepass2android public class CreateDb : OperationWithFinishHandler { + public const string DefaultDbName = "Keepass2Android Password Database"; private readonly IOConnectionInfo _ioc; private readonly bool _dontSave; private readonly IKp2aApp _app; @@ -68,7 +69,7 @@ public override void Run() db.KpDatabase.New(_ioc, _key, _app.GetFileStorage(_ioc).GetFilenameWithoutPathAndExt(_ioc)); db.KpDatabase.KdfParameters = (new AesKdf()).GetDefaultParameters(); - db.KpDatabase.Name = "Keepass2Android Password Database"; + db.KpDatabase.Name = DefaultDbName; //re-set the name of the root group because the PwDatabase uses UrlUtil which is not appropriate for all file storages: db.KpDatabase.RootGroup.Name = _app.GetFileStorage(_ioc).GetFilenameWithoutPathAndExt(_ioc); diff --git a/src/keepass2android-app/Resources/values/strings.xml b/src/keepass2android-app/Resources/values/strings.xml index c3246a357..e8f05879b 100644 --- a/src/keepass2android-app/Resources/values/strings.xml +++ b/src/keepass2android-app/Resources/values/strings.xml @@ -1350,5 +1350,10 @@ Periodic background synchronization time interval Set the interval for background synchronization in minutes. + + Password credential provider + Save Password in new entry + Open %1$s + Manage Credentials \ No newline at end of file diff --git a/src/keepass2android-app/Resources/xml/credentials_provider.xml b/src/keepass2android-app/Resources/xml/credentials_provider.xml new file mode 100644 index 000000000..636c5d9cb --- /dev/null +++ b/src/keepass2android-app/Resources/xml/credentials_provider.xml @@ -0,0 +1,30 @@ + + + + + + + + \ No newline at end of file diff --git a/src/keepass2android-app/app/App.cs b/src/keepass2android-app/app/App.cs index 4123500a2..bc030ba62 100644 --- a/src/keepass2android-app/app/App.cs +++ b/src/keepass2android-app/app/App.cs @@ -66,6 +66,7 @@ You should have received a copy of the GNU General Public License using Java.Interop; using AndroidX.Lifecycle; using keepass2android.services; +using Java.Util.Concurrent.Atomic; namespace keepass2android @@ -1628,6 +1629,13 @@ public void PerformPendingActions(int instanceId) } } } + + private readonly AtomicInteger requestCodeForCredentialProvider = new(); + public int RequestCodeForCredentialProvider + { + get => requestCodeForCredentialProvider.IncrementAndGet(); + } + } diff --git a/src/keepass2android-app/keepass2android-app.csproj b/src/keepass2android-app/keepass2android-app.csproj index 768e6815d..94ea7eb17 100644 --- a/src/keepass2android-app/keepass2android-app.csproj +++ b/src/keepass2android-app/keepass2android-app.csproj @@ -745,6 +745,7 @@ + diff --git a/src/keepass2android-app/services/Kp2aCredentialProvider/Kp2aCredentialLauncherActivity.cs b/src/keepass2android-app/services/Kp2aCredentialProvider/Kp2aCredentialLauncherActivity.cs new file mode 100644 index 000000000..189ca23e3 --- /dev/null +++ b/src/keepass2android-app/services/Kp2aCredentialProvider/Kp2aCredentialLauncherActivity.cs @@ -0,0 +1,388 @@ +// This file is part of Keepass2Android, Copyright 2025 Philipp Crocoll. +// +// Keepass2Android 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. +// +// Keepass2Android 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 Keepass2Android. If not, see . + +using System.Diagnostics; +using Android.Content; +using Android.Content.PM; +using Android.OS; +using Android.Runtime; +using Android.Views; +using AndroidX.Core.App; +using AndroidX.Credentials; +using AndroidX.Credentials.Exceptions; +using AndroidX.Credentials.Provider; +using Java.Time; +using Keepass2android.Pluginsdk; +using KeePassLib; +using KeePassLib.Utility; +using Org.Json; + +namespace keepass2android.services.Kp2aCredentialProvider +{ + [Activity( + Label = AppNames.AppName, + ConfigurationChanges = ConfigChanges.Orientation | ConfigChanges.KeyboardHidden, + Theme = "@style/Kp2aTheme_ActionBar", + WindowSoftInputMode = SoftInput.AdjustResize, + Permission = "keepass2android." + AppNames.PackagePart + ".permission.Kp2aChooseAutofill" + )] + public class Kp2aCredentialLauncherActivity : AndroidX.AppCompat.App.AppCompatActivity + { + public const string CredentialRequestTypeKey = "credential_request_type"; + public const int CredentialRequestTypeCreatePassword = 1; + public const int CredentialRequestTypeUnlock = 2; + public const int CredentialRequestTypeGetPasswordForEntry = 3; + private const int CreateEntryRequestCode = 100; + private const int UnlockRequestCode = 300; + + protected override void OnCreate(Bundle? savedInstanceState) + { + base.OnCreate(savedInstanceState); + + var intent = Intent; + if (intent == null) + { + // should never happen + SetResult(Result.Canceled); + Finish(); + } + else + { + var requestType = intent.GetIntExtra(CredentialRequestTypeKey, 0); + switch (requestType) + { + case CredentialRequestTypeCreatePassword: + HandleCreatePasswordRequest(intent); + break; + case CredentialRequestTypeUnlock: + HandleUnlockRequest(intent); + break; + case CredentialRequestTypeGetPasswordForEntry: + HandleGetPasswordForEntryRequest(intent); + break; + + default: + // should never happen + SetResult(Result.Canceled); + Finish(); + break; + } + } + } + + private void HandleCreatePasswordRequest(Intent requestIntent) + { + var createRequest = PendingIntentHandler.RetrieveProviderCreateCredentialRequest( + requestIntent + ); + if (createRequest != null && createRequest?.CallingRequest is CreatePasswordRequest) + { + if (createRequest.CallingRequest is not CreatePasswordRequest request) + { + SetUpFailureResponseForCreateAndFinish("Unable to extract request from intent"); + } + else + { + var callingPackage = createRequest.CallingAppInfo.PackageName; + + var forwardIntent = new Intent(this, typeof(SelectCurrentDbActivity)); + + Dictionary outputFields = []; + if (callingPackage != null) + { + outputFields.TryAdd(PwDefs.UrlField, $"{KeePass.AndroidAppScheme}{callingPackage}"); + } + + outputFields.TryAdd(PwDefs.UserNameField, request.Id); + outputFields.TryAdd(PwDefs.PasswordField, request.Password); + + JSONObject jsonOutput = new(outputFields); + var jsonOutputStr = jsonOutput.ToString(); + forwardIntent.PutExtra(Strings.ExtraEntryOutputData, jsonOutputStr); + + JSONArray jsonProtectedFields = new( + (System.Collections.ICollection)Array.Empty() + ); + forwardIntent.PutExtra(Strings.ExtraProtectedFieldsList, jsonProtectedFields.ToString()); + + forwardIntent.PutExtra(AppTask.AppTaskKey, "CreateEntryThenCloseTask"); + forwardIntent.PutExtra(CreateEntryThenCloseTask.ShowUserNotificationsKey, "false"); + StartActivityForResult(forwardIntent, CreateEntryRequestCode); + } + } + else + { + SetUpFailureResponseForCreateAndFinish("Unable to extract request from intent"); + } + } + + private void HandleUnlockRequest(Intent requestIntent) + { + var getRequest = PendingIntentHandler.RetrieveBeginGetCredentialRequest(requestIntent); + var callingPackage = getRequest?.CallingAppInfo?.PackageName; + + if (getRequest == null || callingPackage == null) + { + //should never happen + return; + } + + var query = $"{KeePass.AndroidAppScheme}{callingPackage}"; + //launch SelectCurrentDbActivity (which is root of the stack (exception: we're even below!)) with the appropriate task. + //will return the results later + Intent forwardIntent = new(this, typeof(SelectCurrentDbActivity)); + //don't show user notifications when an entry is opened. + var task = new SearchUrlTask() { UrlToSearchFor = query }; + task.ToIntent(forwardIntent); + StartActivityForResult(forwardIntent, UnlockRequestCode); + } + + private void HandleGetPasswordForEntryRequest(Intent requestIntent) + { + var getRequest = PendingIntentHandler.RetrieveProviderGetCredentialRequest(requestIntent); + var options = getRequest?.CredentialOptions; + if (options == null || options.Count == 0) + { + SetUpFailureResponseForGetAndFinish(); + return; + } + + var option = options.First(); + if (option is GetPasswordOption) + { + var entryId = requestIntent.GetStringExtra(Strings.ExtraEntryId); + if (string.IsNullOrEmpty(entryId)) + { + SetUpFailureResponseForGetAndFinish(); + return; + } + var entryUuid = new PwUuid(MemUtil.HexStringToByteArray(entryId)); + + var lastOpenedEntry = App.Kp2a.LastOpenedEntry; + if (lastOpenedEntry != null && entryUuid.Equals(lastOpenedEntry.Uuid)) + { + SetupGetCredentialResponseForEntryAndFinish(lastOpenedEntry.Entry); + } + else + { + foreach (Database db in App.Kp2a.OpenDatabases) + { + if (db.EntriesById.TryGetValue(entryUuid, out var resultEntry)) + { + SetupGetCredentialResponseForEntryAndFinish(resultEntry); + return; + } + } + //nothing found in open databases + SetUpNoCredentialResponseForGetAndFinish(); + } + } + else + { + SetUpFailureResponseForGetAndFinish(); + } + } + + protected override void OnActivityResult( + int requestCode, + [GeneratedEnum] Result resultCode, + Intent? data + ) + { + base.OnActivityResult(requestCode, resultCode, data); + switch (requestCode) + { + case CreateEntryRequestCode: + SetupCreateCredentialResponseAndFinish(resultCode); + break; + + case UnlockRequestCode: + SetupUnlockResponseAndFinish(resultCode); + break; + } + } + + /// + /// Saves the password and sets the response back to the calling app. + /// + /// The result code of forward intent. + private void SetupCreateCredentialResponseAndFinish([GeneratedEnum] Result resultCode) + { + var result = new Intent(); + if (resultCode == KeePass.ExitCloseAfterTaskComplete) + { + PendingIntentHandler.SetCreateCredentialResponse(result, new CreatePasswordResponse()); + SetResult(Result.Ok, result); + } + else + { + PendingIntentHandler.SetCreateCredentialException( + result, + new CreateCredentialCancellationException() + ); + SetResult(Result.Canceled, result); + } + if (!IsFinishing) + { + Finish(); + } + } + + /// + /// Sets and returns the credential list from the unlocked database + /// + /// The result code of forward intent. + private void SetupUnlockResponseAndFinish([GeneratedEnum] Result resultCode) + { + var thisIntent = Intent; + if (thisIntent != null) + { + var getRequest = PendingIntentHandler.RetrieveBeginGetCredentialRequest(thisIntent); + var options = getRequest?.BeginGetCredentialOptions; + if (options != null && options.Count >= 0) + { + var option = options.First(); + if (option != null && option is BeginGetPasswordOption) + { + var result = new Intent(); + if (resultCode == KeePass.ExitCloseAfterTaskComplete) + { + var response = new BeginGetCredentialResponse.Builder(); + var entry = App.Kp2a.LastOpenedEntry; + var username = entry?.Entry.Strings.ReadSafe(PwDefs.UserNameField); + if (entry != null && !string.IsNullOrEmpty(username)) + { + var lastAccessTime = + Build.VERSION.SdkInt >= BuildVersionCodes.O + ? Instant.OfEpochMilli( + new DateTimeOffset(entry.Entry.LastAccessTime).ToUnixTimeMilliseconds() + ) + : null; + + response.AddCredentialEntry( + new PasswordCredentialEntry.Builder( + this, + username, + PendingIntentCompat.GetActivity( + this, + App.Kp2a.RequestCodeForCredentialProvider, + new Intent(this, typeof(Kp2aCredentialLauncherActivity)) + .PutExtra( + CredentialRequestTypeKey, + CredentialRequestTypeGetPasswordForEntry + ) + .PutExtra(Strings.ExtraEntryId, entry.Entry.Uuid.ToHexString()), + (int)PendingIntentFlags.UpdateCurrent, + true + )!, + option.JavaCast() + ) + .SetDisplayName(entry.Entry.Strings.ReadSafe(PwDefs.TitleField)) + .SetAffiliatedDomain(entry.Entry.ParentGroup?.Name) + .SetLastUsedTime(lastAccessTime) + //TODO SetIcon() of the entry + .Build() + ); + } + PendingIntentHandler.SetBeginGetCredentialResponse(result, response.Build()); + SetResult(Result.Ok, result); + } + } + } + } + + if (!IsFinishing) + { + Finish(); + } + } + + private void SetupGetCredentialResponseForEntryAndFinish(PwEntry entry) + { + var username = entry.Strings.ReadSafe(PwDefs.UserNameField); + var password = entry.Strings.ReadSafe(PwDefs.PasswordField); + if (username == null || string.IsNullOrEmpty(password)) + { + SetUpNoCredentialResponseForGetAndFinish(); + return; + } + + var result = new Intent(); + PendingIntentHandler.SetGetCredentialResponse( + result, + new GetCredentialResponse(new PasswordCredential(username, password)) + ); + SetResult(Result.Ok, result); + + if (!IsFinishing) + { + Finish(); + } + } + + /// + /// If the request is null, send an unknown exception to client and finish the flow. + /// + /// The error message to send to the client. + private void SetUpFailureResponseForCreateAndFinish(string? message = null) + { + var result = new Intent(); + PendingIntentHandler.SetCreateCredentialException( + result, + new CreateCredentialUnknownException(message) + ); + SetResult(Result.Ok, result); + + if (!IsFinishing) + { + Finish(); + } + } + + /// + /// Sets up a failure response for get request and finishes the activity. + /// + /// The error message to include in the response. + private void SetUpFailureResponseForGetAndFinish(string? message = null) + { + var result = new Intent(); + PendingIntentHandler.SetGetCredentialException( + result, + new GetCredentialUnknownException(message) + ); + SetResult(Result.Ok, result); + + if (!IsFinishing) + { + Finish(); + } + } + + /// + /// Sets up a no credential response for get request and finishes the activity. + /// + private void SetUpNoCredentialResponseForGetAndFinish() + { + var result = new Intent(); + PendingIntentHandler.SetGetCredentialException(result, new NoCredentialException()); + SetResult(Result.Ok, result); + + if (!IsFinishing) + { + Finish(); + } + } + } +} diff --git a/src/keepass2android-app/services/Kp2aCredentialProvider/Kp2aCredentialProviderService.cs b/src/keepass2android-app/services/Kp2aCredentialProvider/Kp2aCredentialProviderService.cs new file mode 100644 index 000000000..d018c3212 --- /dev/null +++ b/src/keepass2android-app/services/Kp2aCredentialProvider/Kp2aCredentialProviderService.cs @@ -0,0 +1,217 @@ +// This file is part of Keepass2Android, Copyright 2025 Philipp Crocoll. +// +// Keepass2Android 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. +// +// Keepass2Android 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 Keepass2Android. If not, see . + +using System.Runtime.Versioning; +using Android.Content; +using Android.Graphics; +using Android.Graphics.Drawables; +using Android.OS; +using AndroidX.Core.App; +using AndroidX.Credentials.Exceptions; +using AndroidX.Credentials.Provider; +using Java.Interop; +using Java.Time; +using Keepass2android.Pluginsdk; +using KeePassLib; + +namespace keepass2android.services.Kp2aCredentialProvider +{ + [Service( + Enabled = true, + Exported = true, + Permission = "android.permission.BIND_CREDENTIAL_PROVIDER_SERVICE" + )] + [IntentFilter(actions: ["android.service.credentials.CredentialProviderService"])] + [MetaData(name: "android.credentials.provider", Resource = "@xml/credentials_provider")] + [SupportedOSPlatform("android31.0")] + public class Kp2aCredentialProviderService : CredentialProviderService + { + public override void OnBeginCreateCredentialRequest( + BeginCreateCredentialRequest request, + CancellationSignal cancellationSignal, + IOutcomeReceiver callback + ) + { + Kp2aLog.Log("Kp2aCredentialProviderService:onBeginCreateCredentialRequest called"); + if (request is BeginCreatePasswordCredentialRequest) + { + var currentDb = App.Kp2a.CurrentDb; + var accountName = currentDb?.KpDatabase?.Name ?? CreateDb.DefaultDbName; + var blendMode = BlendMode.Dst; + var icon = + blendMode == null + ? null + : Icon.CreateWithResource(this, AppNames.LauncherIcon)?.SetTintBlendMode(blendMode); + + callback.OnResult( + new BeginCreateCredentialResponse.Builder() + .AddCreateEntry( + new CreateEntry.Builder( + accountName, + PendingIntentCompat.GetActivity( + this, + App.Kp2a.RequestCodeForCredentialProvider, + new Intent(this, typeof(Kp2aCredentialLauncherActivity)).PutExtra( + Kp2aCredentialLauncherActivity.CredentialRequestTypeKey, + Kp2aCredentialLauncherActivity.CredentialRequestTypeCreatePassword + ), + (int)PendingIntentFlags.UpdateCurrent, + true + )! + ) + .SetIcon(icon) + .SetDescription( + GetString(Resource.String.credential_provider_password_creation_description) + ) + // Set the last used time to "now" + // so the active account is the default option in the system prompt. + .SetLastUsedTime(currentDb == null ? null : Instant.Now()) + .Build() + ) + .Build() + ); + } + else + { + callback.OnError(new CreateCredentialUnsupportedException().JavaCast()); + } + } + + public override void OnBeginGetCredentialRequest( + BeginGetCredentialRequest request, + CancellationSignal cancellationSignal, + IOutcomeReceiver callback + ) + { + Kp2aLog.Log("Kp2aCredentialProviderService:OnBeginGetCredentialRequest called"); + + var callingPackage = request.CallingAppInfo?.PackageName; + if (callingPackage == null) + { + callback.OnError(new NoCredentialException().JavaCast()); + } + + var appLocked = !App.Kp2a.DatabaseIsUnlocked; + var responseBuilder = new BeginGetCredentialResponse.Builder(); + + // Note that if your credentials are locked, you can immediately set an AuthenticationAction on the response and invoke the callback. + if (appLocked) + { + callback.OnResult( + responseBuilder + .AddAuthenticationAction( + new AuthenticationAction( + // Providers that require unlocking the credentials before returning any credentialEntries, + // must set up a pending intent that navigates the user to the app's unlock flow. + GetString(AppNames.AppNameResource), + PendingIntentCompat.GetActivity( + this, + App.Kp2a.RequestCodeForCredentialProvider, + new Intent(this, typeof(Kp2aCredentialLauncherActivity)).PutExtra( + Kp2aCredentialLauncherActivity.CredentialRequestTypeKey, + Kp2aCredentialLauncherActivity.CredentialRequestTypeUnlock + ), + (int)PendingIntentFlags.UpdateCurrent, + true + )! + ) + ) + .Build() + ); + return; + } + + foreach (var option in request.BeginGetCredentialOptions) + { + if (option is BeginGetPasswordOption) + { + var query = $"{KeePass.AndroidAppScheme}{callingPackage}"; + var foundEntries = ShareUrlResults.GetSearchResultsForUrl(query)?.Entries ?? []; + + var lastOpenedEntry = App.Kp2a.LastOpenedEntry; + if (lastOpenedEntry != null && lastOpenedEntry?.SearchUrl == query) + { + foundEntries.Clear(); + foundEntries.Add(lastOpenedEntry.Entry); + } + + foreach (var entry in foundEntries) + { + var username = entry.Strings.ReadSafe(PwDefs.UserNameField); + if (string.IsNullOrEmpty(username)) + { + continue; + } + + responseBuilder.AddCredentialEntry( + new PasswordCredentialEntry.Builder( + this, + username, + PendingIntentCompat.GetActivity( + this, + App.Kp2a.RequestCodeForCredentialProvider, + new Intent(this, typeof(Kp2aCredentialLauncherActivity)) + .PutExtra( + Kp2aCredentialLauncherActivity.CredentialRequestTypeKey, + Kp2aCredentialLauncherActivity.CredentialRequestTypeGetPasswordForEntry + ) + .PutExtra(Strings.ExtraEntryId, entry.Uuid.ToHexString()), + (int)PendingIntentFlags.UpdateCurrent, + true + )!, + option.JavaCast() + ) + .SetDisplayName(entry.Strings.ReadSafe(PwDefs.TitleField)) + .SetAffiliatedDomain(entry.ParentGroup?.Name) + .SetLastUsedTime( + Instant.OfEpochMilli( + new DateTimeOffset(entry.LastAccessTime).ToUnixTimeMilliseconds() + ) + ) + //TODO SetIcon() of the entry + .Build() + ); + } + } + } + + responseBuilder.AddAction( + new AndroidX.Credentials.Provider.Action( + GetString(Resource.String.open_app_name, GetString(AppNames.AppNameResource)!), + PendingIntentCompat.GetActivity( + this, + App.Kp2a.RequestCodeForCredentialProvider, + new Intent(this, typeof(KeePass)), + (int)PendingIntentFlags.UpdateCurrent, + true + )!, + GetString(Resource.String.manage_credentials) + ) + ); + + callback.OnResult(responseBuilder.Build()); + } + + public override void OnClearCredentialStateRequest( + ProviderClearCredentialStateRequest request, + CancellationSignal cancellationSignal, + IOutcomeReceiver callback + ) + { + Kp2aLog.Log("Kp2aCredentialProviderService:OnClearCredentialStateRequest called"); + //no-op + } + } +}