From 2b2e18115929ed4c7817dc5a32070c7a760859ef Mon Sep 17 00:00:00 2001 From: plyght Date: Sun, 30 Nov 2025 00:40:01 -0800 Subject: [PATCH 01/31] init --- .../org.eclipse.buildship.core.prefs | 13 + src/keepass2android-app/KeeShare.cs | 288 ++++++++++++++++++ src/keepass2android-app/PasswordActivity.cs | 14 +- 3 files changed, 314 insertions(+), 1 deletion(-) create mode 100644 src/java/PluginQR/.settings/org.eclipse.buildship.core.prefs create mode 100644 src/keepass2android-app/KeeShare.cs diff --git a/src/java/PluginQR/.settings/org.eclipse.buildship.core.prefs b/src/java/PluginQR/.settings/org.eclipse.buildship.core.prefs new file mode 100644 index 000000000..19f4d05f7 --- /dev/null +++ b/src/java/PluginQR/.settings/org.eclipse.buildship.core.prefs @@ -0,0 +1,13 @@ +arguments=--init-script /var/folders/r3/41cmfh2s0l52ndz3w971w7lc0000gn/T/db3b08fc4a9ef609cb16b96b200fa13e563f396e9bb1ed0905fdab7bc3bc513b.gradle --init-script /var/folders/r3/41cmfh2s0l52ndz3w971w7lc0000gn/T/52cde0cfcf3e28b8b7510e992210d9614505e0911af0c190bd590d7158574963.gradle +auto.sync=false +build.scans.enabled=false +connection.gradle.distribution=GRADLE_DISTRIBUTION(WRAPPER) +connection.project.dir= +eclipse.preferences.version=1 +gradle.user.home= +java.home=/Library/Java/JavaVirtualMachines/temurin-21.jdk/Contents/Home +jvm.arguments= +offline.mode=false +override.workspace.settings=true +show.console.view=true +show.executions.view=true diff --git a/src/keepass2android-app/KeeShare.cs b/src/keepass2android-app/KeeShare.cs new file mode 100644 index 000000000..56d037201 --- /dev/null +++ b/src/keepass2android-app/KeeShare.cs @@ -0,0 +1,288 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Android.App; +using KeePassLib; +using KeePassLib.Interfaces; +using KeePassLib.Keys; +using KeePassLib.Serialization; +using keepass2android.Io; +using KeePassLib.Utility; +using System.IO.Compression; +using Android.Content; + +namespace keepass2android +{ + public class KeeShare + { + public static void Check(IKp2aApp app, OnOperationFinishedHandler nextHandler) + { + var db = app.CurrentDb; + if (db == null || !db.KpDatabase.IsOpen) + { + nextHandler?.Run(); + return; + } + + if (!HasKeeShareGroups(db.KpDatabase.RootGroup)) + { + nextHandler?.Run(); + return; + } + + var op = new KeeShareCheckOperation(app, nextHandler); + new BlockingOperationStarter(app, op).Run(); + } + + private static bool HasKeeShareGroups(PwGroup group) + { + if (group.CustomData.Get("KeeShare.Active") == "true") + return true; + + foreach (var sub in group.Groups) + { + if (HasKeeShareGroups(sub)) return true; + } + return false; + } + } + + public class KeeShareCheckOperation : OperationWithFinishHandler + { + private readonly IKp2aApp _app; + + public KeeShareCheckOperation(IKp2aApp app, OnOperationFinishedHandler handler) + : base(app, handler) + { + _app = app; + } + + public override void Run() + { + try + { + ProcessGroup(_app.CurrentDb.KpDatabase.RootGroup); + Finish(true); + } + catch (Exception ex) + { + Kp2aLog.LogUnexpectedError(ex); + Finish(false, "KeeShare error: " + ex.Message); + } + } + + private void ProcessGroup(PwGroup group) + { + if (group.CustomData.Get("KeeShare.Active") == "true") + { + try + { + ProcessKeeShare(group); + } + catch (Exception ex) + { + Kp2aLog.Log("Error processing KeeShare for group " + group.Name + ": " + ex.ToString()); + // Continue with other groups even if one fails + } + } + + // We must iterate over a copy of the groups list because ProcessKeeShare (Import) might modify subgroups + // However, Import usually modifies the *content* of the group, replacing subgroups. + // If we replace subgroups, we shouldn't recurse into the *old* subgroups? + // Or should we recurse into the *new* subgroups? + // KeeShare groups are usually leaf nodes in terms of configuration (you don't have nested KeeShare groups usually). + // But just in case, let's recurse first? No, if I import, I overwrite. + + foreach (var sub in group.Groups.ToList()) + { + ProcessGroup(sub); + } + } + + private void ProcessKeeShare(PwGroup group) + { + string type = group.CustomData.Get("KeeShare.Type"); + string path = group.CustomData.Get("KeeShare.FilePath"); + string password = group.CustomData.Get("KeeShare.Password"); + + if (string.IsNullOrEmpty(path)) return; + + if (type == "Import" || type == "Synchronize") + { + StatusLogger.UpdateMessage(_app.GetResourceString(UiStringKey.OpeningDatabase) + ": " + group.Name); + Import(group, path, password); + } + } + + private void Import(PwGroup targetGroup, string path, string password) + { + IOConnectionInfo ioc = ResolvePath(path); + + try + { + using (Stream s = OpenStream(ioc)) + { + if (s == null) return; + + // Check if it's a Zip (KeeShare signed file) + // We can't seek on some streams, so we might need to copy to memory if we need random access + // But KdbxFile loads from stream. ZipArchive needs seekable stream usually. + + MemoryStream ms = new MemoryStream(); + s.CopyTo(ms); + ms.Position = 0; + + Stream kdbxStream = ms; + bool isZip = false; + + // Check for PK header (Zip) + if (ms.Length > 4) + { + byte[] header = new byte[4]; + ms.Read(header, 0, 4); + ms.Position = 0; + if (header[0] == 0x50 && header[1] == 0x4b && header[2] == 0x03 && header[3] == 0x04) + { + isZip = true; + } + } + + if (isZip) + { + try + { + using (ZipArchive archive = new ZipArchive(ms, ZipArchiveMode.Read, true)) + { + // Find .kdbx file + var kdbxEntry = archive.Entries.FirstOrDefault(e => e.Name.EndsWith(".kdbx", StringComparison.OrdinalIgnoreCase)); + if (kdbxEntry != null) + { + // Extract to a new memory stream because KdbxFile might close it or we need a clean stream + MemoryStream kdbxMem = new MemoryStream(); + using (var es = kdbxEntry.Open()) + { + es.CopyTo(kdbxMem); + } + kdbxMem.Position = 0; + kdbxStream = kdbxMem; + } + } + } + catch (Exception ex) + { + Kp2aLog.Log("Failed to treat file as zip: " + ex.Message); + ms.Position = 0; // Rewind and try as KDBX directly + kdbxStream = ms; + } + } + + // Load the KDBX + PwDatabase shareDb = new PwDatabase(); + CompositeKey key = new CompositeKey(); + if (!string.IsNullOrEmpty(password)) + { + key.AddUserKey(new KcpPassword(password)); + } + // If password is empty, KcpPassword("") is added? or just empty composite key? + // KeeShare without password implies empty password or no master key? + // Usually shares have passwords. If empty, try empty password. + if (key.UserKeys.Count() == 0) + key.AddUserKey(new KcpPassword("")); + + KdbxFile kdbx = new KdbxFile(shareDb); + // We need a null status logger or similar + kdbx.Load(kdbxStream, KdbxFormat.Default, key); + + // Now copy content from shareDb.RootGroup to targetGroup + SyncGroups(shareDb.RootGroup, targetGroup); + } + } + catch (Exception ex) + { + Kp2aLog.Log("KeeShare import failed for " + path + ": " + ex.Message); + // Don't fail the whole operation, just log + } + } + + private void SyncGroups(PwGroup source, PwGroup target) + { + // For minimal implementation: clear target and copy source + // But we must preserve the CustomData of the target group (KeeShare config)! + // And Name/Icon/Uuid of the target group should probably stay? + // KeeShare says: "The group in your database is synchronized with the Shared Database" + + // We should keep: UUID, Parent, Name, Icon (maybe?), CustomData + + // Clear entries and subgroups + target.Entries.Clear(); + target.Groups.Clear(); + + // Copy entries + foreach (var entry in source.Entries) + { + target.AddEntry(entry.CloneDeep(), true); + } + + // Copy subgroups + foreach (var group in source.Groups) + { + target.AddGroup(group.CloneDeep(), true); + } + + // We might want to update Name/Icon/Notes from source if they changed? + // For now, keeping it simple (only content). + + target.Touch(true, false); + } + + private IOConnectionInfo ResolvePath(string path) + { + // Check if absolute + if (path.Contains("://") || path.StartsWith("/")) + { + return IOConnectionInfo.FromPath(path); + } + + // Try relative to current DB + try + { + var currentIoc = _app.CurrentDb.Ioc; + var storage = _app.GetFileStorage(currentIoc); + + // This is a bit hacky as GetParentPath is not always supported or returns something valid + // But let's try. + + // If it's a local file, we can use Path.Combine + if (currentIoc.IsLocalFile()) + { + string dir = Path.GetDirectoryName(currentIoc.Path); + string fullPath = Path.Combine(dir, path); + return IOConnectionInfo.FromPath(fullPath); + } + + // For other storages, it depends. + // Assume path is relative to same storage + // Many storages don't support relative paths easily without full URL manipulation + // For now, return as is if not local, or try to reconstruct + } + catch {} + + return IOConnectionInfo.FromPath(path); + } + + private Stream OpenStream(IOConnectionInfo ioc) + { + try + { + var storage = _app.GetFileStorage(ioc); + return storage.OpenFileForRead(ioc); + } + catch (Exception ex) + { + Kp2aLog.Log("Failed to open stream for " + ioc.Path + ": " + ex.Message); + return null; + } + } + } +} diff --git a/src/keepass2android-app/PasswordActivity.cs b/src/keepass2android-app/PasswordActivity.cs index 03eaeec88..c5c2e9cc3 100644 --- a/src/keepass2android-app/PasswordActivity.cs +++ b/src/keepass2android-app/PasswordActivity.cs @@ -1450,7 +1450,19 @@ private void PerformLoadDatabaseWithCompositeKey(CompositeKey compositeKey) MakePasswordMaskedOrVisible(); Handler handler = new Handler(); - OnOperationFinishedHandler onOperationFinishedHandler = new AfterLoad(handler, this, _ioConnection); + OnOperationFinishedHandler afterLoadHandler = new AfterLoad(handler, this, _ioConnection); + OnOperationFinishedHandler onOperationFinishedHandler = new ActionOnOperationFinished(App.Kp2a, (success, message, activity) => + { + if (success) + { + KeeShare.Check(App.Kp2a, afterLoadHandler); + } + else + { + afterLoadHandler.SetResult(success, message, false, null); + afterLoadHandler.Run(); + } + }); LoadDb loadOperation = (KeyProviderTypes.Contains(KeyProviders.Otp)) ? new SaveOtpAuxFileAndLoadDb(App.Kp2a, _ioConnection, _loadDbFileTask, compositeKey, GetKeyProviderString(), onOperationFinishedHandler, this, true, _makeCurrent) From 356a1c5db3b8ca7e6574fef21dabcb6cb43d065e Mon Sep 17 00:00:00 2001 From: plyght Date: Sun, 30 Nov 2025 19:25:35 -0500 Subject: [PATCH 02/31] updates --- src/KeeShare.Tests/HasKeeShareGroupsTests.cs | 92 +++++ src/KeeShare.Tests/KeeShare.Tests.csproj | 36 ++ .../SignatureVerificationTests.cs | 242 +++++++++++++ src/keepass2android-app/KeeShare.cs | 322 +++++++++++++++--- 4 files changed, 642 insertions(+), 50 deletions(-) create mode 100644 src/KeeShare.Tests/HasKeeShareGroupsTests.cs create mode 100644 src/KeeShare.Tests/KeeShare.Tests.csproj create mode 100644 src/KeeShare.Tests/SignatureVerificationTests.cs diff --git a/src/KeeShare.Tests/HasKeeShareGroupsTests.cs b/src/KeeShare.Tests/HasKeeShareGroupsTests.cs new file mode 100644 index 000000000..f6006bf3e --- /dev/null +++ b/src/KeeShare.Tests/HasKeeShareGroupsTests.cs @@ -0,0 +1,92 @@ +namespace KeeShare.Tests +{ + public class HasKeeShareGroupsTests + { + [Fact] + public void HasKeeShareGroups_WithNoGroups_ReturnsFalse() + { + var root = new MockPwGroup(); + bool result = KeeShareHelpers.HasKeeShareGroups(root); + Assert.False(result); + } + + [Fact] + public void HasKeeShareGroups_WithKeeShareActiveTrue_ReturnsTrue() + { + var root = new MockPwGroup(); + root.CustomData["KeeShare.Active"] = "true"; + bool result = KeeShareHelpers.HasKeeShareGroups(root); + Assert.True(result); + } + + [Fact] + public void HasKeeShareGroups_WithKeeShareActiveFalse_ReturnsFalse() + { + var root = new MockPwGroup(); + root.CustomData["KeeShare.Active"] = "false"; + bool result = KeeShareHelpers.HasKeeShareGroups(root); + Assert.False(result); + } + + [Fact] + public void HasKeeShareGroups_WithNestedKeeShareGroup_ReturnsTrue() + { + var root = new MockPwGroup(); + var child1 = new MockPwGroup(); + var child2 = new MockPwGroup(); + child2.CustomData["KeeShare.Active"] = "true"; + child1.Groups.Add(child2); + root.Groups.Add(child1); + bool result = KeeShareHelpers.HasKeeShareGroups(root); + Assert.True(result); + } + + [Fact] + public void HasKeeShareGroups_WithMultipleNonKeeShareGroups_ReturnsFalse() + { + var root = new MockPwGroup(); + root.Groups.Add(new MockPwGroup()); + root.Groups.Add(new MockPwGroup()); + root.Groups.Add(new MockPwGroup()); + bool result = KeeShareHelpers.HasKeeShareGroups(root); + Assert.False(result); + } + + [Fact] + public void HasKeeShareGroups_WithDeeplyNestedKeeShareGroup_ReturnsTrue() + { + var root = new MockPwGroup(); + var level1 = new MockPwGroup(); + var level2 = new MockPwGroup(); + var level3 = new MockPwGroup(); + level3.CustomData["KeeShare.Active"] = "true"; + level2.Groups.Add(level3); + level1.Groups.Add(level2); + root.Groups.Add(level1); + bool result = KeeShareHelpers.HasKeeShareGroups(root); + Assert.True(result); + } + } + + public class MockPwGroup + { + public Dictionary CustomData { get; set; } = new Dictionary(); + public List Groups { get; set; } = new List(); + } + + public static class KeeShareHelpers + { + public static bool HasKeeShareGroups(MockPwGroup group) + { + if (group.CustomData.ContainsKey("KeeShare.Active") && + group.CustomData["KeeShare.Active"] == "true") + return true; + + foreach (var sub in group.Groups) + { + if (HasKeeShareGroups(sub)) return true; + } + return false; + } + } +} diff --git a/src/KeeShare.Tests/KeeShare.Tests.csproj b/src/KeeShare.Tests/KeeShare.Tests.csproj new file mode 100644 index 000000000..e0d633870 --- /dev/null +++ b/src/KeeShare.Tests/KeeShare.Tests.csproj @@ -0,0 +1,36 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + + + + + + + + + + + PreserveNewest + + + + + + + + diff --git a/src/KeeShare.Tests/SignatureVerificationTests.cs b/src/KeeShare.Tests/SignatureVerificationTests.cs new file mode 100644 index 000000000..3111a45a2 --- /dev/null +++ b/src/KeeShare.Tests/SignatureVerificationTests.cs @@ -0,0 +1,242 @@ +using System.Security.Cryptography; +using System.Text; + +namespace KeeShare.Tests +{ + public class SignatureVerificationTests + { + [Fact] + public void VerifySignature_WithValidSignature_ReturnsTrue() + { + using var rsa = RSA.Create(2048); + var publicKeyBytes = rsa.ExportSubjectPublicKeyInfo(); + var publicKeyCert = Convert.ToBase64String(publicKeyBytes); + byte[] testData = Encoding.UTF8.GetBytes("Test KDBX data content"); + byte[] hash = SHA256.HashData(testData); + byte[] signature = rsa.SignHash(hash, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + string signatureBase64 = Convert.ToBase64String(signature); + byte[] signatureData = Encoding.UTF8.GetBytes(signatureBase64); + bool result = SignatureVerifier.VerifySignature(publicKeyCert, testData, signatureData); + Assert.True(result, "Signature verification should succeed with valid signature"); + } + + [Fact] + public void VerifySignature_WithInvalidSignature_ReturnsFalse() + { + using var rsa = RSA.Create(2048); + var publicKeyBytes = rsa.ExportSubjectPublicKeyInfo(); + var publicKeyCert = Convert.ToBase64String(publicKeyBytes); + byte[] testData = Encoding.UTF8.GetBytes("Test KDBX data content"); + byte[] invalidSignature = new byte[256]; + new Random().NextBytes(invalidSignature); + string signatureBase64 = Convert.ToBase64String(invalidSignature); + byte[] signatureData = Encoding.UTF8.GetBytes(signatureBase64); + bool result = SignatureVerifier.VerifySignature(publicKeyCert, testData, signatureData); + Assert.False(result, "Signature verification should fail with invalid signature"); + } + + [Fact] + public void VerifySignature_WithTamperedData_ReturnsFalse() + { + using var rsa = RSA.Create(2048); + var publicKeyBytes = rsa.ExportSubjectPublicKeyInfo(); + var publicKeyCert = Convert.ToBase64String(publicKeyBytes); + byte[] originalData = Encoding.UTF8.GetBytes("Original KDBX data"); + byte[] hash = SHA256.HashData(originalData); + byte[] signature = rsa.SignHash(hash, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + string signatureBase64 = Convert.ToBase64String(signature); + byte[] signatureData = Encoding.UTF8.GetBytes(signatureBase64); + byte[] tamperedData = Encoding.UTF8.GetBytes("Tampered KDBX data"); + bool result = SignatureVerifier.VerifySignature(publicKeyCert, tamperedData, signatureData); + Assert.False(result, "Signature verification should fail when data is tampered"); + } + + [Fact] + public void VerifySignature_WithPemFormattedCertificate_ReturnsTrue() + { + using var rsa = RSA.Create(2048); + var publicKeyBytes = rsa.ExportSubjectPublicKeyInfo(); + var publicKeyCertBase64 = Convert.ToBase64String(publicKeyBytes); + string publicKeyCertPem = $"-----BEGIN PUBLIC KEY-----\n{publicKeyCertBase64}\n-----END PUBLIC KEY-----"; + byte[] testData = Encoding.UTF8.GetBytes("Test KDBX data content"); + byte[] hash = SHA256.HashData(testData); + byte[] signature = rsa.SignHash(hash, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + string signatureBase64 = Convert.ToBase64String(signature); + byte[] signatureData = Encoding.UTF8.GetBytes(signatureBase64); + bool result = SignatureVerifier.VerifySignature(publicKeyCertPem, testData, signatureData); + Assert.True(result, "Signature verification should work with PEM formatted certificate"); + } + + [Fact] + public void VerifySignature_WithEmptyCertificate_ReturnsFalse() + { + byte[] testData = Encoding.UTF8.GetBytes("Test data"); + byte[] signatureData = Encoding.UTF8.GetBytes("fake signature"); + bool result = SignatureVerifier.VerifySignature("", testData, signatureData); + Assert.False(result, "Signature verification should fail with empty certificate"); + } + + [Fact] + public void VerifySignature_WithNullData_ReturnsFalse() + { + using var rsa = RSA.Create(2048); + var publicKeyBytes = rsa.ExportSubjectPublicKeyInfo(); + var publicKeyCert = Convert.ToBase64String(publicKeyBytes); + byte[] signatureData = Encoding.UTF8.GetBytes("signature"); + bool result = SignatureVerifier.VerifySignature(publicKeyCert, null, signatureData); + Assert.False(result, "Signature verification should fail with null data"); + } + + [Fact] + public void VerifySignature_WithEmptyData_ReturnsFalse() + { + using var rsa = RSA.Create(2048); + var publicKeyBytes = rsa.ExportSubjectPublicKeyInfo(); + var publicKeyCert = Convert.ToBase64String(publicKeyBytes); + byte[] signatureData = Encoding.UTF8.GetBytes("signature"); + bool result = SignatureVerifier.VerifySignature(publicKeyCert, new byte[0], signatureData); + Assert.False(result, "Signature verification should fail with empty data"); + } + + [Fact] + public void VerifySignature_WithMalformedBase64Signature_ReturnsFalse() + { + using var rsa = RSA.Create(2048); + var publicKeyBytes = rsa.ExportSubjectPublicKeyInfo(); + var publicKeyCert = Convert.ToBase64String(publicKeyBytes); + byte[] testData = Encoding.UTF8.GetBytes("Test data"); + byte[] signatureData = Encoding.UTF8.GetBytes("not-valid-base64!@#$"); + bool result = SignatureVerifier.VerifySignature(publicKeyCert, testData, signatureData); + Assert.False(result, "Signature verification should fail with malformed base64"); + } + + [Fact] + public void VerifySignature_WithSignatureContainingWhitespace_ReturnsTrue() + { + using var rsa = RSA.Create(2048); + var publicKeyBytes = rsa.ExportSubjectPublicKeyInfo(); + var publicKeyCert = Convert.ToBase64String(publicKeyBytes); + byte[] testData = Encoding.UTF8.GetBytes("Test KDBX data content"); + byte[] hash = SHA256.HashData(testData); + byte[] signature = rsa.SignHash(hash, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + string signatureBase64 = Convert.ToBase64String(signature); + string signatureWithWhitespace = $"\r\n {signatureBase64} \r\n"; + byte[] signatureData = Encoding.UTF8.GetBytes(signatureWithWhitespace); + bool result = SignatureVerifier.VerifySignature(publicKeyCert, testData, signatureData); + Assert.True(result, "Signature verification should handle whitespace in signature"); + } + } + + public static class SignatureVerifier + { + public static bool VerifySignature(string trustedCertificate, byte[]? kdbxData, byte[]? signatureData) + { + try + { + if (string.IsNullOrEmpty(trustedCertificate)) + { + return false; + } + + if (signatureData == null || signatureData.Length == 0) + { + return false; + } + + if (kdbxData == null || kdbxData.Length == 0) + { + return false; + } + + string signatureText = Encoding.UTF8.GetString(signatureData).Trim(); + signatureText = signatureText.Replace("\r", "").Replace("\n", "").Replace(" ", ""); + + byte[] signatureBytes; + try + { + signatureBytes = Convert.FromBase64String(signatureText); + } + catch (Exception) + { + return false; + } + + byte[] publicKeyBytes; + try + { + if (trustedCertificate.Contains("-----BEGIN")) + { + var lines = trustedCertificate.Split('\n'); + var base64Lines = lines.Where(l => !l.Contains("BEGIN") && !l.Contains("END") && !string.IsNullOrWhiteSpace(l)); + string base64Content = string.Join("", base64Lines).Trim(); + publicKeyBytes = Convert.FromBase64String(base64Content); + } + else + { + publicKeyBytes = Convert.FromBase64String(trustedCertificate); + } + } + catch (Exception) + { + return false; + } + + RSA rsa = RSA.Create(); + try + { + try + { + rsa.ImportSubjectPublicKeyInfo(publicKeyBytes, out int bytesRead); + if (bytesRead == 0) + { + throw new CryptographicException("No bytes read from public key"); + } + } + catch (Exception) + { + try + { + rsa.ImportRSAPublicKey(publicKeyBytes, out int bytesRead); + if (bytesRead == 0) + { + throw new CryptographicException("No bytes read from public key"); + } + } + catch (Exception) + { + rsa.Dispose(); + return false; + } + } + + byte[] hash; + using (SHA256 sha256 = SHA256.Create()) + { + hash = sha256.ComputeHash(kdbxData); + } + + bool isValid = false; + try + { + isValid = rsa.VerifyHash(hash, signatureBytes, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + } + finally + { + rsa.Dispose(); + } + + return isValid; + } + catch + { + rsa.Dispose(); + return false; + } + } + catch (Exception) + { + return false; + } + } + } +} diff --git a/src/keepass2android-app/KeeShare.cs b/src/keepass2android-app/KeeShare.cs index 56d037201..26332d25a 100644 --- a/src/keepass2android-app/KeeShare.cs +++ b/src/keepass2android-app/KeeShare.cs @@ -11,6 +11,8 @@ using KeePassLib.Utility; using System.IO.Compression; using Android.Content; +using System.Security.Cryptography; +using System.Text; namespace keepass2android { @@ -87,13 +89,8 @@ private void ProcessGroup(PwGroup group) } } - // We must iterate over a copy of the groups list because ProcessKeeShare (Import) might modify subgroups - // However, Import usually modifies the *content* of the group, replacing subgroups. - // If we replace subgroups, we shouldn't recurse into the *old* subgroups? - // Or should we recurse into the *new* subgroups? - // KeeShare groups are usually leaf nodes in terms of configuration (you don't have nested KeeShare groups usually). - // But just in case, let's recurse first? No, if I import, I overwrite. - + // Process subgroups AFTER processing KeeShare, so we recurse into the newly imported groups + // We must iterate over a copy of the groups list to avoid issues if ProcessGroup modifies the collection foreach (var sub in group.Groups.ToList()) { ProcessGroup(sub); @@ -130,72 +127,156 @@ private void Import(PwGroup targetGroup, string path, string password) // But KdbxFile loads from stream. ZipArchive needs seekable stream usually. MemoryStream ms = new MemoryStream(); - s.CopyTo(ms); - ms.Position = 0; - - Stream kdbxStream = ms; - bool isZip = false; - - // Check for PK header (Zip) - if (ms.Length > 4) + try { - byte[] header = new byte[4]; - ms.Read(header, 0, 4); + s.CopyTo(ms); ms.Position = 0; - if (header[0] == 0x50 && header[1] == 0x4b && header[2] == 0x03 && header[3] == 0x04) + + Stream kdbxStream = ms; + MemoryStream kdbxMem = null; + bool isZip = false; + + // Check for PK header (Zip) + if (ms.Length > 4) { - isZip = true; + byte[] header = new byte[4]; + ms.Read(header, 0, 4); + ms.Position = 0; + if (header[0] == 0x50 && header[1] == 0x4b && header[2] == 0x03 && header[3] == 0x04) + { + isZip = true; + } } - } - if (isZip) - { - try + if (isZip) { - using (ZipArchive archive = new ZipArchive(ms, ZipArchiveMode.Read, true)) + try { - // Find .kdbx file - var kdbxEntry = archive.Entries.FirstOrDefault(e => e.Name.EndsWith(".kdbx", StringComparison.OrdinalIgnoreCase)); - if (kdbxEntry != null) + using (ZipArchive archive = new ZipArchive(ms, ZipArchiveMode.Read, true)) { + // Find .kdbx file + var kdbxEntry = archive.Entries.FirstOrDefault(e => e.Name.EndsWith(".kdbx", StringComparison.OrdinalIgnoreCase)); + if (kdbxEntry == null) + { + Kp2aLog.Log("KeeShare: No .kdbx file found in ZIP archive"); + return; + } + // Extract to a new memory stream because KdbxFile might close it or we need a clean stream - MemoryStream kdbxMem = new MemoryStream(); + kdbxMem = new MemoryStream(); using (var es = kdbxEntry.Open()) { es.CopyTo(kdbxMem); } + + // Store kdbx data for signature verification + byte[] kdbxData = kdbxMem.ToArray(); + + // Check for signature file (.sig) and verify if certificate is trusted + var sigEntry = archive.Entries.FirstOrDefault(e => + e.Name.EndsWith(".sig", StringComparison.OrdinalIgnoreCase) || + e.Name.EndsWith(".signature", StringComparison.OrdinalIgnoreCase)); + + // Check if a trusted certificate is configured + string trustedCert = targetGroup.CustomData.Get("KeeShare.TrustedCertificate"); + bool hasTrustedCert = !string.IsNullOrEmpty(trustedCert); + + if (sigEntry != null) + { + // Only verify signature if a trusted certificate is configured + if (hasTrustedCert) + { + // Extract signature for verification + byte[] signatureData; + using (var sigStream = sigEntry.Open()) + using (var sigMem = new MemoryStream()) + { + sigStream.CopyTo(sigMem); + signatureData = sigMem.ToArray(); + } + + // Verify signature - only import if certificate is trusted and signature is valid + if (!VerifySignature(targetGroup, kdbxData, signatureData)) + { + Kp2aLog.Log("KeeShare: Signature verification failed or certificate not trusted for " + path + ". Skipping import."); + return; + } + else + { + Kp2aLog.Log("KeeShare: Signature verified successfully for " + path); + } + } + else + { + // Signature file exists but no certificate configured - skip verification for backward compatibility + Kp2aLog.Log("KeeShare: Signature file found but no trusted certificate configured for " + path + ". Continuing without signature verification (backward compatibility)."); + } + } + else + { + // If a trusted certificate is configured, we MUST have a signature file for security + if (hasTrustedCert) + { + Kp2aLog.Log("KeeShare: Trusted certificate is configured but no signature file found in ZIP archive for " + path + ". Skipping import for security."); + return; + } + else + { + Kp2aLog.Log("KeeShare: No signature file found in ZIP archive for " + path + ". Continuing without signature verification (backward compatibility)."); + } + } + + // Reset stream position for KDBX loading kdbxMem.Position = 0; kdbxStream = kdbxMem; } } + catch (Exception ex) + { + Kp2aLog.Log("Failed to treat file as zip: " + ex.Message); + ms.Position = 0; // Rewind and try as KDBX directly + kdbxStream = ms; + // kdbxMem will be disposed in finally block if it was created + } + } + + // Load the KDBX + try + { + PwDatabase shareDb = new PwDatabase(); + CompositeKey key = new CompositeKey(); + if (!string.IsNullOrEmpty(password)) + { + key.AddUserKey(new KcpPassword(password)); + } + // If password is empty, KcpPassword("") is added? or just empty composite key? + // KeeShare without password implies empty password or no master key? + // Usually shares have passwords. If empty, try empty password. + if (key.UserKeys.Count() == 0) + key.AddUserKey(new KcpPassword("")); + + KdbxFile kdbx = new KdbxFile(shareDb); + kdbx.Load(kdbxStream, KdbxFormat.Default, key); + + // Now copy content from shareDb.RootGroup to targetGroup + SyncGroups(shareDb.RootGroup, targetGroup); } - catch (Exception ex) + finally { - Kp2aLog.Log("Failed to treat file as zip: " + ex.Message); - ms.Position = 0; // Rewind and try as KDBX directly - kdbxStream = ms; + // Dispose kdbxMem if it was created (for ZIP files) + if (kdbxMem != null && kdbxMem != ms) + { + kdbxMem.Dispose(); + } } } - - // Load the KDBX - PwDatabase shareDb = new PwDatabase(); - CompositeKey key = new CompositeKey(); - if (!string.IsNullOrEmpty(password)) + finally { - key.AddUserKey(new KcpPassword(password)); + // Dispose ms if it's not being used as kdbxStream (i.e., if kdbxMem was used instead) + // Note: If ms is used as kdbxStream, KdbxFile.Load will handle it, but we should still dispose + // Actually, ms is always used either directly or indirectly, so we dispose it here + ms.Dispose(); } - // If password is empty, KcpPassword("") is added? or just empty composite key? - // KeeShare without password implies empty password or no master key? - // Usually shares have passwords. If empty, try empty password. - if (key.UserKeys.Count() == 0) - key.AddUserKey(new KcpPassword("")); - - KdbxFile kdbx = new KdbxFile(shareDb); - // We need a null status logger or similar - kdbx.Load(kdbxStream, KdbxFormat.Default, key); - - // Now copy content from shareDb.RootGroup to targetGroup - SyncGroups(shareDb.RootGroup, targetGroup); } } catch (Exception ex) @@ -284,5 +365,146 @@ private Stream OpenStream(IOConnectionInfo ioc) return null; } } + + /// + /// Verifies the signature of a KeeShare file. + /// Returns true if signature is valid and certificate is trusted, false otherwise. + /// + private bool VerifySignature(PwGroup group, byte[] kdbxData, byte[] signatureData) + { + try + { + // Get trusted certificate (public key) from group CustomData + string trustedCert = group.CustomData.Get("KeeShare.TrustedCertificate"); + + if (string.IsNullOrEmpty(trustedCert)) + { + Kp2aLog.Log("KeeShare: No trusted certificate configured for group " + group.Name); + return false; + } + + if (signatureData == null || signatureData.Length == 0) + { + Kp2aLog.Log("KeeShare: Signature file is empty"); + return false; + } + + if (kdbxData == null || kdbxData.Length == 0) + { + Kp2aLog.Log("KeeShare: KDBX data is empty"); + return false; + } + + // KeeShare signature format: base64-encoded RSA signature + // The signature is computed over the kdbx file data using SHA-256 + string signatureText = Encoding.UTF8.GetString(signatureData).Trim(); + + // Remove any whitespace/newlines + signatureText = signatureText.Replace("\r", "").Replace("\n", "").Replace(" ", ""); + + byte[] signatureBytes; + try + { + signatureBytes = Convert.FromBase64String(signatureText); + } + catch (Exception ex) + { + Kp2aLog.Log("KeeShare: Failed to decode base64 signature: " + ex.Message); + return false; + } + + // Parse the trusted certificate (public key) + // Format: PEM-encoded public key or base64-encoded DER + byte[] publicKeyBytes; + try + { + // Try to decode as base64 first + if (trustedCert.Contains("-----BEGIN")) + { + // PEM format - extract base64 content + var lines = trustedCert.Split('\n'); + var base64Lines = lines.Where(l => !l.Contains("BEGIN") && !l.Contains("END") && !string.IsNullOrWhiteSpace(l)); + string base64Content = string.Join("", base64Lines).Trim(); + publicKeyBytes = Convert.FromBase64String(base64Content); + } + else + { + // Assume it's already base64-encoded DER + publicKeyBytes = Convert.FromBase64String(trustedCert); + } + } + catch (Exception ex) + { + Kp2aLog.Log("KeeShare: Failed to parse trusted certificate: " + ex.Message); + return false; + } + + // Create RSA object from public key + RSA rsa = RSA.Create(); + try + { + // Try importing as SubjectPublicKeyInfo (standard format) + rsa.ImportSubjectPublicKeyInfo(publicKeyBytes, out int bytesRead); + if (bytesRead == 0) + { + throw new CryptographicException("No bytes read from public key"); + } + } + catch (Exception ex) + { + Kp2aLog.Log("KeeShare: Failed to import public key as SubjectPublicKeyInfo: " + ex.Message); + try + { + // Try importing as RSAPublicKey (PKCS#1 format) + rsa.ImportRSAPublicKey(publicKeyBytes, out int bytesRead); + if (bytesRead == 0) + { + throw new CryptographicException("No bytes read from public key"); + } + } + catch (Exception ex2) + { + Kp2aLog.Log("KeeShare: Failed to import public key as RSAPublicKey: " + ex2.Message); + rsa.Dispose(); + return false; + } + } + + // Compute hash of kdbx data + byte[] hash; + using (SHA256 sha256 = SHA256.Create()) + { + hash = sha256.ComputeHash(kdbxData); + } + + // Verify signature + bool isValid = false; + try + { + isValid = rsa.VerifyHash(hash, signatureBytes, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + + if (isValid) + { + Kp2aLog.Log("KeeShare: Signature verified successfully for group " + group.Name); + } + else + { + Kp2aLog.Log("KeeShare: Signature verification failed for group " + group.Name); + } + } + finally + { + rsa.Dispose(); + } + + return isValid; + } + catch (Exception ex) + { + Kp2aLog.Log("KeeShare: Error verifying signature: " + ex.Message); + Kp2aLog.Log("KeeShare: Stack trace: " + ex.StackTrace); + return false; + } + } } } From 48f0bfa66649f7ee62b7f440627bb9f642348201 Mon Sep 17 00:00:00 2001 From: plyght Date: Sun, 30 Nov 2025 19:44:13 -0500 Subject: [PATCH 03/31] Refactor KeeShare tests to use PwGroup and update signature verification logic. Add project references for keepass2android-app and KeePassLib2Android. Update .gitignore to include PluginQR settings. Remove obsolete settings file. --- .gitignore | 2 + src/KeeShare.Tests/HasKeeShareGroupsTests.cs | 83 +++----- src/KeeShare.Tests/KeeShare.Tests.csproj | 5 + .../SignatureVerificationTests.cs | 132 +----------- .../org.eclipse.buildship.core.prefs | 13 -- src/keepass2android-app/AssemblyInfo.cs | 3 +- src/keepass2android-app/KeeShare.cs | 188 +++++++++++------- 7 files changed, 163 insertions(+), 263 deletions(-) delete mode 100644 src/java/PluginQR/.settings/org.eclipse.buildship.core.prefs diff --git a/.gitignore b/.gitignore index edfb7c624..bbb1ba6a4 100644 --- a/.gitignore +++ b/.gitignore @@ -48,6 +48,8 @@ Thumbs.db /src/java/kp2akeytransform/.settings +/src/java/PluginQR/.settings + /src/java/workspace/UITest /src/java/JavaFileStorage/gen/keepass2android/javafilestorage/BuildConfig.java /src/java/JavaFileStorage/gen/keepass2android/javafilestorage/R.java diff --git a/src/KeeShare.Tests/HasKeeShareGroupsTests.cs b/src/KeeShare.Tests/HasKeeShareGroupsTests.cs index f6006bf3e..079de90f8 100644 --- a/src/KeeShare.Tests/HasKeeShareGroupsTests.cs +++ b/src/KeeShare.Tests/HasKeeShareGroupsTests.cs @@ -1,3 +1,6 @@ +using KeePassLib; +using keepass2android; + namespace KeeShare.Tests { public class HasKeeShareGroupsTests @@ -5,88 +8,66 @@ public class HasKeeShareGroupsTests [Fact] public void HasKeeShareGroups_WithNoGroups_ReturnsFalse() { - var root = new MockPwGroup(); - bool result = KeeShareHelpers.HasKeeShareGroups(root); + var root = new PwGroup(); + bool result = keepass2android.KeeShare.HasKeeShareGroups(root); Assert.False(result); } [Fact] public void HasKeeShareGroups_WithKeeShareActiveTrue_ReturnsTrue() { - var root = new MockPwGroup(); - root.CustomData["KeeShare.Active"] = "true"; - bool result = KeeShareHelpers.HasKeeShareGroups(root); + var root = new PwGroup(); + root.CustomData.Set("KeeShare.Active", "true"); + bool result = keepass2android.KeeShare.HasKeeShareGroups(root); Assert.True(result); } [Fact] public void HasKeeShareGroups_WithKeeShareActiveFalse_ReturnsFalse() { - var root = new MockPwGroup(); - root.CustomData["KeeShare.Active"] = "false"; - bool result = KeeShareHelpers.HasKeeShareGroups(root); + var root = new PwGroup(); + root.CustomData.Set("KeeShare.Active", "false"); + bool result = keepass2android.KeeShare.HasKeeShareGroups(root); Assert.False(result); } [Fact] public void HasKeeShareGroups_WithNestedKeeShareGroup_ReturnsTrue() { - var root = new MockPwGroup(); - var child1 = new MockPwGroup(); - var child2 = new MockPwGroup(); - child2.CustomData["KeeShare.Active"] = "true"; - child1.Groups.Add(child2); - root.Groups.Add(child1); - bool result = KeeShareHelpers.HasKeeShareGroups(root); + var root = new PwGroup(); + var child1 = new PwGroup(); + var child2 = new PwGroup(); + child2.CustomData.Set("KeeShare.Active", "true"); + child1.AddGroup(child2, true); + root.AddGroup(child1, true); + bool result = keepass2android.KeeShare.HasKeeShareGroups(root); Assert.True(result); } [Fact] public void HasKeeShareGroups_WithMultipleNonKeeShareGroups_ReturnsFalse() { - var root = new MockPwGroup(); - root.Groups.Add(new MockPwGroup()); - root.Groups.Add(new MockPwGroup()); - root.Groups.Add(new MockPwGroup()); - bool result = KeeShareHelpers.HasKeeShareGroups(root); + var root = new PwGroup(); + root.AddGroup(new PwGroup(), true); + root.AddGroup(new PwGroup(), true); + root.AddGroup(new PwGroup(), true); + bool result = keepass2android.KeeShare.HasKeeShareGroups(root); Assert.False(result); } [Fact] public void HasKeeShareGroups_WithDeeplyNestedKeeShareGroup_ReturnsTrue() { - var root = new MockPwGroup(); - var level1 = new MockPwGroup(); - var level2 = new MockPwGroup(); - var level3 = new MockPwGroup(); - level3.CustomData["KeeShare.Active"] = "true"; - level2.Groups.Add(level3); - level1.Groups.Add(level2); - root.Groups.Add(level1); - bool result = KeeShareHelpers.HasKeeShareGroups(root); + var root = new PwGroup(); + var level1 = new PwGroup(); + var level2 = new PwGroup(); + var level3 = new PwGroup(); + level3.CustomData.Set("KeeShare.Active", "true"); + level2.AddGroup(level3, true); + level1.AddGroup(level2, true); + root.AddGroup(level1, true); + bool result = keepass2android.KeeShare.HasKeeShareGroups(root); Assert.True(result); } } - - public class MockPwGroup - { - public Dictionary CustomData { get; set; } = new Dictionary(); - public List Groups { get; set; } = new List(); - } - - public static class KeeShareHelpers - { - public static bool HasKeeShareGroups(MockPwGroup group) - { - if (group.CustomData.ContainsKey("KeeShare.Active") && - group.CustomData["KeeShare.Active"] == "true") - return true; - - foreach (var sub in group.Groups) - { - if (HasKeeShareGroups(sub)) return true; - } - return false; - } - } } diff --git a/src/KeeShare.Tests/KeeShare.Tests.csproj b/src/KeeShare.Tests/KeeShare.Tests.csproj index e0d633870..6ea878ee3 100644 --- a/src/KeeShare.Tests/KeeShare.Tests.csproj +++ b/src/KeeShare.Tests/KeeShare.Tests.csproj @@ -33,4 +33,9 @@ + + + + + diff --git a/src/KeeShare.Tests/SignatureVerificationTests.cs b/src/KeeShare.Tests/SignatureVerificationTests.cs index 3111a45a2..8add3b14a 100644 --- a/src/KeeShare.Tests/SignatureVerificationTests.cs +++ b/src/KeeShare.Tests/SignatureVerificationTests.cs @@ -1,5 +1,6 @@ using System.Security.Cryptography; using System.Text; +using keepass2android; namespace KeeShare.Tests { @@ -16,7 +17,7 @@ public void VerifySignature_WithValidSignature_ReturnsTrue() byte[] signature = rsa.SignHash(hash, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); string signatureBase64 = Convert.ToBase64String(signature); byte[] signatureData = Encoding.UTF8.GetBytes(signatureBase64); - bool result = SignatureVerifier.VerifySignature(publicKeyCert, testData, signatureData); + bool result = KeeShare.VerifySignatureCore(publicKeyCert, testData, signatureData); Assert.True(result, "Signature verification should succeed with valid signature"); } @@ -31,7 +32,7 @@ public void VerifySignature_WithInvalidSignature_ReturnsFalse() new Random().NextBytes(invalidSignature); string signatureBase64 = Convert.ToBase64String(invalidSignature); byte[] signatureData = Encoding.UTF8.GetBytes(signatureBase64); - bool result = SignatureVerifier.VerifySignature(publicKeyCert, testData, signatureData); + bool result = KeeShare.VerifySignatureCore(publicKeyCert, testData, signatureData); Assert.False(result, "Signature verification should fail with invalid signature"); } @@ -47,7 +48,7 @@ public void VerifySignature_WithTamperedData_ReturnsFalse() string signatureBase64 = Convert.ToBase64String(signature); byte[] signatureData = Encoding.UTF8.GetBytes(signatureBase64); byte[] tamperedData = Encoding.UTF8.GetBytes("Tampered KDBX data"); - bool result = SignatureVerifier.VerifySignature(publicKeyCert, tamperedData, signatureData); + bool result = KeeShare.VerifySignatureCore(publicKeyCert, tamperedData, signatureData); Assert.False(result, "Signature verification should fail when data is tampered"); } @@ -63,7 +64,7 @@ public void VerifySignature_WithPemFormattedCertificate_ReturnsTrue() byte[] signature = rsa.SignHash(hash, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); string signatureBase64 = Convert.ToBase64String(signature); byte[] signatureData = Encoding.UTF8.GetBytes(signatureBase64); - bool result = SignatureVerifier.VerifySignature(publicKeyCertPem, testData, signatureData); + bool result = KeeShare.VerifySignatureCore(publicKeyCertPem, testData, signatureData); Assert.True(result, "Signature verification should work with PEM formatted certificate"); } @@ -72,7 +73,7 @@ public void VerifySignature_WithEmptyCertificate_ReturnsFalse() { byte[] testData = Encoding.UTF8.GetBytes("Test data"); byte[] signatureData = Encoding.UTF8.GetBytes("fake signature"); - bool result = SignatureVerifier.VerifySignature("", testData, signatureData); + bool result = KeeShare.VerifySignatureCore("", testData, signatureData); Assert.False(result, "Signature verification should fail with empty certificate"); } @@ -83,7 +84,7 @@ public void VerifySignature_WithNullData_ReturnsFalse() var publicKeyBytes = rsa.ExportSubjectPublicKeyInfo(); var publicKeyCert = Convert.ToBase64String(publicKeyBytes); byte[] signatureData = Encoding.UTF8.GetBytes("signature"); - bool result = SignatureVerifier.VerifySignature(publicKeyCert, null, signatureData); + bool result = KeeShare.VerifySignatureCore(publicKeyCert, null, signatureData); Assert.False(result, "Signature verification should fail with null data"); } @@ -94,7 +95,7 @@ public void VerifySignature_WithEmptyData_ReturnsFalse() var publicKeyBytes = rsa.ExportSubjectPublicKeyInfo(); var publicKeyCert = Convert.ToBase64String(publicKeyBytes); byte[] signatureData = Encoding.UTF8.GetBytes("signature"); - bool result = SignatureVerifier.VerifySignature(publicKeyCert, new byte[0], signatureData); + bool result = KeeShare.VerifySignatureCore(publicKeyCert, new byte[0], signatureData); Assert.False(result, "Signature verification should fail with empty data"); } @@ -106,7 +107,7 @@ public void VerifySignature_WithMalformedBase64Signature_ReturnsFalse() var publicKeyCert = Convert.ToBase64String(publicKeyBytes); byte[] testData = Encoding.UTF8.GetBytes("Test data"); byte[] signatureData = Encoding.UTF8.GetBytes("not-valid-base64!@#$"); - bool result = SignatureVerifier.VerifySignature(publicKeyCert, testData, signatureData); + bool result = KeeShare.VerifySignatureCore(publicKeyCert, testData, signatureData); Assert.False(result, "Signature verification should fail with malformed base64"); } @@ -122,121 +123,8 @@ public void VerifySignature_WithSignatureContainingWhitespace_ReturnsTrue() string signatureBase64 = Convert.ToBase64String(signature); string signatureWithWhitespace = $"\r\n {signatureBase64} \r\n"; byte[] signatureData = Encoding.UTF8.GetBytes(signatureWithWhitespace); - bool result = SignatureVerifier.VerifySignature(publicKeyCert, testData, signatureData); + bool result = KeeShare.VerifySignatureCore(publicKeyCert, testData, signatureData); Assert.True(result, "Signature verification should handle whitespace in signature"); } } - - public static class SignatureVerifier - { - public static bool VerifySignature(string trustedCertificate, byte[]? kdbxData, byte[]? signatureData) - { - try - { - if (string.IsNullOrEmpty(trustedCertificate)) - { - return false; - } - - if (signatureData == null || signatureData.Length == 0) - { - return false; - } - - if (kdbxData == null || kdbxData.Length == 0) - { - return false; - } - - string signatureText = Encoding.UTF8.GetString(signatureData).Trim(); - signatureText = signatureText.Replace("\r", "").Replace("\n", "").Replace(" ", ""); - - byte[] signatureBytes; - try - { - signatureBytes = Convert.FromBase64String(signatureText); - } - catch (Exception) - { - return false; - } - - byte[] publicKeyBytes; - try - { - if (trustedCertificate.Contains("-----BEGIN")) - { - var lines = trustedCertificate.Split('\n'); - var base64Lines = lines.Where(l => !l.Contains("BEGIN") && !l.Contains("END") && !string.IsNullOrWhiteSpace(l)); - string base64Content = string.Join("", base64Lines).Trim(); - publicKeyBytes = Convert.FromBase64String(base64Content); - } - else - { - publicKeyBytes = Convert.FromBase64String(trustedCertificate); - } - } - catch (Exception) - { - return false; - } - - RSA rsa = RSA.Create(); - try - { - try - { - rsa.ImportSubjectPublicKeyInfo(publicKeyBytes, out int bytesRead); - if (bytesRead == 0) - { - throw new CryptographicException("No bytes read from public key"); - } - } - catch (Exception) - { - try - { - rsa.ImportRSAPublicKey(publicKeyBytes, out int bytesRead); - if (bytesRead == 0) - { - throw new CryptographicException("No bytes read from public key"); - } - } - catch (Exception) - { - rsa.Dispose(); - return false; - } - } - - byte[] hash; - using (SHA256 sha256 = SHA256.Create()) - { - hash = sha256.ComputeHash(kdbxData); - } - - bool isValid = false; - try - { - isValid = rsa.VerifyHash(hash, signatureBytes, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); - } - finally - { - rsa.Dispose(); - } - - return isValid; - } - catch - { - rsa.Dispose(); - return false; - } - } - catch (Exception) - { - return false; - } - } - } } diff --git a/src/java/PluginQR/.settings/org.eclipse.buildship.core.prefs b/src/java/PluginQR/.settings/org.eclipse.buildship.core.prefs deleted file mode 100644 index 19f4d05f7..000000000 --- a/src/java/PluginQR/.settings/org.eclipse.buildship.core.prefs +++ /dev/null @@ -1,13 +0,0 @@ -arguments=--init-script /var/folders/r3/41cmfh2s0l52ndz3w971w7lc0000gn/T/db3b08fc4a9ef609cb16b96b200fa13e563f396e9bb1ed0905fdab7bc3bc513b.gradle --init-script /var/folders/r3/41cmfh2s0l52ndz3w971w7lc0000gn/T/52cde0cfcf3e28b8b7510e992210d9614505e0911af0c190bd590d7158574963.gradle -auto.sync=false -build.scans.enabled=false -connection.gradle.distribution=GRADLE_DISTRIBUTION(WRAPPER) -connection.project.dir= -eclipse.preferences.version=1 -gradle.user.home= -java.home=/Library/Java/JavaVirtualMachines/temurin-21.jdk/Contents/Home -jvm.arguments= -offline.mode=false -override.workspace.settings=true -show.console.view=true -show.executions.view=true diff --git a/src/keepass2android-app/AssemblyInfo.cs b/src/keepass2android-app/AssemblyInfo.cs index 9e2a4a88c..1cfcb13f5 100644 --- a/src/keepass2android-app/AssemblyInfo.cs +++ b/src/keepass2android-app/AssemblyInfo.cs @@ -15,7 +15,8 @@ You should have received a copy of the GNU General Public License along with Keepass2Android. If not, see . */ +using System.Runtime.CompilerServices; - +[assembly: InternalsVisibleTo("KeeShare.Tests")] diff --git a/src/keepass2android-app/KeeShare.cs b/src/keepass2android-app/KeeShare.cs index 26332d25a..7e985b14d 100644 --- a/src/keepass2android-app/KeeShare.cs +++ b/src/keepass2android-app/KeeShare.cs @@ -37,7 +37,7 @@ public static void Check(IKp2aApp app, OnOperationFinishedHandler nextHandler) new BlockingOperationStarter(app, op).Run(); } - private static bool HasKeeShareGroups(PwGroup group) + internal static bool HasKeeShareGroups(PwGroup group) { if (group.CustomData.Get("KeeShare.Active") == "true") return true; @@ -108,11 +108,11 @@ private void ProcessKeeShare(PwGroup group) if (type == "Import" || type == "Synchronize") { StatusLogger.UpdateMessage(_app.GetResourceString(UiStringKey.OpeningDatabase) + ": " + group.Name); - Import(group, path, password); + Import(group, path, password, type); } } - private void Import(PwGroup targetGroup, string path, string password) + private void Import(PwGroup targetGroup, string path, string password, string type) { IOConnectionInfo ioc = ResolvePath(path); @@ -259,7 +259,7 @@ private void Import(PwGroup targetGroup, string path, string password) kdbx.Load(kdbxStream, KdbxFormat.Default, key); // Now copy content from shareDb.RootGroup to targetGroup - SyncGroups(shareDb.RootGroup, targetGroup); + SyncGroups(shareDb.RootGroup, targetGroup, type); } finally { @@ -286,33 +286,51 @@ private void Import(PwGroup targetGroup, string path, string password) } } - private void SyncGroups(PwGroup source, PwGroup target) + /// + /// Synchronizes the target group with the source group from the shared database. + /// + /// WARNING: This is a destructive operation that will overwrite all entries and subgroups + /// in the target group with content from the source. Any local modifications to entries + /// within a KeeShare group will be lost on each sync. + /// + /// This behavior is intentional for "Import" mode (one-time import), but for "Synchronize" + /// mode, a proper merge implementation would be preferable to preserve local modifications + /// that haven't been synced back to the shared database. + /// + /// The following properties of the target group are preserved: + /// - UUID (group identity) + /// - Parent (group hierarchy) + /// - Name (local group name) + /// - Icon (local group icon) + /// - CustomData (KeeShare configuration) + /// + /// Source group from the shared database + /// Target group in the local database + /// KeeShare type: "Import" or "Synchronize" + private void SyncGroups(PwGroup source, PwGroup target, string type) { - // For minimal implementation: clear target and copy source - // But we must preserve the CustomData of the target group (KeeShare config)! - // And Name/Icon/Uuid of the target group should probably stay? - // KeeShare says: "The group in your database is synchronized with the Shared Database" + // TODO: For "Synchronize" mode, consider implementing proper merge logic using + // KeePassLib's merge functionality (e.g., PwDatabase.MergeIn) to preserve local + // modifications and handle conflicts appropriately. - // We should keep: UUID, Parent, Name, Icon (maybe?), CustomData - - // Clear entries and subgroups + // Clear entries and subgroups (destructive operation) target.Entries.Clear(); target.Groups.Clear(); - // Copy entries + // Copy entries from source foreach (var entry in source.Entries) { target.AddEntry(entry.CloneDeep(), true); } - // Copy subgroups + // Copy subgroups from source foreach (var group in source.Groups) { target.AddGroup(group.CloneDeep(), true); } - // We might want to update Name/Icon/Notes from source if they changed? - // For now, keeping it simple (only content). + // Note: We preserve Name/Icon/Notes of the target group to maintain local identity + // Only the content (entries and subgroups) is synchronized from the shared database. target.Touch(true, false); } @@ -347,7 +365,10 @@ private IOConnectionInfo ResolvePath(string path) // Many storages don't support relative paths easily without full URL manipulation // For now, return as is if not local, or try to reconstruct } - catch {} + catch (Exception ex) + { + Kp2aLog.Log("KeeShare: Error resolving relative path for " + path + ": " + ex.ToString()); + } return IOConnectionInfo.FromPath(path); } @@ -371,27 +392,54 @@ private Stream OpenStream(IOConnectionInfo ioc) /// Returns true if signature is valid and certificate is trusted, false otherwise. /// private bool VerifySignature(PwGroup group, byte[] kdbxData, byte[] signatureData) + { + // Get trusted certificate (public key) from group CustomData + string trustedCert = group.CustomData.Get("KeeShare.TrustedCertificate"); + + if (string.IsNullOrEmpty(trustedCert)) + { + Kp2aLog.Log("KeeShare: No trusted certificate configured for group " + group.Name); + return false; + } + + bool result = VerifySignatureCore(trustedCert, kdbxData, signatureData); + + if (result) + { + Kp2aLog.Log("KeeShare: Signature verified successfully for group " + group.Name); + } + else + { + Kp2aLog.Log("KeeShare: Signature verification failed for group " + group.Name); + } + + return result; + } + + /// + /// Core signature verification logic that can be used by both production code and tests. + /// Verifies a signature using the provided trusted certificate, KDBX data, and signature data. + /// + /// The trusted certificate (public key) as base64-encoded DER or PEM format + /// The KDBX file data that was signed + /// The signature data (base64-encoded) + /// True if signature is valid, false otherwise + internal static bool VerifySignatureCore(string trustedCertificate, byte[]? kdbxData, byte[]? signatureData) { try { - // Get trusted certificate (public key) from group CustomData - string trustedCert = group.CustomData.Get("KeeShare.TrustedCertificate"); - - if (string.IsNullOrEmpty(trustedCert)) + if (string.IsNullOrEmpty(trustedCertificate)) { - Kp2aLog.Log("KeeShare: No trusted certificate configured for group " + group.Name); return false; } if (signatureData == null || signatureData.Length == 0) { - Kp2aLog.Log("KeeShare: Signature file is empty"); return false; } if (kdbxData == null || kdbxData.Length == 0) { - Kp2aLog.Log("KeeShare: KDBX data is empty"); return false; } @@ -407,9 +455,8 @@ private bool VerifySignature(PwGroup group, byte[] kdbxData, byte[] signatureDat { signatureBytes = Convert.FromBase64String(signatureText); } - catch (Exception ex) + catch (Exception) { - Kp2aLog.Log("KeeShare: Failed to decode base64 signature: " + ex.Message); return false; } @@ -419,10 +466,10 @@ private bool VerifySignature(PwGroup group, byte[] kdbxData, byte[] signatureDat try { // Try to decode as base64 first - if (trustedCert.Contains("-----BEGIN")) + if (trustedCertificate.Contains("-----BEGIN")) { // PEM format - extract base64 content - var lines = trustedCert.Split('\n'); + var lines = trustedCertificate.Split('\n'); var base64Lines = lines.Where(l => !l.Contains("BEGIN") && !l.Contains("END") && !string.IsNullOrWhiteSpace(l)); string base64Content = string.Join("", base64Lines).Trim(); publicKeyBytes = Convert.FromBase64String(base64Content); @@ -430,79 +477,68 @@ private bool VerifySignature(PwGroup group, byte[] kdbxData, byte[] signatureDat else { // Assume it's already base64-encoded DER - publicKeyBytes = Convert.FromBase64String(trustedCert); + publicKeyBytes = Convert.FromBase64String(trustedCertificate); } } - catch (Exception ex) + catch (Exception) { - Kp2aLog.Log("KeeShare: Failed to parse trusted certificate: " + ex.Message); return false; } // Create RSA object from public key - RSA rsa = RSA.Create(); - try + // Use using block to ensure deterministic disposal even if exceptions occur + bool isValid = false; + bool importFailed = false; + using (RSA rsa = RSA.Create()) { // Try importing as SubjectPublicKeyInfo (standard format) - rsa.ImportSubjectPublicKeyInfo(publicKeyBytes, out int bytesRead); - if (bytesRead == 0) - { - throw new CryptographicException("No bytes read from public key"); - } - } - catch (Exception ex) - { - Kp2aLog.Log("KeeShare: Failed to import public key as SubjectPublicKeyInfo: " + ex.Message); + bool importSucceeded = false; try { - // Try importing as RSAPublicKey (PKCS#1 format) - rsa.ImportRSAPublicKey(publicKeyBytes, out int bytesRead); + rsa.ImportSubjectPublicKeyInfo(publicKeyBytes, out int bytesRead); if (bytesRead == 0) { throw new CryptographicException("No bytes read from public key"); } + importSucceeded = true; } - catch (Exception ex2) + catch (Exception) { - Kp2aLog.Log("KeeShare: Failed to import public key as RSAPublicKey: " + ex2.Message); - rsa.Dispose(); - return false; + try + { + // Try importing as RSAPublicKey (PKCS#1 format) + rsa.ImportRSAPublicKey(publicKeyBytes, out int bytesRead); + if (bytesRead == 0) + { + throw new CryptographicException("No bytes read from public key"); + } + importSucceeded = true; + } + catch (Exception) + { + importFailed = true; + } } - } - - // Compute hash of kdbx data - byte[] hash; - using (SHA256 sha256 = SHA256.Create()) - { - hash = sha256.ComputeHash(kdbxData); - } - // Verify signature - bool isValid = false; - try - { - isValid = rsa.VerifyHash(hash, signatureBytes, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); - - if (isValid) - { - Kp2aLog.Log("KeeShare: Signature verified successfully for group " + group.Name); - } - else + if (!importFailed && importSucceeded) { - Kp2aLog.Log("KeeShare: Signature verification failed for group " + group.Name); + // Compute hash of kdbx data + byte[] hash; + using (SHA256 sha256 = SHA256.Create()) + { + hash = sha256.ComputeHash(kdbxData); + } + + // Verify signature + isValid = rsa.VerifyHash(hash, signatureBytes, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); } } - finally - { - rsa.Dispose(); - } - return isValid; + // Return false if import failed, otherwise return verification result + return !importFailed && isValid; } - catch (Exception ex) + catch (Exception) { - Kp2aLog.Log("KeeShare: Error verifying signature: " + ex.Message); - Kp2aLog.Log("KeeShare: Stack trace: " + ex.StackTrace); return false; } } From 58e4b429762038aa8a7dec1b31c6affbd01434ec Mon Sep 17 00:00:00 2001 From: plyght Date: Sun, 30 Nov 2025 19:59:15 -0500 Subject: [PATCH 04/31] Add signature verification enhancements in KeeShare tests and core logic --- .../SignatureVerificationTests.cs | 108 +++++++++++++++--- src/keepass2android-app/KeeShare.cs | 50 +++++++- 2 files changed, 139 insertions(+), 19 deletions(-) diff --git a/src/KeeShare.Tests/SignatureVerificationTests.cs b/src/KeeShare.Tests/SignatureVerificationTests.cs index 8add3b14a..b7bcb3b9f 100644 --- a/src/KeeShare.Tests/SignatureVerificationTests.cs +++ b/src/KeeShare.Tests/SignatureVerificationTests.cs @@ -6,6 +6,23 @@ namespace KeeShare.Tests { public class SignatureVerificationTests { + /// + /// Helper to convert bytes to hex string (KeeShare format) + /// + private static string BytesToHex(byte[] bytes) + { + return BitConverter.ToString(bytes).Replace("-", "").ToLowerInvariant(); + } + + /// + /// Helper to format signature in KeeShare format: "rsa|" + /// + private static byte[] FormatKeeShareSignature(byte[] signature) + { + string hex = BytesToHex(signature); + return Encoding.UTF8.GetBytes($"rsa|{hex}"); + } + [Fact] public void VerifySignature_WithValidSignature_ReturnsTrue() { @@ -15,12 +32,27 @@ public void VerifySignature_WithValidSignature_ReturnsTrue() byte[] testData = Encoding.UTF8.GetBytes("Test KDBX data content"); byte[] hash = SHA256.HashData(testData); byte[] signature = rsa.SignHash(hash, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); - string signatureBase64 = Convert.ToBase64String(signature); - byte[] signatureData = Encoding.UTF8.GetBytes(signatureBase64); + byte[] signatureData = FormatKeeShareSignature(signature); bool result = KeeShare.VerifySignatureCore(publicKeyCert, testData, signatureData); Assert.True(result, "Signature verification should succeed with valid signature"); } + [Fact] + public void VerifySignature_WithValidSignatureWithoutPrefix_ReturnsTrue() + { + using var rsa = RSA.Create(2048); + var publicKeyBytes = rsa.ExportSubjectPublicKeyInfo(); + var publicKeyCert = Convert.ToBase64String(publicKeyBytes); + byte[] testData = Encoding.UTF8.GetBytes("Test KDBX data content"); + byte[] hash = SHA256.HashData(testData); + byte[] signature = rsa.SignHash(hash, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + // Hex without "rsa|" prefix should also work + string signatureHex = BytesToHex(signature); + byte[] signatureData = Encoding.UTF8.GetBytes(signatureHex); + bool result = KeeShare.VerifySignatureCore(publicKeyCert, testData, signatureData); + Assert.True(result, "Signature verification should succeed with hex signature without prefix"); + } + [Fact] public void VerifySignature_WithInvalidSignature_ReturnsFalse() { @@ -30,8 +62,7 @@ public void VerifySignature_WithInvalidSignature_ReturnsFalse() byte[] testData = Encoding.UTF8.GetBytes("Test KDBX data content"); byte[] invalidSignature = new byte[256]; new Random().NextBytes(invalidSignature); - string signatureBase64 = Convert.ToBase64String(invalidSignature); - byte[] signatureData = Encoding.UTF8.GetBytes(signatureBase64); + byte[] signatureData = FormatKeeShareSignature(invalidSignature); bool result = KeeShare.VerifySignatureCore(publicKeyCert, testData, signatureData); Assert.False(result, "Signature verification should fail with invalid signature"); } @@ -45,8 +76,7 @@ public void VerifySignature_WithTamperedData_ReturnsFalse() byte[] originalData = Encoding.UTF8.GetBytes("Original KDBX data"); byte[] hash = SHA256.HashData(originalData); byte[] signature = rsa.SignHash(hash, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); - string signatureBase64 = Convert.ToBase64String(signature); - byte[] signatureData = Encoding.UTF8.GetBytes(signatureBase64); + byte[] signatureData = FormatKeeShareSignature(signature); byte[] tamperedData = Encoding.UTF8.GetBytes("Tampered KDBX data"); bool result = KeeShare.VerifySignatureCore(publicKeyCert, tamperedData, signatureData); Assert.False(result, "Signature verification should fail when data is tampered"); @@ -62,8 +92,7 @@ public void VerifySignature_WithPemFormattedCertificate_ReturnsTrue() byte[] testData = Encoding.UTF8.GetBytes("Test KDBX data content"); byte[] hash = SHA256.HashData(testData); byte[] signature = rsa.SignHash(hash, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); - string signatureBase64 = Convert.ToBase64String(signature); - byte[] signatureData = Encoding.UTF8.GetBytes(signatureBase64); + byte[] signatureData = FormatKeeShareSignature(signature); bool result = KeeShare.VerifySignatureCore(publicKeyCertPem, testData, signatureData); Assert.True(result, "Signature verification should work with PEM formatted certificate"); } @@ -72,7 +101,7 @@ public void VerifySignature_WithPemFormattedCertificate_ReturnsTrue() public void VerifySignature_WithEmptyCertificate_ReturnsFalse() { byte[] testData = Encoding.UTF8.GetBytes("Test data"); - byte[] signatureData = Encoding.UTF8.GetBytes("fake signature"); + byte[] signatureData = Encoding.UTF8.GetBytes("rsa|abcd1234"); bool result = KeeShare.VerifySignatureCore("", testData, signatureData); Assert.False(result, "Signature verification should fail with empty certificate"); } @@ -83,7 +112,7 @@ public void VerifySignature_WithNullData_ReturnsFalse() using var rsa = RSA.Create(2048); var publicKeyBytes = rsa.ExportSubjectPublicKeyInfo(); var publicKeyCert = Convert.ToBase64String(publicKeyBytes); - byte[] signatureData = Encoding.UTF8.GetBytes("signature"); + byte[] signatureData = Encoding.UTF8.GetBytes("rsa|abcd1234"); bool result = KeeShare.VerifySignatureCore(publicKeyCert, null, signatureData); Assert.False(result, "Signature verification should fail with null data"); } @@ -94,21 +123,35 @@ public void VerifySignature_WithEmptyData_ReturnsFalse() using var rsa = RSA.Create(2048); var publicKeyBytes = rsa.ExportSubjectPublicKeyInfo(); var publicKeyCert = Convert.ToBase64String(publicKeyBytes); - byte[] signatureData = Encoding.UTF8.GetBytes("signature"); + byte[] signatureData = Encoding.UTF8.GetBytes("rsa|abcd1234"); bool result = KeeShare.VerifySignatureCore(publicKeyCert, new byte[0], signatureData); Assert.False(result, "Signature verification should fail with empty data"); } [Fact] - public void VerifySignature_WithMalformedBase64Signature_ReturnsFalse() + public void VerifySignature_WithMalformedHexSignature_ReturnsFalse() + { + using var rsa = RSA.Create(2048); + var publicKeyBytes = rsa.ExportSubjectPublicKeyInfo(); + var publicKeyCert = Convert.ToBase64String(publicKeyBytes); + byte[] testData = Encoding.UTF8.GetBytes("Test data"); + // Invalid hex characters + byte[] signatureData = Encoding.UTF8.GetBytes("rsa|not-valid-hex!@#$GHIJ"); + bool result = KeeShare.VerifySignatureCore(publicKeyCert, testData, signatureData); + Assert.False(result, "Signature verification should fail with malformed hex"); + } + + [Fact] + public void VerifySignature_WithOddLengthHex_ReturnsFalse() { using var rsa = RSA.Create(2048); var publicKeyBytes = rsa.ExportSubjectPublicKeyInfo(); var publicKeyCert = Convert.ToBase64String(publicKeyBytes); byte[] testData = Encoding.UTF8.GetBytes("Test data"); - byte[] signatureData = Encoding.UTF8.GetBytes("not-valid-base64!@#$"); + // Odd-length hex string (invalid) + byte[] signatureData = Encoding.UTF8.GetBytes("rsa|abc"); bool result = KeeShare.VerifySignatureCore(publicKeyCert, testData, signatureData); - Assert.False(result, "Signature verification should fail with malformed base64"); + Assert.False(result, "Signature verification should fail with odd-length hex"); } [Fact] @@ -120,11 +163,44 @@ public void VerifySignature_WithSignatureContainingWhitespace_ReturnsTrue() byte[] testData = Encoding.UTF8.GetBytes("Test KDBX data content"); byte[] hash = SHA256.HashData(testData); byte[] signature = rsa.SignHash(hash, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); - string signatureBase64 = Convert.ToBase64String(signature); - string signatureWithWhitespace = $"\r\n {signatureBase64} \r\n"; + string signatureHex = BytesToHex(signature); + // Add whitespace around the signature + string signatureWithWhitespace = $"\r\n rsa|{signatureHex} \r\n"; byte[] signatureData = Encoding.UTF8.GetBytes(signatureWithWhitespace); bool result = KeeShare.VerifySignatureCore(publicKeyCert, testData, signatureData); Assert.True(result, "Signature verification should handle whitespace in signature"); } + + [Fact] + public void VerifySignature_WithUppercaseHex_ReturnsTrue() + { + using var rsa = RSA.Create(2048); + var publicKeyBytes = rsa.ExportSubjectPublicKeyInfo(); + var publicKeyCert = Convert.ToBase64String(publicKeyBytes); + byte[] testData = Encoding.UTF8.GetBytes("Test KDBX data content"); + byte[] hash = SHA256.HashData(testData); + byte[] signature = rsa.SignHash(hash, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + // Use uppercase hex + string signatureHex = BitConverter.ToString(signature).Replace("-", "").ToUpperInvariant(); + byte[] signatureData = Encoding.UTF8.GetBytes($"rsa|{signatureHex}"); + bool result = KeeShare.VerifySignatureCore(publicKeyCert, testData, signatureData); + Assert.True(result, "Signature verification should handle uppercase hex"); + } + + [Fact] + public void VerifySignature_WithUppercaseRsaPrefix_ReturnsTrue() + { + using var rsa = RSA.Create(2048); + var publicKeyBytes = rsa.ExportSubjectPublicKeyInfo(); + var publicKeyCert = Convert.ToBase64String(publicKeyBytes); + byte[] testData = Encoding.UTF8.GetBytes("Test KDBX data content"); + byte[] hash = SHA256.HashData(testData); + byte[] signature = rsa.SignHash(hash, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + string signatureHex = BytesToHex(signature); + // Use uppercase "RSA|" prefix + byte[] signatureData = Encoding.UTF8.GetBytes($"RSA|{signatureHex}"); + bool result = KeeShare.VerifySignatureCore(publicKeyCert, testData, signatureData); + Assert.True(result, "Signature verification should handle uppercase RSA prefix"); + } } } diff --git a/src/keepass2android-app/KeeShare.cs b/src/keepass2android-app/KeeShare.cs index 7e985b14d..cdc06e0ed 100644 --- a/src/keepass2android-app/KeeShare.cs +++ b/src/keepass2android-app/KeeShare.cs @@ -422,7 +422,7 @@ private bool VerifySignature(PwGroup group, byte[] kdbxData, byte[] signatureDat /// /// The trusted certificate (public key) as base64-encoded DER or PEM format /// The KDBX file data that was signed - /// The signature data (base64-encoded) + /// The signature data in KeeShare format ("rsa|<hex>") /// True if signature is valid, false otherwise internal static bool VerifySignatureCore(string trustedCertificate, byte[]? kdbxData, byte[]? signatureData) { @@ -443,22 +443,35 @@ internal static bool VerifySignatureCore(string trustedCertificate, byte[]? kdbx return false; } - // KeeShare signature format: base64-encoded RSA signature + // KeeShare signature format: "rsa|" where hex is the RSA signature // The signature is computed over the kdbx file data using SHA-256 string signatureText = Encoding.UTF8.GetString(signatureData).Trim(); // Remove any whitespace/newlines signatureText = signatureText.Replace("\r", "").Replace("\n", "").Replace(" ", ""); + // Strip "rsa|" prefix if present + const string rsaPrefix = "rsa|"; + if (signatureText.StartsWith(rsaPrefix, StringComparison.OrdinalIgnoreCase)) + { + signatureText = signatureText.Substring(rsaPrefix.Length); + } + + // Hex-decode the signature byte[] signatureBytes; try { - signatureBytes = Convert.FromBase64String(signatureText); + signatureBytes = HexStringToBytes(signatureText); } catch (Exception) { return false; } + + if (signatureBytes == null || signatureBytes.Length == 0) + { + return false; + } // Parse the trusted certificate (public key) // Format: PEM-encoded public key or base64-encoded DER @@ -542,5 +555,36 @@ internal static bool VerifySignatureCore(string trustedCertificate, byte[]? kdbx return false; } } + + /// + /// Converts a hexadecimal string to a byte array. + /// + /// The hexadecimal string (case-insensitive, no separators) + /// The decoded byte array, or null if the input is invalid + private static byte[]? HexStringToBytes(string hex) + { + if (string.IsNullOrEmpty(hex)) + { + return null; + } + + // Hex string must have even length + if (hex.Length % 2 != 0) + { + return null; + } + + byte[] bytes = new byte[hex.Length / 2]; + for (int i = 0; i < bytes.Length; i++) + { + string byteStr = hex.Substring(i * 2, 2); + if (!byte.TryParse(byteStr, System.Globalization.NumberStyles.HexNumber, null, out bytes[i])) + { + return null; + } + } + + return bytes; + } } } From d2d17ec1317c1c23e3a8bd526c0ed213f28d7d74 Mon Sep 17 00:00:00 2001 From: plyght Date: Sun, 30 Nov 2025 20:12:57 -0500 Subject: [PATCH 05/31] Enhance KeeShare synchronization logic by implementing non-destructive merge for "Synchronize" mode and clarifying behavior for "Import" mode. --- src/keepass2android-app/KeeShare.cs | 80 +++++++++++++++++++++++------ 1 file changed, 65 insertions(+), 15 deletions(-) diff --git a/src/keepass2android-app/KeeShare.cs b/src/keepass2android-app/KeeShare.cs index cdc06e0ed..926a467e0 100644 --- a/src/keepass2android-app/KeeShare.cs +++ b/src/keepass2android-app/KeeShare.cs @@ -289,15 +289,14 @@ private void Import(PwGroup targetGroup, string path, string password, string ty /// /// Synchronizes the target group with the source group from the shared database. /// - /// WARNING: This is a destructive operation that will overwrite all entries and subgroups - /// in the target group with content from the source. Any local modifications to entries - /// within a KeeShare group will be lost on each sync. + /// For "Import" mode: Performs a destructive replace - clears target and copies all + /// content from source. Any local modifications will be lost. /// - /// This behavior is intentional for "Import" mode (one-time import), but for "Synchronize" - /// mode, a proper merge implementation would be preferable to preserve local modifications - /// that haven't been synced back to the shared database. + /// For "Synchronize" mode: Performs a non-destructive merge - adds new entries/groups + /// from source, and updates existing entries/groups if the source version is newer + /// (based on LastModificationTime). Local entries not in source are preserved. /// - /// The following properties of the target group are preserved: + /// The following properties of the target group are always preserved: /// - UUID (group identity) /// - Parent (group hierarchy) /// - Name (local group name) @@ -309,11 +308,26 @@ private void Import(PwGroup targetGroup, string path, string password, string ty /// KeeShare type: "Import" or "Synchronize" private void SyncGroups(PwGroup source, PwGroup target, string type) { - // TODO: For "Synchronize" mode, consider implementing proper merge logic using - // KeePassLib's merge functionality (e.g., PwDatabase.MergeIn) to preserve local - // modifications and handle conflicts appropriately. + if (type == "Synchronize") + { + // Non-destructive merge: add new items, update existing if newer + MergeGroupContents(source, target); + } + else + { + // Import mode: destructive replace + ImportGroupContents(source, target); + } - // Clear entries and subgroups (destructive operation) + target.Touch(true, false); + } + + /// + /// Performs a destructive import: clears target and copies all content from source. + /// + private void ImportGroupContents(PwGroup source, PwGroup target) + { + // Clear entries and subgroups target.Entries.Clear(); target.Groups.Clear(); @@ -328,11 +342,47 @@ private void SyncGroups(PwGroup source, PwGroup target, string type) { target.AddGroup(group.CloneDeep(), true); } + } + + /// + /// Performs a non-destructive merge: adds new entries/groups from source, + /// updates existing if source is newer. Local items not in source are preserved. + /// + private void MergeGroupContents(PwGroup source, PwGroup target) + { + // Merge entries + foreach (var sourceEntry in source.Entries) + { + var targetEntry = target.FindEntry(sourceEntry.Uuid, false); + if (targetEntry == null) + { + // Entry doesn't exist in target - add it + target.AddEntry(sourceEntry.CloneDeep(), true); + } + else + { + // Entry exists - update if source is newer + // AssignProperties with bOnlyIfNewer=true will only update if source.LastMod > target.LastMod + targetEntry.AssignProperties(sourceEntry, true, false, false); + } + } - // Note: We preserve Name/Icon/Notes of the target group to maintain local identity - // Only the content (entries and subgroups) is synchronized from the shared database. - - target.Touch(true, false); + // Merge subgroups recursively + foreach (var sourceGroup in source.Groups) + { + var targetGroup = target.FindGroup(sourceGroup.Uuid, false); + if (targetGroup == null) + { + // Group doesn't exist in target - add it + target.AddGroup(sourceGroup.CloneDeep(), true); + } + else + { + // Group exists - update properties if source is newer, then merge contents + targetGroup.AssignProperties(sourceGroup, true, false); + MergeGroupContents(sourceGroup, targetGroup); + } + } } private IOConnectionInfo ResolvePath(string path) From 30172792a55ac5024108d57b4cc972c164317d9c Mon Sep 17 00:00:00 2001 From: plyght Date: Sun, 30 Nov 2025 20:21:33 -0500 Subject: [PATCH 06/31] Refactor KeeShare to ensure proper resource management by closing the shared database after use, preventing potential memory leaks. --- src/keepass2android-app/KeeShare.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/keepass2android-app/KeeShare.cs b/src/keepass2android-app/KeeShare.cs index 926a467e0..481d35b9a 100644 --- a/src/keepass2android-app/KeeShare.cs +++ b/src/keepass2android-app/KeeShare.cs @@ -241,9 +241,10 @@ private void Import(PwGroup targetGroup, string path, string password, string ty } // Load the KDBX + PwDatabase? shareDb = null; try { - PwDatabase shareDb = new PwDatabase(); + shareDb = new PwDatabase(); CompositeKey key = new CompositeKey(); if (!string.IsNullOrEmpty(password)) { @@ -263,6 +264,9 @@ private void Import(PwGroup targetGroup, string path, string password, string ty } finally { + // Close/dispose the shared database to release resources + shareDb?.Close(); + // Dispose kdbxMem if it was created (for ZIP files) if (kdbxMem != null && kdbxMem != ms) { From 92085a03b8f756b6594b03ec4320272d7f635cd6 Mon Sep 17 00:00:00 2001 From: plyght Date: Mon, 1 Dec 2025 18:40:43 -0500 Subject: [PATCH 07/31] Implement background synchronization for KeeShare groups and enhance error handling during path resolution --- src/keepass2android-app/KeeShare.cs | 306 +++++++++++----------------- src/keepass2android-app/SyncUtil.cs | 27 ++- 2 files changed, 137 insertions(+), 196 deletions(-) diff --git a/src/keepass2android-app/KeeShare.cs b/src/keepass2android-app/KeeShare.cs index 481d35b9a..4a47c7afa 100644 --- a/src/keepass2android-app/KeeShare.cs +++ b/src/keepass2android-app/KeeShare.cs @@ -18,6 +18,13 @@ namespace keepass2android { public class KeeShare { + /// + /// Checks for KeeShare groups and processes them. This is called after database load + /// and can also be triggered by "Synchronize Database" action. + /// Uses non-blocking background operations for fast database loading (similar to database sync in 1.15+). + /// + /// The application instance + /// Handler to call when operation completes public static void Check(IKp2aApp app, OnOperationFinishedHandler nextHandler) { var db = app.CurrentDb; @@ -34,7 +41,16 @@ public static void Check(IKp2aApp app, OnOperationFinishedHandler nextHandler) } var op = new KeeShareCheckOperation(app, nextHandler); - new BlockingOperationStarter(app, op).Run(); + OperationRunner.Instance.Run(app, op); + } + + /// + /// Triggers KeeShare synchronization in the background. + /// Called when user selects "Synchronize Database". + /// + public static void SyncInBackground(IKp2aApp app, OnOperationFinishedHandler onFinished) + { + Check(app, onFinished); } internal static bool HasKeeShareGroups(PwGroup group) @@ -85,12 +101,9 @@ private void ProcessGroup(PwGroup group) catch (Exception ex) { Kp2aLog.Log("Error processing KeeShare for group " + group.Name + ": " + ex.ToString()); - // Continue with other groups even if one fails } } - // Process subgroups AFTER processing KeeShare, so we recurse into the newly imported groups - // We must iterate over a copy of the groups list to avoid issues if ProcessGroup modifies the collection foreach (var sub in group.Groups.ToList()) { ProcessGroup(sub); @@ -121,10 +134,6 @@ private void Import(PwGroup targetGroup, string path, string password, string ty using (Stream s = OpenStream(ioc)) { if (s == null) return; - - // Check if it's a Zip (KeeShare signed file) - // We can't seek on some streams, so we might need to copy to memory if we need random access - // But KdbxFile loads from stream. ZipArchive needs seekable stream usually. MemoryStream ms = new MemoryStream(); try @@ -136,7 +145,6 @@ private void Import(PwGroup targetGroup, string path, string password, string ty MemoryStream kdbxMem = null; bool isZip = false; - // Check for PK header (Zip) if (ms.Length > 4) { byte[] header = new byte[4]; @@ -154,7 +162,6 @@ private void Import(PwGroup targetGroup, string path, string password, string ty { using (ZipArchive archive = new ZipArchive(ms, ZipArchiveMode.Read, true)) { - // Find .kdbx file var kdbxEntry = archive.Entries.FirstOrDefault(e => e.Name.EndsWith(".kdbx", StringComparison.OrdinalIgnoreCase)); if (kdbxEntry == null) { @@ -162,31 +169,25 @@ private void Import(PwGroup targetGroup, string path, string password, string ty return; } - // Extract to a new memory stream because KdbxFile might close it or we need a clean stream kdbxMem = new MemoryStream(); using (var es = kdbxEntry.Open()) { es.CopyTo(kdbxMem); } - // Store kdbx data for signature verification byte[] kdbxData = kdbxMem.ToArray(); - // Check for signature file (.sig) and verify if certificate is trusted var sigEntry = archive.Entries.FirstOrDefault(e => e.Name.EndsWith(".sig", StringComparison.OrdinalIgnoreCase) || e.Name.EndsWith(".signature", StringComparison.OrdinalIgnoreCase)); - // Check if a trusted certificate is configured string trustedCert = targetGroup.CustomData.Get("KeeShare.TrustedCertificate"); bool hasTrustedCert = !string.IsNullOrEmpty(trustedCert); if (sigEntry != null) { - // Only verify signature if a trusted certificate is configured if (hasTrustedCert) { - // Extract signature for verification byte[] signatureData; using (var sigStream = sigEntry.Open()) using (var sigMem = new MemoryStream()) @@ -195,38 +196,34 @@ private void Import(PwGroup targetGroup, string path, string password, string ty signatureData = sigMem.ToArray(); } - // Verify signature - only import if certificate is trusted and signature is valid if (!VerifySignature(targetGroup, kdbxData, signatureData)) { - Kp2aLog.Log("KeeShare: Signature verification failed or certificate not trusted for " + path + ". Skipping import."); + Kp2aLog.Log("KeeShare: Signature verification failed or certificate not trusted for group " + targetGroup.Name + ". Skipping import."); return; } else { - Kp2aLog.Log("KeeShare: Signature verified successfully for " + path); + Kp2aLog.Log("KeeShare: Signature verified successfully for group " + targetGroup.Name); } } else { - // Signature file exists but no certificate configured - skip verification for backward compatibility - Kp2aLog.Log("KeeShare: Signature file found but no trusted certificate configured for " + path + ". Continuing without signature verification (backward compatibility)."); + Kp2aLog.Log("KeeShare: Signature file found but no trusted certificate configured for group " + targetGroup.Name + ". Continuing without signature verification (backward compatibility)."); } } else { - // If a trusted certificate is configured, we MUST have a signature file for security if (hasTrustedCert) { - Kp2aLog.Log("KeeShare: Trusted certificate is configured but no signature file found in ZIP archive for " + path + ". Skipping import for security."); + Kp2aLog.Log("KeeShare: Trusted certificate is configured but no signature file found in ZIP archive for group " + targetGroup.Name + ". Skipping import for security."); return; } else { - Kp2aLog.Log("KeeShare: No signature file found in ZIP archive for " + path + ". Continuing without signature verification (backward compatibility)."); + Kp2aLog.Log("KeeShare: No signature file found in ZIP archive for group " + targetGroup.Name + ". Continuing without signature verification (backward compatibility)."); } } - // Reset stream position for KDBX loading kdbxMem.Position = 0; kdbxStream = kdbxMem; } @@ -234,14 +231,12 @@ private void Import(PwGroup targetGroup, string path, string password, string ty catch (Exception ex) { Kp2aLog.Log("Failed to treat file as zip: " + ex.Message); - ms.Position = 0; // Rewind and try as KDBX directly + ms.Position = 0; kdbxStream = ms; - // kdbxMem will be disposed in finally block if it was created } } - // Load the KDBX - PwDatabase? shareDb = null; + PwDatabase shareDb = null; try { shareDb = new PwDatabase(); @@ -250,24 +245,18 @@ private void Import(PwGroup targetGroup, string path, string password, string ty { key.AddUserKey(new KcpPassword(password)); } - // If password is empty, KcpPassword("") is added? or just empty composite key? - // KeeShare without password implies empty password or no master key? - // Usually shares have passwords. If empty, try empty password. if (key.UserKeys.Count() == 0) key.AddUserKey(new KcpPassword("")); KdbxFile kdbx = new KdbxFile(shareDb); kdbx.Load(kdbxStream, KdbxFormat.Default, key); - // Now copy content from shareDb.RootGroup to targetGroup - SyncGroups(shareDb.RootGroup, targetGroup, type); + SyncGroups(shareDb, targetGroup, type); } finally { - // Close/dispose the shared database to release resources shareDb?.Close(); - // Dispose kdbxMem if it was created (for ZIP files) if (kdbxMem != null && kdbxMem != ms) { kdbxMem.Dispose(); @@ -276,152 +265,146 @@ private void Import(PwGroup targetGroup, string path, string password, string ty } finally { - // Dispose ms if it's not being used as kdbxStream (i.e., if kdbxMem was used instead) - // Note: If ms is used as kdbxStream, KdbxFile.Load will handle it, but we should still dispose - // Actually, ms is always used either directly or indirectly, so we dispose it here ms.Dispose(); } } } catch (Exception ex) { - Kp2aLog.Log("KeeShare import failed for " + path + ": " + ex.Message); - // Don't fail the whole operation, just log + Kp2aLog.Log("KeeShare import failed for group " + targetGroup.Name + ": " + ex.Message); } } /// - /// Synchronizes the target group with the source group from the shared database. + /// Synchronizes the target group with the source database using PwDatabase.MergeIn. /// - /// For "Import" mode: Performs a destructive replace - clears target and copies all - /// content from source. Any local modifications will be lost. + /// For "Import" mode: Performs a destructive replace - clears target group first, + /// then merges with OverwriteExisting. Any local modifications will be lost. /// - /// For "Synchronize" mode: Performs a non-destructive merge - adds new entries/groups - /// from source, and updates existing entries/groups if the source version is newer - /// (based on LastModificationTime). Local entries not in source are preserved. + /// For "Synchronize" mode: Uses MergeIn with Synchronize method - adds new entries/groups + /// from source, updates existing if source is newer. Handles entry history, deletions, + /// and relocations properly. /// - /// The following properties of the target group are always preserved: - /// - UUID (group identity) - /// - Parent (group hierarchy) - /// - Name (local group name) - /// - Icon (local group icon) - /// - CustomData (KeeShare configuration) + /// Implementation note: MergeIn uses reference comparison for the source root group + /// (pgSourceParent == pdSource.m_pgRootGroup), so we can't just swap UUIDs. Instead, + /// we restructure the source database by creating a wrapper group with the target + /// group's UUID and moving all content into it. This ensures MergeIn's FindGroup() + /// call will find the correct local container. /// - /// Source group from the shared database - /// Target group in the local database - /// KeeShare type: "Import" or "Synchronize" - private void SyncGroups(PwGroup source, PwGroup target, string type) + private void SyncGroups(PwDatabase shareDb, PwGroup targetGroup, string type) { - if (type == "Synchronize") + PwDatabase mainDb = _app.CurrentDb.KpDatabase; + + // Save target group's CustomData before MergeIn - MergeIn will overwrite group + // properties with the wrapper group's (empty) CustomData, losing KeeShare config. + var savedCustomData = new Dictionary(); + foreach (var kvp in targetGroup.CustomData) { - // Non-destructive merge: add new items, update existing if newer - MergeGroupContents(source, target); + savedCustomData[kvp.Key] = kvp.Value; } - else + + // Create a wrapper group with the target group's UUID. + // MergeIn uses FindGroup(parentUuid) for non-root parents, so this ensures + // content is placed in the target KeeShare group, not the database root. + PwGroup wrapperGroup = new PwGroup(false, false); + wrapperGroup.Uuid = targetGroup.Uuid; + wrapperGroup.Name = targetGroup.Name; + + // Copy all important properties from target group to wrapper group. + // When MergeIn processes the wrapper group, it will call AssignProperties + // which copies properties from wrapper to target. By copying target's properties + // to wrapper first, we ensure MergeIn preserves the target group's properties + // (Icon, IconUuid, Notes, Tags, etc.) instead of overwriting them with defaults. + wrapperGroup.Notes = targetGroup.Notes; + wrapperGroup.IconId = targetGroup.IconId; + wrapperGroup.CustomIconUuid = targetGroup.CustomIconUuid; + wrapperGroup.DefaultAutoTypeSequence = targetGroup.DefaultAutoTypeSequence; + wrapperGroup.EnableAutoType = targetGroup.EnableAutoType; + wrapperGroup.EnableSearching = targetGroup.EnableSearching; + wrapperGroup.Expires = targetGroup.Expires; + wrapperGroup.ExpiryTime = targetGroup.ExpiryTime; + wrapperGroup.LastTopVisibleEntry = targetGroup.LastTopVisibleEntry; + foreach (string tag in targetGroup.Tags) { - // Import mode: destructive replace - ImportGroupContents(source, target); + wrapperGroup.Tags.Add(tag); } - target.Touch(true, false); - } - - /// - /// Performs a destructive import: clears target and copies all content from source. - /// - private void ImportGroupContents(PwGroup source, PwGroup target) - { - // Clear entries and subgroups - target.Entries.Clear(); - target.Groups.Clear(); + // Move all entries from source root to wrapper group. + // Use bTakeOwnership=true so entry.ParentGroup points to wrapperGroup, + // otherwise MergeIn's check (pgSourceParent == pdSource.m_pgRootGroup) will + // match and place entries in the database root instead of targetGroup. + while (shareDb.RootGroup.Entries.UCount > 0) + { + PwEntry entry = shareDb.RootGroup.Entries.GetAt(0); + shareDb.RootGroup.Entries.RemoveAt(0); + wrapperGroup.AddEntry(entry, true); + } - // Copy entries from source - foreach (var entry in source.Entries) + // Move all subgroups from source root to wrapper group (bTakeOwnership=true) + while (shareDb.RootGroup.Groups.UCount > 0) { - target.AddEntry(entry.CloneDeep(), true); + PwGroup group = shareDb.RootGroup.Groups.GetAt(0); + shareDb.RootGroup.Groups.RemoveAt(0); + wrapperGroup.AddGroup(group, true); } - // Copy subgroups from source - foreach (var group in source.Groups) + // Add wrapper group as child of source root (bTakeOwnership=true) + shareDb.RootGroup.AddGroup(wrapperGroup, true); + + if (type == "Import") { - target.AddGroup(group.CloneDeep(), true); + // For Import mode: clear target first, then merge with OverwriteExisting + ClearGroupContents(targetGroup); + mainDb.MergeIn(shareDb, PwMergeMethod.OverwriteExisting); } - } - - /// - /// Performs a non-destructive merge: adds new entries/groups from source, - /// updates existing if source is newer. Local items not in source are preserved. - /// - private void MergeGroupContents(PwGroup source, PwGroup target) - { - // Merge entries - foreach (var sourceEntry in source.Entries) + else { - var targetEntry = target.FindEntry(sourceEntry.Uuid, false); - if (targetEntry == null) - { - // Entry doesn't exist in target - add it - target.AddEntry(sourceEntry.CloneDeep(), true); - } - else - { - // Entry exists - update if source is newer - // AssignProperties with bOnlyIfNewer=true will only update if source.LastMod > target.LastMod - targetEntry.AssignProperties(sourceEntry, true, false, false); - } + // For Synchronize mode: use MergeIn with Synchronize + // This handles: update if newer, add new items, handle history, handle deletions + mainDb.MergeIn(shareDb, PwMergeMethod.Synchronize); } - // Merge subgroups recursively - foreach (var sourceGroup in source.Groups) + // Restore target group's CustomData (KeeShare configuration) + // MergeIn would have overwritten it with the wrapper's empty CustomData + foreach (var kvp in savedCustomData) { - var targetGroup = target.FindGroup(sourceGroup.Uuid, false); - if (targetGroup == null) - { - // Group doesn't exist in target - add it - target.AddGroup(sourceGroup.CloneDeep(), true); - } - else - { - // Group exists - update properties if source is newer, then merge contents - targetGroup.AssignProperties(sourceGroup, true, false); - MergeGroupContents(sourceGroup, targetGroup); - } + targetGroup.CustomData.Set(kvp.Key, kvp.Value); } + + targetGroup.Touch(true, false); + } + + /// + /// Clears all entries and subgroups from a group (for Import mode). + /// + private void ClearGroupContents(PwGroup group) + { + group.Entries.Clear(); + group.Groups.Clear(); } private IOConnectionInfo ResolvePath(string path) { - // Check if absolute if (path.Contains("://") || path.StartsWith("/")) { return IOConnectionInfo.FromPath(path); } - // Try relative to current DB try { var currentIoc = _app.CurrentDb.Ioc; var storage = _app.GetFileStorage(currentIoc); - // This is a bit hacky as GetParentPath is not always supported or returns something valid - // But let's try. - - // If it's a local file, we can use Path.Combine if (currentIoc.IsLocalFile()) { string dir = Path.GetDirectoryName(currentIoc.Path); string fullPath = Path.Combine(dir, path); return IOConnectionInfo.FromPath(fullPath); } - - // For other storages, it depends. - // Assume path is relative to same storage - // Many storages don't support relative paths easily without full URL manipulation - // For now, return as is if not local, or try to reconstruct } catch (Exception ex) { - Kp2aLog.Log("KeeShare: Error resolving relative path for " + path + ": " + ex.ToString()); + Kp2aLog.Log("KeeShare: Error resolving relative path: " + ex.GetType().Name); } return IOConnectionInfo.FromPath(path); @@ -436,18 +419,13 @@ private Stream OpenStream(IOConnectionInfo ioc) } catch (Exception ex) { - Kp2aLog.Log("Failed to open stream for " + ioc.Path + ": " + ex.Message); + Kp2aLog.Log("Failed to open KeeShare stream: " + ex.GetType().Name + " - " + ex.Message); return null; } } - /// - /// Verifies the signature of a KeeShare file. - /// Returns true if signature is valid and certificate is trusted, false otherwise. - /// private bool VerifySignature(PwGroup group, byte[] kdbxData, byte[] signatureData) { - // Get trusted certificate (public key) from group CustomData string trustedCert = group.CustomData.Get("KeeShare.TrustedCertificate"); if (string.IsNullOrEmpty(trustedCert)) @@ -470,15 +448,7 @@ private bool VerifySignature(PwGroup group, byte[] kdbxData, byte[] signatureDat return result; } - /// - /// Core signature verification logic that can be used by both production code and tests. - /// Verifies a signature using the provided trusted certificate, KDBX data, and signature data. - /// - /// The trusted certificate (public key) as base64-encoded DER or PEM format - /// The KDBX file data that was signed - /// The signature data in KeeShare format ("rsa|<hex>") - /// True if signature is valid, false otherwise - internal static bool VerifySignatureCore(string trustedCertificate, byte[]? kdbxData, byte[]? signatureData) + internal static bool VerifySignatureCore(string trustedCertificate, byte[] kdbxData, byte[] signatureData) { try { @@ -497,25 +467,20 @@ internal static bool VerifySignatureCore(string trustedCertificate, byte[]? kdbx return false; } - // KeeShare signature format: "rsa|" where hex is the RSA signature - // The signature is computed over the kdbx file data using SHA-256 string signatureText = Encoding.UTF8.GetString(signatureData).Trim(); - // Remove any whitespace/newlines signatureText = signatureText.Replace("\r", "").Replace("\n", "").Replace(" ", ""); - // Strip "rsa|" prefix if present const string rsaPrefix = "rsa|"; if (signatureText.StartsWith(rsaPrefix, StringComparison.OrdinalIgnoreCase)) { signatureText = signatureText.Substring(rsaPrefix.Length); } - // Hex-decode the signature byte[] signatureBytes; try { - signatureBytes = HexStringToBytes(signatureText); + signatureBytes = MemUtil.HexStringToByteArray(signatureText); } catch (Exception) { @@ -527,15 +492,11 @@ internal static bool VerifySignatureCore(string trustedCertificate, byte[]? kdbx return false; } - // Parse the trusted certificate (public key) - // Format: PEM-encoded public key or base64-encoded DER byte[] publicKeyBytes; try { - // Try to decode as base64 first if (trustedCertificate.Contains("-----BEGIN")) { - // PEM format - extract base64 content var lines = trustedCertificate.Split('\n'); var base64Lines = lines.Where(l => !l.Contains("BEGIN") && !l.Contains("END") && !string.IsNullOrWhiteSpace(l)); string base64Content = string.Join("", base64Lines).Trim(); @@ -543,7 +504,6 @@ internal static bool VerifySignatureCore(string trustedCertificate, byte[]? kdbx } else { - // Assume it's already base64-encoded DER publicKeyBytes = Convert.FromBase64String(trustedCertificate); } } @@ -552,13 +512,10 @@ internal static bool VerifySignatureCore(string trustedCertificate, byte[]? kdbx return false; } - // Create RSA object from public key - // Use using block to ensure deterministic disposal even if exceptions occur bool isValid = false; bool importFailed = false; using (RSA rsa = RSA.Create()) { - // Try importing as SubjectPublicKeyInfo (standard format) bool importSucceeded = false; try { @@ -573,7 +530,6 @@ internal static bool VerifySignatureCore(string trustedCertificate, byte[]? kdbx { try { - // Try importing as RSAPublicKey (PKCS#1 format) rsa.ImportRSAPublicKey(publicKeyBytes, out int bytesRead); if (bytesRead == 0) { @@ -589,19 +545,16 @@ internal static bool VerifySignatureCore(string trustedCertificate, byte[]? kdbx if (!importFailed && importSucceeded) { - // Compute hash of kdbx data byte[] hash; using (SHA256 sha256 = SHA256.Create()) { hash = sha256.ComputeHash(kdbxData); } - // Verify signature isValid = rsa.VerifyHash(hash, signatureBytes, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); } } - // Return false if import failed, otherwise return verification result return !importFailed && isValid; } catch (Exception) @@ -609,36 +562,5 @@ internal static bool VerifySignatureCore(string trustedCertificate, byte[]? kdbx return false; } } - - /// - /// Converts a hexadecimal string to a byte array. - /// - /// The hexadecimal string (case-insensitive, no separators) - /// The decoded byte array, or null if the input is invalid - private static byte[]? HexStringToBytes(string hex) - { - if (string.IsNullOrEmpty(hex)) - { - return null; - } - - // Hex string must have even length - if (hex.Length % 2 != 0) - { - return null; - } - - byte[] bytes = new byte[hex.Length / 2]; - for (int i = 0; i < bytes.Length; i++) - { - string byteStr = hex.Substring(i * 2, 2); - if (!byte.TryParse(byteStr, System.Globalization.NumberStyles.HexNumber, null, out bytes[i])) - { - return null; - } - } - - return bytes; - } } } diff --git a/src/keepass2android-app/SyncUtil.cs b/src/keepass2android-app/SyncUtil.cs index 139731688..b3c1d5929 100644 --- a/src/keepass2android-app/SyncUtil.cs +++ b/src/keepass2android-app/SyncUtil.cs @@ -116,11 +116,21 @@ public void StartSynchronizeDatabase(Database database, bool forceSynchronizatio adapter?.NotifyDataSetChanged(); }); - if (database?.OtpAuxFileIoc != null) + // Sync KeeShare groups if present + if (success && KeeShare.HasKeeShareGroups(database.KpDatabase.RootGroup)) { - var task2 = new SyncOtpAuxFile(_activity, database.OtpAuxFileIoc); - - OperationRunner.Instance.Run(App.Kp2a, task2); + KeeShare.SyncInBackground(App.Kp2a, new ActionOnOperationFinished(App.Kp2a, (keeShareSuccess, keeShareMessage, ctx) => + { + if (!String.IsNullOrEmpty(keeShareMessage)) + App.Kp2a.ShowMessage(ctx, keeShareMessage, keeShareSuccess ? MessageSeverity.Info : MessageSeverity.Error); + + // Continue with OTP aux file sync after KeeShare completes + SyncOtpAuxFileIfNeeded(database); + })); + } + else + { + SyncOtpAuxFileIfNeeded(database); } }); @@ -139,6 +149,15 @@ public void StartSynchronizeDatabase(Database database, bool forceSynchronizatio } + private void SyncOtpAuxFileIfNeeded(Database database) + { + if (database?.OtpAuxFileIoc != null) + { + var task = new SyncOtpAuxFile(_activity, database.OtpAuxFileIoc); + OperationRunner.Instance.Run(App.Kp2a, task); + } + } + public void TryStartPendingSyncs() { var prefs = PreferenceManager.GetDefaultSharedPreferences(_activity); From 06f095f5b4c9cc01783d87c152911bdd28444cbc Mon Sep 17 00:00:00 2001 From: plyght Date: Mon, 1 Dec 2025 22:19:27 -0500 Subject: [PATCH 08/31] Refactor SyncOtpAuxFile constructor --- src/keepass2android-app/SyncUtil.cs | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/keepass2android-app/SyncUtil.cs b/src/keepass2android-app/SyncUtil.cs index b3c1d5929..efb22bff7 100644 --- a/src/keepass2android-app/SyncUtil.cs +++ b/src/keepass2android-app/SyncUtil.cs @@ -37,7 +37,7 @@ public class SyncOtpAuxFile : OperationWithFinishHandler { private readonly IOConnectionInfo _ioc; - public SyncOtpAuxFile(Activity activity, IOConnectionInfo ioc) + public SyncOtpAuxFile(IOConnectionInfo ioc) : base(App.Kp2a, null) { _ioc = ioc; @@ -117,12 +117,15 @@ public void StartSynchronizeDatabase(Database database, bool forceSynchronizatio }); // Sync KeeShare groups if present - if (success && KeeShare.HasKeeShareGroups(database.KpDatabase.RootGroup)) + if (success && database.KpDatabase?.IsOpen == true && KeeShare.HasKeeShareGroups(database.KpDatabase.RootGroup)) { KeeShare.SyncInBackground(App.Kp2a, new ActionOnOperationFinished(App.Kp2a, (keeShareSuccess, keeShareMessage, ctx) => { - if (!String.IsNullOrEmpty(keeShareMessage)) - App.Kp2a.ShowMessage(ctx, keeShareMessage, keeShareSuccess ? MessageSeverity.Info : MessageSeverity.Error); + App.Kp2a.UiThreadHandler.Post(() => + { + if (!String.IsNullOrEmpty(keeShareMessage)) + App.Kp2a.ShowMessage(ctx, keeShareMessage, keeShareSuccess ? MessageSeverity.Info : MessageSeverity.Error); + }); // Continue with OTP aux file sync after KeeShare completes SyncOtpAuxFileIfNeeded(database); @@ -153,7 +156,7 @@ private void SyncOtpAuxFileIfNeeded(Database database) { if (database?.OtpAuxFileIoc != null) { - var task = new SyncOtpAuxFile(_activity, database.OtpAuxFileIoc); + var task = new SyncOtpAuxFile(database.OtpAuxFileIoc); OperationRunner.Instance.Run(App.Kp2a, task); } } From 21e0ad92dd7d9867d9b949e52cd03dfcbb9c2169 Mon Sep 17 00:00:00 2001 From: plyght Date: Mon, 1 Dec 2025 22:34:10 -0500 Subject: [PATCH 09/31] Refactor LoadDb to wrap operation finished handler for KeeShare integration, enhancing synchronization logic during database loading. --- src/Kp2aBusinessLogic/database/edit/LoadDB.cs | 22 ++++++++++++++++++- src/keepass2android-app/PasswordActivity.cs | 16 ++------------ 2 files changed, 23 insertions(+), 15 deletions(-) diff --git a/src/Kp2aBusinessLogic/database/edit/LoadDB.cs b/src/Kp2aBusinessLogic/database/edit/LoadDB.cs index ba15af21f..aa7729400 100644 --- a/src/Kp2aBusinessLogic/database/edit/LoadDB.cs +++ b/src/Kp2aBusinessLogic/database/edit/LoadDB.cs @@ -46,7 +46,7 @@ public class LoadDb : OperationWithFinishHandler public LoadDb(IKp2aApp app, IOConnectionInfo ioc, Task databaseData, CompositeKey compositeKey, string keyfileOrProvider, OnOperationFinishedHandler operationFinishedHandler, - bool updateLastUsageTimestamp, bool makeCurrent, IDatabaseModificationWatcher modificationWatcher = null) : base(app, operationFinishedHandler) + bool updateLastUsageTimestamp, bool makeCurrent, IDatabaseModificationWatcher modificationWatcher = null) : base(app, WrapHandlerForKeeShare(app, operationFinishedHandler)) { _modificationWatcher = modificationWatcher ?? new NullDatabaseModificationWatcher(); _app = app; @@ -59,6 +59,26 @@ public LoadDb(IKp2aApp app, IOConnectionInfo ioc, Task databaseDat _rememberKeyfile = app.GetBooleanPreference(PreferenceKey.remember_keyfile); } + private static OnOperationFinishedHandler WrapHandlerForKeeShare(IKp2aApp app, OnOperationFinishedHandler originalHandler) + { + if (originalHandler == null) + return null; + + return new ActionOnOperationFinished(app, (success, message, context) => + { + originalHandler.SetResult(success, message, false, null); + if (success && app.CurrentDb?.KpDatabase?.IsOpen == true && + KeeShare.HasKeeShareGroups(app.CurrentDb.KpDatabase.RootGroup)) + { + KeeShare.Check(app, originalHandler); + } + else + { + originalHandler.Run(); + } + }); + } + protected bool success = false; private bool _updateLastUsageTimestamp; private readonly bool _makeCurrent; diff --git a/src/keepass2android-app/PasswordActivity.cs b/src/keepass2android-app/PasswordActivity.cs index c5c2e9cc3..20d0a1c91 100644 --- a/src/keepass2android-app/PasswordActivity.cs +++ b/src/keepass2android-app/PasswordActivity.cs @@ -1451,22 +1451,10 @@ private void PerformLoadDatabaseWithCompositeKey(CompositeKey compositeKey) Handler handler = new Handler(); OnOperationFinishedHandler afterLoadHandler = new AfterLoad(handler, this, _ioConnection); - OnOperationFinishedHandler onOperationFinishedHandler = new ActionOnOperationFinished(App.Kp2a, (success, message, activity) => - { - if (success) - { - KeeShare.Check(App.Kp2a, afterLoadHandler); - } - else - { - afterLoadHandler.SetResult(success, message, false, null); - afterLoadHandler.Run(); - } - }); LoadDb loadOperation = (KeyProviderTypes.Contains(KeyProviders.Otp)) ? new SaveOtpAuxFileAndLoadDb(App.Kp2a, _ioConnection, _loadDbFileTask, compositeKey, GetKeyProviderString(), - onOperationFinishedHandler, this, true, _makeCurrent) - : new LoadDb(App.Kp2a, _ioConnection, _loadDbFileTask, compositeKey, GetKeyProviderString(), onOperationFinishedHandler, true, _makeCurrent); + afterLoadHandler, this, true, _makeCurrent) + : new LoadDb(App.Kp2a, _ioConnection, _loadDbFileTask, compositeKey, GetKeyProviderString(), afterLoadHandler, true, _makeCurrent); _loadDbFileTask = null; // prevent accidental re-use _lastLoadOperation = loadOperation; From 81d6a4a850004ca7808cee9613c57a1303e74089 Mon Sep 17 00:00:00 2001 From: plyght Date: Mon, 1 Dec 2025 22:54:18 -0500 Subject: [PATCH 10/31] Enhance KeeShare integration --- src/keepass2android-app/EntryActivity.cs | 2 +- src/keepass2android-app/GroupActivity.cs | 6 +++-- src/keepass2android-app/KeeShare.cs | 30 ++++++++++++++++++++++++ 3 files changed, 35 insertions(+), 3 deletions(-) diff --git a/src/keepass2android-app/EntryActivity.cs b/src/keepass2android-app/EntryActivity.cs index 2be7cbee5..69b5be637 100644 --- a/src/keepass2android-app/EntryActivity.cs +++ b/src/keepass2android-app/EntryActivity.cs @@ -238,7 +238,7 @@ protected void SetEntryView() protected void SetupEditButtons() { View edit = FindViewById(Resource.Id.entry_edit); - if (App.Kp2a.CurrentDb.CanWrite && _historyIndex < 0) + if (App.Kp2a.CurrentDb.CanWrite && _historyIndex < 0 && !KeeShare.IsReadOnlyBecauseKeeShareImport(Entry)) { edit.Visibility = ViewStates.Visible; edit.Click += (sender, e) => diff --git a/src/keepass2android-app/GroupActivity.cs b/src/keepass2android-app/GroupActivity.cs index 89eaf57b6..2e6e26e02 100644 --- a/src/keepass2android-app/GroupActivity.cs +++ b/src/keepass2android-app/GroupActivity.cs @@ -100,12 +100,14 @@ public override void SetupNormalButtons() protected override bool AddEntryEnabled { - get { return App.Kp2a.CurrentDb.CanWrite && ((this.Group.ParentGroup != null) || App.Kp2a.CurrentDb.DatabaseFormat.CanHaveEntriesInRootGroup); } + get { return App.Kp2a.CurrentDb.CanWrite && + ((this.Group.ParentGroup != null) || App.Kp2a.CurrentDb.DatabaseFormat.CanHaveEntriesInRootGroup) && + !KeeShare.IsReadOnlyBecauseKeeShareImport(this.Group); } } protected override bool AddGroupEnabled { - get { return App.Kp2a.CurrentDb.CanWrite; } + get { return App.Kp2a.CurrentDb.CanWrite && !KeeShare.IsReadOnlyBecauseKeeShareImport(this.Group); } } private class TemplateListAdapter : ArrayAdapter diff --git a/src/keepass2android-app/KeeShare.cs b/src/keepass2android-app/KeeShare.cs index 4a47c7afa..9c0c877f1 100644 --- a/src/keepass2android-app/KeeShare.cs +++ b/src/keepass2android-app/KeeShare.cs @@ -64,6 +64,36 @@ internal static bool HasKeeShareGroups(PwGroup group) } return false; } + + /// + /// Checks if a group is read-only because it's a KeeShare Import group + /// or is contained within one. Import groups replace their contents on sync, + /// so local modifications would be lost. + /// + public static bool IsReadOnlyBecauseKeeShareImport(PwGroup group) + { + if (group == null) return false; + + PwGroup current = group; + while (current != null) + { + if (current.CustomData.Get("KeeShare.Active") == "true" && + current.CustomData.Get("KeeShare.Type") == "Import") + { + return true; + } + current = current.ParentGroup; + } + return false; + } + + /// + /// Checks if an entry is read-only because it's in a KeeShare Import group. + /// + public static bool IsReadOnlyBecauseKeeShareImport(PwEntry entry) + { + return entry?.ParentGroup != null && IsReadOnlyBecauseKeeShareImport(entry.ParentGroup); + } } public class KeeShareCheckOperation : OperationWithFinishHandler From 94b780bc6af598e0e0866d58ba5f74109370007d Mon Sep 17 00:00:00 2001 From: plyght Date: Tue, 2 Dec 2025 12:18:38 -0500 Subject: [PATCH 11/31] Refactor public key extraction in KeeShare to utilize X509Certificate2 for improved PEM handling and reliability. --- src/keepass2android-app/KeeShare.cs | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/src/keepass2android-app/KeeShare.cs b/src/keepass2android-app/KeeShare.cs index 9c0c877f1..4bfa498b5 100644 --- a/src/keepass2android-app/KeeShare.cs +++ b/src/keepass2android-app/KeeShare.cs @@ -12,6 +12,7 @@ using System.IO.Compression; using Android.Content; using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; using System.Text; namespace keepass2android @@ -525,17 +526,9 @@ internal static bool VerifySignatureCore(string trustedCertificate, byte[] kdbxD byte[] publicKeyBytes; try { - if (trustedCertificate.Contains("-----BEGIN")) - { - var lines = trustedCertificate.Split('\n'); - var base64Lines = lines.Where(l => !l.Contains("BEGIN") && !l.Contains("END") && !string.IsNullOrWhiteSpace(l)); - string base64Content = string.Join("", base64Lines).Trim(); - publicKeyBytes = Convert.FromBase64String(base64Content); - } - else - { - publicKeyBytes = Convert.FromBase64String(trustedCertificate); - } + string pemText = trustedCertificate.Trim(); + var cert = X509Certificate2.CreateFromPem(pemText); + publicKeyBytes = cert.GetPublicKey(); } catch (Exception) { From b27bad21a5548f3b9911c7f109518dfa6b7977ea Mon Sep 17 00:00:00 2001 From: plyght Date: Tue, 2 Dec 2025 12:27:32 -0500 Subject: [PATCH 12/31] Enhance public key extraction in KeeShare by adding support for both certificate and public key formats, improving flexibility in PEM handling. --- src/keepass2android-app/KeeShare.cs | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/src/keepass2android-app/KeeShare.cs b/src/keepass2android-app/KeeShare.cs index 4bfa498b5..93e046df3 100644 --- a/src/keepass2android-app/KeeShare.cs +++ b/src/keepass2android-app/KeeShare.cs @@ -527,8 +527,23 @@ internal static bool VerifySignatureCore(string trustedCertificate, byte[] kdbxD try { string pemText = trustedCertificate.Trim(); - var cert = X509Certificate2.CreateFromPem(pemText); - publicKeyBytes = cert.GetPublicKey(); + + if (pemText.Contains("-----BEGIN CERTIFICATE-----")) + { + var cert = X509Certificate2.CreateFromPem(pemText); + publicKeyBytes = cert.GetPublicKey(); + } + else if (pemText.Contains("-----BEGIN PUBLIC KEY-----")) + { + var lines = pemText.Split('\n'); + var base64Lines = lines.Where(l => !string.IsNullOrWhiteSpace(l) && !l.Trim().StartsWith("-----")); + string base64Content = string.Join("", base64Lines).Trim(); + publicKeyBytes = Convert.FromBase64String(base64Content); + } + else + { + publicKeyBytes = Convert.FromBase64String(pemText); + } } catch (Exception) { From 442b5e24a994e9326eddc07276881ad75707454f Mon Sep 17 00:00:00 2001 From: plyght Date: Tue, 2 Dec 2025 12:50:46 -0500 Subject: [PATCH 13/31] Refine PEM format handling in KeeShare by changing checks from Contains to StartsWith --- src/keepass2android-app/KeeShare.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/keepass2android-app/KeeShare.cs b/src/keepass2android-app/KeeShare.cs index 93e046df3..d229b16f8 100644 --- a/src/keepass2android-app/KeeShare.cs +++ b/src/keepass2android-app/KeeShare.cs @@ -528,12 +528,12 @@ internal static bool VerifySignatureCore(string trustedCertificate, byte[] kdbxD { string pemText = trustedCertificate.Trim(); - if (pemText.Contains("-----BEGIN CERTIFICATE-----")) + if (pemText.StartsWith("-----BEGIN CERTIFICATE-----")) { var cert = X509Certificate2.CreateFromPem(pemText); publicKeyBytes = cert.GetPublicKey(); } - else if (pemText.Contains("-----BEGIN PUBLIC KEY-----")) + else if (pemText.StartsWith("-----BEGIN PUBLIC KEY-----")) { var lines = pemText.Split('\n'); var base64Lines = lines.Where(l => !string.IsNullOrWhiteSpace(l) && !l.Trim().StartsWith("-----")); From 56653bd0693982b51a13c6a0257856e0b76b0686 Mon Sep 17 00:00:00 2001 From: plyght Date: Tue, 2 Dec 2025 13:08:37 -0500 Subject: [PATCH 14/31] Implement KeeShare export functionality on database save, enhancing synchronization with shared files. Add new UI elements and logic for managing KeeShare groups, including device-specific paths and export operations. --- src/Kp2aBusinessLogic/database/edit/SaveDB.cs | 32 +- .../ConfigureKeeShareActivity.cs | 252 ++++++++++++ src/keepass2android-app/KeeShare.cs | 375 +++++++++++++++++- .../Resources/layout/config_keeshare.xml | 34 ++ .../Resources/layout/keeshare_config_row.xml | 98 +++++ .../Resources/values/strings.xml | 23 +- .../Resources/xml/pref_database.xml | 7 + src/keepass2android-app/app/App.cs | 12 + 8 files changed, 828 insertions(+), 5 deletions(-) create mode 100644 src/keepass2android-app/ConfigureKeeShareActivity.cs create mode 100644 src/keepass2android-app/Resources/layout/config_keeshare.xml create mode 100644 src/keepass2android-app/Resources/layout/keeshare_config_row.xml diff --git a/src/Kp2aBusinessLogic/database/edit/SaveDB.cs b/src/Kp2aBusinessLogic/database/edit/SaveDB.cs index b0b8ec96d..71ca81513 100644 --- a/src/Kp2aBusinessLogic/database/edit/SaveDB.cs +++ b/src/Kp2aBusinessLogic/database/edit/SaveDB.cs @@ -47,6 +47,12 @@ public class SaveDb : OperationWithFinishHandler private readonly IDatabaseModificationWatcher _modificationWatcher; private bool requiresSubsequentSync = false; //if true, we need to sync the file after saving. + /// + /// Static callback that can be registered by the app to handle KeeShare export after save. + /// Called with (IKp2aApp app, Database db, OnOperationFinishedHandler handler). + /// + public static Action OnSaveCompleteKeeShareExport { get; set; } + public bool DoNotSetStatusLoggerMessage = false; /// @@ -236,6 +242,7 @@ private void FinishWithSuccess() if (!System.String.IsNullOrEmpty(message)) _app.ShowMessage(context, message, success ? MessageSeverity.Info : MessageSeverity.Error); + TriggerKeeShareExportThenFinish(); }), new BackgroundDatabaseModificationLocker(_app) ); OperationRunner.Instance.Run(_app, syncTask); @@ -243,9 +250,32 @@ private void FinishWithSuccess() else { _db.LastSyncTime = DateTime.Now; + TriggerKeeShareExportThenFinish(); + } + } + private void TriggerKeeShareExportThenFinish() + { + try + { + if (OnSaveCompleteKeeShareExport != null) + { + OnSaveCompleteKeeShareExport.Invoke(_app, _db, new ActionOnOperationFinished(_app, + (success, message, context) => + { + Finish(true); + })); + } + else + { + Finish(true); + } + } + catch (Exception ex) + { + Kp2aLog.Log("KeeShare export after save failed: " + ex.Message); + Finish(true); } - Finish(true); } private void MergeAndFinish(IFileStorage fileStorage, IOConnectionInfo ioc) diff --git a/src/keepass2android-app/ConfigureKeeShareActivity.cs b/src/keepass2android-app/ConfigureKeeShareActivity.cs new file mode 100644 index 000000000..f03e6771f --- /dev/null +++ b/src/keepass2android-app/ConfigureKeeShareActivity.cs @@ -0,0 +1,252 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Android.App; +using Android.Content; +using Android.Content.PM; +using Android.OS; +using Android.Views; +using Android.Widget; +using Google.Android.Material.Dialog; +using keepass2android.database.edit; +using KeePassLib; +using KeePassLib.Serialization; + +namespace keepass2android +{ + [Activity(Label = "@string/keeshare_title", MainLauncher = false, Theme = "@style/Kp2aTheme_BlueNoActionBar", LaunchMode = LaunchMode.SingleInstance, ConfigurationChanges = ConfigChanges.Orientation | ConfigChanges.Keyboard | ConfigChanges.KeyboardHidden, Exported = true)] + [IntentFilter(new[] { "kp2a.action.ConfigureKeeShareActivity" }, Categories = new[] { Intent.CategoryDefault })] + public class ConfigureKeeShareActivity : LockCloseActivity + { + private KeeShareAdapter _adapter; + private const int ReqCodeSelectFile = 1; + private KeeShareItem _pendingConfigItem; + + public class KeeShareAdapter : BaseAdapter + { + private readonly ConfigureKeeShareActivity _context; + internal List _displayedItems; + + public KeeShareAdapter(ConfigureKeeShareActivity context) + { + _context = context; + Update(); + } + + public override Java.Lang.Object GetItem(int position) + { + return position; + } + + public override long GetItemId(int position) + { + return position; + } + + private LayoutInflater _inflater; + + public override View GetView(int position, View convertView, ViewGroup parent) + { + if (_inflater == null) + _inflater = (LayoutInflater)_context.GetSystemService(Context.LayoutInflaterService); + + View view; + + if (convertView == null) + { + view = _inflater.Inflate(Resource.Layout.keeshare_config_row, parent, false); + + view.FindViewById public static void SyncInBackground(IKp2aApp app, OnOperationFinishedHandler onFinished) { + if (app == null) + { + onFinished?.Run(); + return; + } Check(app, onFinished); } @@ -269,6 +292,7 @@ public static bool IsReadOnlyBecauseKeeShareImport(PwEntry entry) public static bool HasExportableKeeShareGroups(PwDatabase db) { if (db == null || !db.IsOpen) return false; + if (db.RootGroup == null) return false; return HasExportableKeeShareGroups(db.RootGroup); } @@ -298,8 +322,20 @@ private static bool HasExportableKeeShareGroups(PwGroup group) /// public static void ExportOnSave(IKp2aApp app, OnOperationFinishedHandler nextHandler) { + if (app == null) + { + nextHandler?.Run(); + return; + } + var db = app.CurrentDb; - if (db == null || !db.KpDatabase.IsOpen) + if (db == null) + { + nextHandler?.Run(); + return; + } + + if (db.KpDatabase == null || !db.KpDatabase.IsOpen) { nextHandler?.Run(); return; From 67c62a7d989dce14f29fec0620017e1dd7f7fb15 Mon Sep 17 00:00:00 2001 From: plyght Date: Wed, 3 Dec 2025 13:59:19 -0500 Subject: [PATCH 21/31] Add null check for path parameter in ResolvePath method of KeeShare class --- src/keepass2android-app/KeeShare.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/keepass2android-app/KeeShare.cs b/src/keepass2android-app/KeeShare.cs index a31f07c56..063fa26fa 100644 --- a/src/keepass2android-app/KeeShare.cs +++ b/src/keepass2android-app/KeeShare.cs @@ -96,6 +96,9 @@ public static void SetDeviceFilePath(PwGroup group, string path) /// internal static IOConnectionInfo ResolvePath(IKp2aApp app, string path) { + if (path == null) + throw new ArgumentNullException(nameof(path)); + if (path.Contains("://") || path.StartsWith("/")) { return IOConnectionInfo.FromPath(path); From 115cb7e36ad811066436326ff02dc06683283642 Mon Sep 17 00:00:00 2001 From: Philipp Crocoll Date: Mon, 8 Dec 2025 10:51:22 +0100 Subject: [PATCH 22/31] add workload restore in yml files --- .github/workflows/build.yml | 8 ++++++-- .github/workflows/release.yml | 4 +++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 333e6b97e..55975a84e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -318,7 +318,9 @@ jobs: make manifestlink Flavor=Net - name: Install NuGet dependencies (net) - run: make nuget Flavor=Net + run: | + dotnet workload restore + make nuget Flavor=NoNet - name: Build keepass2android (net) run: | @@ -346,7 +348,9 @@ jobs: make manifestlink Flavor=NoNet - name: Install NuGet dependencies (nonet) - run: make nuget Flavor=NoNet + run: | + dotnet workload restore + make nuget Flavor=NoNet - name: Build keepass2android (nonet) run: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 176a5887f..387ed3ae2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -101,7 +101,9 @@ jobs: - name: Install NuGet dependencies - run: make nuget Flavor=${{ matrix.flavor }} + run: | + dotnet workload restore + make nuget Flavor=${{ matrix.flavor }} - name: List apks run: find . -type f -name "*.apk" From eb526a106ee479ac1cd740fe8c52479354f06ec1 Mon Sep 17 00:00:00 2001 From: Philipp Crocoll Date: Mon, 8 Dec 2025 11:14:20 +0100 Subject: [PATCH 23/31] fix workload restore in yml files --- .github/workflows/build.yml | 4 ++-- .github/workflows/release.yml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 55975a84e..8732000e2 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -319,7 +319,7 @@ jobs: - name: Install NuGet dependencies (net) run: | - dotnet workload restore + dotnet workload restore --project src/KeePass.sln make nuget Flavor=NoNet - name: Build keepass2android (net) @@ -349,7 +349,7 @@ jobs: - name: Install NuGet dependencies (nonet) run: | - dotnet workload restore + dotnet workload restore --project src/KeePass.sln make nuget Flavor=NoNet - name: Build keepass2android (nonet) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 387ed3ae2..620179f9e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -102,7 +102,7 @@ jobs: - name: Install NuGet dependencies run: | - dotnet workload restore + dotnet workload restore --project src/KeePass.sln make nuget Flavor=${{ matrix.flavor }} - name: List apks From b21a5231b99f7ff5185ab97032571628a4e24d11 Mon Sep 17 00:00:00 2001 From: plyght Date: Mon, 8 Dec 2025 11:11:04 -0500 Subject: [PATCH 24/31] Refactor KeeShare methods --- src/KeeShare.Tests/TestHelpers/PwGroupStub.cs | 6 +++ src/Kp2aBusinessLogic/database/edit/LoadDB.cs | 17 ++++++- src/Kp2aBusinessLogic/database/edit/SaveDB.cs | 1 + src/keepass2android-app/KeeShare.cs | 44 ++++++++----------- 4 files changed, 41 insertions(+), 27 deletions(-) diff --git a/src/KeeShare.Tests/TestHelpers/PwGroupStub.cs b/src/KeeShare.Tests/TestHelpers/PwGroupStub.cs index 15d949ded..6e8d9fd54 100644 --- a/src/KeeShare.Tests/TestHelpers/PwGroupStub.cs +++ b/src/KeeShare.Tests/TestHelpers/PwGroupStub.cs @@ -53,3 +53,9 @@ public static bool HasKeeShareGroups(PwGroupStub group) } + + + + + + diff --git a/src/Kp2aBusinessLogic/database/edit/LoadDB.cs b/src/Kp2aBusinessLogic/database/edit/LoadDB.cs index 37d51899f..be859eb53 100644 --- a/src/Kp2aBusinessLogic/database/edit/LoadDB.cs +++ b/src/Kp2aBusinessLogic/database/edit/LoadDB.cs @@ -69,7 +69,22 @@ public LoadDb(IKp2aApp app, IOConnectionInfo ioc, Task databaseDat private static OnOperationFinishedHandler WrapHandlerForKeeShare(IKp2aApp app, OnOperationFinishedHandler originalHandler) { if (originalHandler == null) - return null; + { + return new ActionOnOperationFinished(app, (success, message, context) => + { + if (success && app.CurrentDb?.KpDatabase?.IsOpen == true && OnLoadCompleteKeeShareCheck != null) + { + try + { + OnLoadCompleteKeeShareCheck(app, null); + } + catch (Exception ex) + { + Kp2aLog.Log("KeeShare check after load failed: " + ex.Message); + } + } + }); + } return new ActionOnOperationFinished(app, (success, message, context) => { diff --git a/src/Kp2aBusinessLogic/database/edit/SaveDB.cs b/src/Kp2aBusinessLogic/database/edit/SaveDB.cs index 71ca81513..c49bc13c4 100644 --- a/src/Kp2aBusinessLogic/database/edit/SaveDB.cs +++ b/src/Kp2aBusinessLogic/database/edit/SaveDB.cs @@ -274,6 +274,7 @@ private void TriggerKeeShareExportThenFinish() catch (Exception ex) { Kp2aLog.Log("KeeShare export after save failed: " + ex.Message); + _app.ShowMessage(_app.ActiveContext, "KeeShare export after save failed: " + ex.Message, MessageSeverity.Error); Finish(true); } } diff --git a/src/keepass2android-app/KeeShare.cs b/src/keepass2android-app/KeeShare.cs index 063fa26fa..8edd35c3a 100644 --- a/src/keepass2android-app/KeeShare.cs +++ b/src/keepass2android-app/KeeShare.cs @@ -136,10 +136,18 @@ internal static IOConnectionInfo ResolvePath(IKp2aApp app, string path) } else { - Kp2aLog.Log("KeeShare: Relative path used with non-local database. Path will be treated as-is: " + path); + try + { + var fileStorage = app.GetFileStorage(currentIoc); + IOConnectionInfo parentPath = fileStorage.GetParentPath(currentIoc); + return fileStorage.GetFilePath(parentPath, path); + } + catch (Exception ex) + { + Kp2aLog.Log("KeeShare: Failed to resolve relative path using IFileStorage methods: " + ex.Message + ". Using path as-is: " + path); + return IOConnectionInfo.FromPath(path); + } } - - return IOConnectionInfo.FromPath(path); } /// @@ -358,7 +366,7 @@ public static void ExportOnSave(IKp2aApp app, OnOperationFinishedHandler nextHan /// Exports a KeeShare group to a file. Creates a new database containing only /// the entries and subgroups from the source group, then saves it to the specified path. /// - internal static void ExportGroupToFile(IKp2aApp app, PwGroup sourceGroup, string path, string password) + internal static void ExportGroupToFile(IKp2aApp app, PwGroup sourceGroup, string path, string password, IKp2aStatusLogger statusLogger = null) { if (string.IsNullOrEmpty(path)) { @@ -366,6 +374,8 @@ internal static void ExportGroupToFile(IKp2aApp app, PwGroup sourceGroup, string return; } + statusLogger?.UpdateMessage("Exporting KeeShare database group " + sourceGroup.Name); + IOConnectionInfo ioc = ResolvePath(app, path); PwDatabase exportDb = null; @@ -407,10 +417,6 @@ internal static void ExportGroupToFile(IKp2aApp app, PwGroup sourceGroup, string Kp2aLog.Log("KeeShare: Exported group " + sourceGroup.Name + " to " + path); } - catch (Exception ex) - { - Kp2aLog.Log("KeeShare export failed for group " + sourceGroup.Name + ": " + ex.Message); - } finally { exportDb?.Close(); @@ -476,14 +482,7 @@ private void ProcessGroup(PwGroup group) string type = group.CustomData.Get("KeeShare.Type"); if (type == "Export" || type == "Synchronize") { - try - { - ExportGroup(group); - } - catch (Exception ex) - { - Kp2aLog.Log("Error exporting KeeShare for group " + group.Name + ": " + ex.ToString()); - } + ExportGroup(group); } } @@ -500,7 +499,7 @@ private void ExportGroup(PwGroup sourceGroup) StatusLogger.UpdateMessage(_app.GetResourceString(UiStringKey.saving_database) + ": " + sourceGroup.Name); - KeeShare.ExportGroupToFile(_app, sourceGroup, path, password); + KeeShare.ExportGroupToFile(_app, sourceGroup, path, password, StatusLogger); } } @@ -556,14 +555,7 @@ private void ProcessGroup(PwGroup group) { if (group.CustomData.Get("KeeShare.Active") == "true") { - try - { - ProcessKeeShare(group); - } - catch (Exception ex) - { - Kp2aLog.Log("Error processing KeeShare for group " + group.Name + ": " + ex.ToString()); - } + ProcessKeeShare(group); } foreach (var sub in group.Groups.ToList()) @@ -598,7 +590,7 @@ private void ProcessKeeShare(PwGroup group) private void Export(PwGroup sourceGroup, string path, string password) { - KeeShare.ExportGroupToFile(_app, sourceGroup, path, password); + KeeShare.ExportGroupToFile(_app, sourceGroup, path, password, StatusLogger); } private void Import(PwGroup targetGroup, string path, string password, string type) From bd4130b900f046c263577f0334aa62816e94f71b Mon Sep 17 00:00:00 2001 From: plyght Date: Mon, 8 Dec 2025 11:29:01 -0500 Subject: [PATCH 25/31] Add KeeShare functionality for group management --- docs/KeeShare-Implementation-Guide.md | 236 ++++++++++++++++++ .../EditKeeShareActivity.cs | 180 +++++++++++++ src/keepass2android-app/GroupBaseActivity.cs | 10 + src/keepass2android-app/KeeShare.cs | 107 +++++++- .../Resources/layout/edit_keeshare.xml | 116 +++++++++ .../Resources/menu/group_entriesselected.xml | 7 +- .../Resources/values/arrays.xml | 8 + .../Resources/values/strings.xml | 14 ++ 8 files changed, 675 insertions(+), 3 deletions(-) create mode 100644 docs/KeeShare-Implementation-Guide.md create mode 100644 src/keepass2android-app/EditKeeShareActivity.cs create mode 100644 src/keepass2android-app/Resources/layout/edit_keeshare.xml create mode 100644 src/keepass2android-app/Resources/values/arrays.xml diff --git a/docs/KeeShare-Implementation-Guide.md b/docs/KeeShare-Implementation-Guide.md new file mode 100644 index 000000000..3da5ccc84 --- /dev/null +++ b/docs/KeeShare-Implementation-Guide.md @@ -0,0 +1,236 @@ +# KeeShare Implementation Guide + +## Overview + +KeeShare for Keepass2Android enables sharing password groups between databases with full support for Export, Import, and Synchronize modes. + +**Key Features:** +- Native UI configuration (no KeePassXC required) +- Device-specific file paths +- Optional signature verification (RSA-2048 + SHA-256) +- Password-protected shared databases +- Automatic sync on database open/save +- Fully compatible with KeePassXC + +--- + +## Quick Start + +### Creating a KeeShare Group + +1. **Long-press** a group → Select **"Edit KeeShare..."** +2. Check **"Enable KeeShare for this group"** +3. Choose **Type**: Export / Import / Synchronize +4. Select **File Path** and optional **Password** +5. Tap **OK** (saves automatically) + +### Configuring Device-Specific Paths + +For groups configured on other devices: + +1. **Settings** → **Database** → **Configure KeeShare groups** +2. Select group → **"Configure path"** → Choose file location +3. **"Sync now"** to test + +--- + +## Share Types + +### Export +- **Purpose:** Share your entries with others +- **Behavior:** Exports group contents to file on database save +- **Use case:** You maintain shared credentials (team/family passwords) + +### Import +- **Purpose:** Receive entries from external file +- **Behavior:** Replaces group contents on database open +- **Warning:** ⚠️ Local changes are lost - group is read-only +- **Use case:** Consume credentials maintained by someone else + +### Synchronize +- **Purpose:** Two-way sync between databases +- **Behavior:** Exports on save, imports on open, merges intelligently +- **Conflict resolution:** Newer entry wins, history preserved +- **Use case:** Multiple people editing same shared group + +--- + +## CustomData Properties + +### Core Properties +| Property | Values | Description | +|----------|--------|-------------| +| KeeShare.Active | "true"/"false" | Enable/disable KeeShare | +| KeeShare.Type | Export/Import/Synchronize | Share mode | +| KeeShare.FilePath | /path/to/file.kdbx | Shared file location | +| KeeShare.Password | string | Optional password for shared file | + +### Device-Specific Path +``` +KeeShare.FilePath.{DeviceId} = "/device/specific/path.kdbx" +``` +Overrides global `KeeShare.FilePath` for this device only. + +--- + +## Advanced Features + +### Signature Verification + +Add to CustomData: +``` +KeeShare.TrustedCertificate = +``` + +**Security Modes:** +- No certificate → Imports without verification +- Certificate + signature → Verifies before import +- Certificate but no signature → Blocks import + +**Creating Signed Shares:** +```bash +# Generate keys +openssl genrsa -out private.pem 2048 +openssl rsa -in private.pem -pubout -out public.pem + +# Sign and package +openssl dgst -sha256 -sign private.pem -out shared.sig shared.kdbx +base64 shared.sig > shared.sig.b64 +zip shared.zip shared.kdbx shared.sig.b64 + +# Export public key for configuration +openssl rsa -pubin -in public.pem -outform DER | base64 +``` + +### Relative Paths + +Supported for both local and remote databases: +``` +KeeShare.FilePath = "../shared/team.kdbx" # Parent directory +KeeShare.FilePath = "subfolder/import.kdbx" # Subdirectory +KeeShare.FilePath = "team.kdbx" # Same directory +``` + +### Read-Only Import Groups + +Import groups are automatically read-only to prevent data loss. + +--- + +## How It Works + +### Database Open (Import/Synchronize) +1. Scans for active KeeShare groups +2. Resolves file paths (device-specific or global) +3. Opens shared files (handles ZIP/KDBX, verifies signatures) +4. Merges: Import clears then replaces; Synchronize intelligently merges (newer wins, preserves history) + +### Database Save (Export/Synchronize) +1. Scans for Export/Synchronize groups +2. Creates new KDBX with group contents +3. Saves to configured path with optional password + +--- + +## API Reference + +### Methods + +```csharp +// Enable KeeShare on a group +KeeShare.EnableKeeShare(PwGroup group, string type, string filePath, string password = null) + +// Update configuration +KeeShare.UpdateKeeShareConfig(PwGroup group, string type, string filePath, string password) + +// Disable KeeShare +KeeShare.DisableKeeShare(PwGroup group) + +// Set device-specific path +KeeShare.SetDeviceFilePath(PwGroup group, string path) + +// Get effective path for this device +string path = KeeShare.GetEffectiveFilePath(PwGroup group) +``` + +**Example:** +```csharp +KeeShare.EnableKeeShare(myGroup, "Export", "/sdcard/shared.kdbx", "password123"); +``` + +--- + +## Testing + +### Unit Tests +```bash +cd src/KeeShare.Tests && dotnet test +``` +15 tests covering signature verification, group detection, and edge cases. + +### Manual Testing + +**Quick Tests:** +1. **Export**: Enable on group → Add entries → Save → Verify file created +2. **Import**: Create shared KDBX → Configure group → Reopen DB → Verify imported +3. **Synchronize**: Configure → Import → Add entry → Save → Verify exported +4. **Signatures**: Create signed ZIP → Configure certificate → Verify or fail appropriately + +**Check Logs:** +```bash +adb logcat | grep -i keeshare +``` + +--- + +## Compatibility + +### KeePassXC +**Full interoperability:** Same CustomData format, same file format. Configure in either app, use in both. + +**File Format Support:** +| Format | KP2A | KeePassXC | +|--------|------|-----------| +| Plain .kdbx | ✓ Full | ✓ Full | +| ZIP containers | ✓ Import | ✓ Full | +| Signed ZIP | ✓ Import | ✓ Full | + +--- + +## Troubleshooting + +| Problem | Solution | +|---------|----------| +| Menu not appearing | Long-press **group** (not entry), option appears when single group selected | +| Import not working | Check file path, permissions, password; reopen database; check logs: `adb logcat \| grep -i keeshare` | +| Export not creating files | Verify type is Export/Synchronize, path is writable, permissions granted | +| Signature fails | Check public key format, .sig file in ZIP, database not modified after signing | +| Performance slow | Use local files, reduce database size, disable unused groups | + +--- + +## Security Notes + +**Signature Verification (Optional):** +- RSA-2048 + SHA-256 for secure imports +- Protects against tampering and unauthorized modifications +- No protection against compromised keys or replay attacks + +**Best Practices:** +- Use signatures for sensitive data +- Strong passwords on shared databases +- Verify public keys through separate channel +- Monitor logs for failures +- Limit shared data to necessary entries + +--- + +## References + +- **KeePassXC KeeShare**: https://github.com/keepassxreboot/keepassxc/blob/develop/docs/topics/KeeShare.adoc +- **KDBX Format**: https://keepass.info/help/kb/kdbx_4.html +- **Original Issue**: https://github.com/PhilippC/keepass2android/issues/839 + +--- + +**Version:** 2.0 | **Date:** 2025-01-09 | **License:** GPLv3 diff --git a/src/keepass2android-app/EditKeeShareActivity.cs b/src/keepass2android-app/EditKeeShareActivity.cs new file mode 100644 index 000000000..375314e0e --- /dev/null +++ b/src/keepass2android-app/EditKeeShareActivity.cs @@ -0,0 +1,180 @@ +using System; +using Android.App; +using Android.Content; +using Android.Content.PM; +using Android.OS; +using Android.Views; +using Android.Widget; +using keepass2android.database.edit; +using KeePassLib; +using KeePassLib.Serialization; + +namespace keepass2android +{ + [Activity(Label = "@string/keeshare_edit_title", MainLauncher = false, Theme = "@style/Kp2aTheme_BlueNoActionBar", ConfigurationChanges = ConfigChanges.Orientation | ConfigChanges.Keyboard | ConfigChanges.KeyboardHidden)] + public class EditKeeShareActivity : LockCloseActivity + { + private const int ReqCodeSelectFile = 1; + private const string GroupUuidKey = "GroupUuid"; + + private PwGroup _group; + private CheckBox _enableCheckbox; + private Spinner _typeSpinner; + private EditText _filePathEditText; + private EditText _passwordEditText; + private Button _selectFileButton; + private Button _okButton; + private Button _cancelButton; + + protected override void OnCreate(Bundle savedInstanceState) + { + base.OnCreate(savedInstanceState); + + SetContentView(Resource.Layout.edit_keeshare); + + string groupUuidString = Intent.GetStringExtra(GroupUuidKey); + if (string.IsNullOrEmpty(groupUuidString)) + { + Finish(); + return; + } + + PwUuid groupUuid = new PwUuid(Convert.FromBase64String(groupUuidString)); + _group = App.Kp2a.CurrentDb?.KpDatabase?.RootGroup?.FindGroup(groupUuid, true); + + if (_group == null) + { + App.Kp2a.ShowMessage(this, GetString(Resource.String.error_group_not_found), MessageSeverity.Error); + Finish(); + return; + } + + InitializeViews(); + LoadCurrentSettings(); + } + + private void InitializeViews() + { + _enableCheckbox = FindViewById(Resource.Id.keeshare_enable_checkbox); + _typeSpinner = FindViewById(Resource.Id.keeshare_type_spinner); + _filePathEditText = FindViewById(Resource.Id.keeshare_filepath); + _passwordEditText = FindViewById(Resource.Id.keeshare_password); + _selectFileButton = FindViewById