Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -178,3 +178,5 @@ src/java/Keepass2AndroidPluginSDK2/build/generated/mockable-Google-Inc.-Google-A
/src/MegaTest
*.dtbcache.json
/src/keepass2android-app/AndroidManifest.xml

logs/
151 changes: 151 additions & 0 deletions src/KeePass.sln

Large diffs are not rendered by default.

534 changes: 534 additions & 0 deletions src/Kp2aBusinessLogic/Io/S3FileStorage.cs

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions src/Kp2aBusinessLogic/Kp2aBusinessLogic.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
<Folder Include="Resources\" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="AWSSDK.S3" Version="3.7.511.8" Condition="'$(Flavor)'!='NoNet'" />
<PackageReference Include="FluentFTP" Version="52.1.0" Condition="'$(Flavor)'!='NoNet'" />
<PackageReference Include="MegaApiClient" Version="1.10.4" Condition="'$(Flavor)'!='NoNet'" />
<PackageReference Include="Microsoft.Graph" Version="5.68.0" Condition="'$(Flavor)'!='NoNet'" />
Expand All @@ -23,6 +24,7 @@
<ProjectReference Include="..\AndroidFileChooserBinding\AndroidFileChooserBinding.csproj" />
<ProjectReference Include="..\JavaFileStorageBindings\JavaFileStorageBindings.csproj" Condition="'$(Flavor)'!='NoNet'" />
<ProjectReference Include="..\KeePassLib2Android\KeePassLib2Android.csproj" />
<ProjectReference Include="..\Kp2aS3PathCodec\Kp2aS3PathCodec.csproj" />
<ProjectReference Include="..\KP2AKdbLibraryBinding\KP2AKdbLibraryBinding.csproj" />
<ProjectReference Include="..\TwofishCipher\TwofishCipher.csproj" />
</ItemGroup>
Expand Down
2 changes: 1 addition & 1 deletion src/Kp2aBusinessLogic/database/edit/AddTemplateEntries.cs
Original file line number Diff line number Diff line change
Expand Up @@ -343,7 +343,7 @@ public PwGroup AddTemplates(out List<PwEntry> addedEntries)
templateGroup = new PwGroup(true, true, _app.GetResourceString(UiStringKey.TemplateGroupName), PwIcon.Folder);
_app.CurrentDb.KpDatabase.RootGroup.AddGroup(templateGroup, true);
_app.CurrentDb.KpDatabase.EntryTemplatesGroup = templateGroup.Uuid;
_app.CurrentDb.KpDatabase.EntryTemplatesGroupChanged = DateTime.Now;
_app.CurrentDb.KpDatabase.EntryTemplatesGroupChanged = DateTime.UtcNow;
_app.DirtyGroups.Add(_app.CurrentDb.KpDatabase.RootGroup);
_app.CurrentDb.GroupsById[templateGroup.Uuid] = templateGroup;
_app.CurrentDb.Elements.Add(templateGroup);
Expand Down
2 changes: 1 addition & 1 deletion src/Kp2aBusinessLogic/database/edit/DeleteRunnable.cs
Original file line number Diff line number Diff line change
Expand Up @@ -283,7 +283,7 @@ protected bool DoDeleteGroup(PwGroup pg, List<PwGroup> touchedGroups, List<PwGro
if (pg.Uuid.Equals(pd.EntryTemplatesGroup))
{
pd.EntryTemplatesGroup = PwUuid.Zero;
pd.EntryTemplatesGroupChanged = DateTime.Now;
pd.EntryTemplatesGroupChanged = DateTime.UtcNow;
}

pgParent.Groups.Remove(pg);
Expand Down
23 changes: 17 additions & 6 deletions src/Kp2aBusinessLogic/database/edit/OnOperationFinishedHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -124,19 +124,25 @@ protected void DisplayMessage(Context ctx)

public static void DisplayMessage(Context ctx, string message, bool makeDialog)
{
if (!String.IsNullOrEmpty(message))
if (String.IsNullOrEmpty(message))
return;

Kp2aLog.Log("OnOperationFinishedHandler message: " + message);

// Showing a dialog or toast must happen on the UI thread, but finish handlers
// can run on a background (save/load) worker thread. Touching Toast/Dialog there
// throws ("Can't toast on a thread that has not called Looper.prepare()"), so
// marshal the actual display to the main looper.
void ShowUi()
{
Kp2aLog.Log("OnOperationFinishedHandler message: " + message);
if (makeDialog && ctx != null)
{
try
{
MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(ctx);

builder.SetMessage(message)
new MaterialAlertDialogBuilder(ctx)
.SetMessage(message)
.SetPositiveButton(Android.Resource.String.Ok, (sender, args) => ((Dialog)sender).Dismiss())
.Show();

}
catch (Exception)
{
Expand All @@ -146,6 +152,11 @@ public static void DisplayMessage(Context ctx, string message, bool makeDialog)
else
Toast.MakeText(ctx ?? Application.Context, message, ToastLength.Long).Show();
}

if (Looper.MyLooper() == Looper.MainLooper)
ShowUi();
else
new Handler(Looper.MainLooper).Post(ShowUi);
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion src/Kp2aBusinessLogic/database/edit/SetPassword.cs
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ public override void Run()
DateTime previousMasterKeyChanged = pm.MasterKeyChanged;
CompositeKey previousKey = pm.MasterKey;

pm.MasterKeyChanged = DateTime.Now;
pm.MasterKeyChanged = DateTime.UtcNow;
pm.MasterKey = newKey;

// Save Database
Expand Down
27 changes: 27 additions & 0 deletions src/Kp2aS3PathCodec.Tests/Kp2aS3PathCodec.Tests.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>

<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageReference Include="xunit" Version="2.5.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\Kp2aS3PathCodec\Kp2aS3PathCodec.csproj" />
</ItemGroup>

<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>

</Project>
148 changes: 148 additions & 0 deletions src/Kp2aS3PathCodec.Tests/S3PathCodecTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
// This file is part of Keepass2Android, Copyright 2025 Philipp Crocoll.
//
// Keepass2Android is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Keepass2Android is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Keepass2Android. If not, see <http://www.gnu.org/licenses/>.

using System;
using keepass2android.Io;

namespace Kp2aS3PathCodec.Tests
{
public class S3PathCodecTests
{
// ---- settings round-trip (the encode/decode that worried the reviewer) ----

[Theory]
// plain
[InlineData("AKIAEXAMPLE", "simplesecret", S3Provider.Aws, "us-east-1", "")]
// a real-shaped AWS secret: base64 contains '+' and '/' and may end with '='
[InlineData("AKIA/IDX+1", "wJalrXUtnFEMI/K7MDENG+bPxRfiCYz+a/b=", S3Provider.Aws, "eu-west-3", "")]
// values containing the path delimiters ':' '#' '/' must survive
[InlineData("ak:with#weird/chars", "sk:with#weird/chars+and space", S3Provider.Custom, "us-east-1", "https://minio.example.com:9000")]
// empty/awkward values
[InlineData("", "", S3Provider.CloudflareR2, "", "abc123account")]
public void Settings_RoundTrip(string accessKey, string secretKey, S3Provider provider, string region, string endpointOrAccount)
{
string segment = S3PathCodec.SerializeSettings(accessKey, secretKey, provider, region, endpointOrAccount);

S3PathCodec.ParseSettings(segment, out string ak, out string sk, out S3Provider prov, out string reg, out string ep);

Assert.Equal(accessKey, ak);
Assert.Equal(secretKey, sk); // '+' must come back as '+', not as a space
Assert.Equal(provider, prov);
Assert.Equal(region, reg);
Assert.Equal(endpointOrAccount, ep);
}

[Fact]
public void Settings_PlusInSecret_IsNotDecodedToSpace()
{
string segment = S3PathCodec.SerializeSettings("ak", "a+b+c", S3Provider.Aws, "us-east-1", "");
// the literal '+' must have been percent-encoded, not left bare
Assert.Contains("%2B", segment, StringComparison.OrdinalIgnoreCase);
S3PathCodec.ParseSettings(segment, out _, out string sk, out _, out _, out _);
Assert.Equal("a+b+c", sk);
}

[Fact]
public void ParseSettings_MissingMarker_Throws()
{
Assert.Throws<FormatException>(() => S3PathCodec.ParseSettings("not-a-set-segment", out _, out _, out _, out _, out _));
}

// ---- path parsing ----

[Fact]
public void ParsePath_SplitsSettingsBucketAndKey()
{
string settings = S3PathCodec.SerializeSettings("ak", "sk", S3Provider.Aws, "us-east-1", "");
string path = S3PathCodec.BuildPath(settings, "my-bucket", "folder/db.kdbx");

S3PathCodec.ParsePath(path, out string parsedSettings, out string bucket, out string key);

Assert.Equal(settings, parsedSettings);
Assert.Equal("my-bucket", bucket);
Assert.Equal("folder/db.kdbx", key);
}

[Fact]
public void ParsePath_BucketWithoutKey_YieldsEmptyKey()
{
string settings = S3PathCodec.SerializeSettings("ak", "sk", S3Provider.Aws, "us-east-1", "");
S3PathCodec.ParsePath("s3://" + settings + "#only-bucket", out _, out string bucket, out string key);
Assert.Equal("only-bucket", bucket);
Assert.Equal("", key);
}

[Fact]
public void ParsePath_MissingHash_Throws()
{
Assert.Throws<FormatException>(() => S3PathCodec.ParsePath("s3://SETnohashhere", out _, out _, out _));
}

// ---- full round-trip: dialog values -> path -> back ----

[Fact]
public void BuildFullPath_RoundTripsThroughParse()
{
string path = S3PathCodec.BuildFullPath(S3Provider.Custom, "us-east-1",
"https://minio.example.com:9000", "bucket", "AK:1", "S+K/2=", "/leading/slash/db.kdbx");

S3PathCodec.ParsePath(path, out string settings, out string bucket, out string key);
S3PathCodec.ParseSettings(settings, out string ak, out string sk, out S3Provider prov, out string reg, out string ep);

Assert.Equal("bucket", bucket);
Assert.Equal("leading/slash/db.kdbx", key); // BuildFullPath trims a leading '/'
Assert.Equal("AK:1", ak);
Assert.Equal("S+K/2=", sk);
Assert.Equal(S3Provider.Custom, prov);
Assert.Equal("us-east-1", reg);
Assert.Equal("https://minio.example.com:9000", ep);
}

// ---- preview URL per provider / addressing style ----

[Theory]
[InlineData(S3Provider.Aws, "us-west-2", "", "mybucket", "db.kdbx", "https://mybucket.s3.us-west-2.amazonaws.com/db.kdbx")]
[InlineData(S3Provider.Aws, "", "", "mybucket", "db.kdbx", "https://mybucket.s3.amazonaws.com/db.kdbx")]
[InlineData(S3Provider.Wasabi, "eu-central-1", "", "b", "k.kdbx", "https://b.s3.eu-central-1.wasabisys.com/k.kdbx")]
[InlineData(S3Provider.BackblazeB2, "us-west-004", "", "b", "k.kdbx", "https://b.s3.us-west-004.backblazeb2.com/k.kdbx")]
[InlineData(S3Provider.CloudflareR2, "", "acct123", "b", "k.kdbx", "https://acct123.r2.cloudflarestorage.com/b/k.kdbx")]
[InlineData(S3Provider.Custom, "", "https://minio.example.com:9000", "b", "k.kdbx", "https://minio.example.com:9000/b/k.kdbx")]
public void BuildPreviewUrl_MatchesAddressingStyle(S3Provider provider, string region, string endpointOrAccount,
string bucket, string objectKey, string expected)
{
Assert.Equal(expected, S3PathCodec.BuildPreviewUrl(provider, region, endpointOrAccount, bucket, objectKey));
}

[Fact]
public void BuildPreviewUrl_TrimsLeadingSlashOnKey()
{
Assert.Equal("https://b.s3.amazonaws.com/folder/k.kdbx",
S3PathCodec.BuildPreviewUrl(S3Provider.Aws, "", "", "b", "/folder/k.kdbx"));
}

// ---- bucket-in-key detection ----

[Theory]
[InlineData("mybucket", "mybucket/db.kdbx", true)] // repeated bucket prefix
[InlineData("mybucket", "/mybucket/db.kdbx", true)] // leading slash still detected
[InlineData("mybucket", "db.kdbx", false)]
[InlineData("mybucket", "mybucketX/db.kdbx", false)] // prefix but not a path segment
[InlineData("", "anything/db.kdbx", false)] // no bucket -> no warning
public void KeyRepeatsBucket_DetectsDuplication(string bucket, string objectKey, bool expected)
{
Assert.Equal(expected, S3PathCodec.KeyRepeatsBucket(bucket, objectKey));
}
}
}
7 changes: 7 additions & 0 deletions src/Kp2aS3PathCodec/Kp2aS3PathCodec.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>
Loading