Skip to content

Commit 5e8893d

Browse files
Implement KeeShare support for group synchronization
Add receiving-end support for KeeShare to enable secure sharing of password groups between databases. This feature allows automatic synchronization of shared groups when the database is opened. Key features: - Parse KeeShare reference metadata from group CustomData - Handle both raw .kdbx files and .share container format (zip) - RSA signature verification using SHA-256 for secure imports - Automatic merge using KeePassLib's native Synchronize method - Integration hook in Database.LoadData for seamless operation The implementation follows KeePassXC's KeeShare specification and provides the import functionality requested by users for sharing passwords across devices and users. Fixes #1161
1 parent 0ba4cec commit 5e8893d

10 files changed

Lines changed: 1292 additions & 0 deletions

src/Kp2aBusinessLogic/KeeShare/KeeShareImporter.cs

Lines changed: 579 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
using System;
2+
using System.IO;
3+
using System.Text;
4+
using System.Xml;
5+
using System.Xml.Linq;
6+
using KeePassLib;
7+
8+
namespace keepass2android.KeeShare
9+
{
10+
public class KeeShareSettings
11+
{
12+
public const string KeeShareReferenceKey = "KeeShare/Reference";
13+
14+
[Flags]
15+
public enum TypeFlag
16+
{
17+
Inactive = 0,
18+
ImportFrom = 1 << 0,
19+
ExportTo = 1 << 1,
20+
SynchronizeWith = ImportFrom | ExportTo
21+
}
22+
23+
public class Reference
24+
{
25+
public TypeFlag Type { get; set; } = TypeFlag.Inactive;
26+
public PwUuid Uuid { get; set; }
27+
public string Path { get; set; }
28+
public string Password { get; set; }
29+
public bool KeepGroups { get; set; } = true;
30+
31+
public bool IsImporting => (Type & TypeFlag.ImportFrom) == TypeFlag.ImportFrom && !string.IsNullOrEmpty(Path);
32+
}
33+
34+
public static Reference GetReference(PwGroup group)
35+
{
36+
if (group == null || group.CustomData == null) return null;
37+
38+
var encoded = group.CustomData.Get(KeeShareReferenceKey);
39+
if (string.IsNullOrEmpty(encoded)) return null;
40+
41+
try
42+
{
43+
var bytes = Convert.FromBase64String(encoded);
44+
var xml = Encoding.UTF8.GetString(bytes);
45+
return ParseReference(xml);
46+
}
47+
catch (Exception ex)
48+
{
49+
Kp2aLog.Log("KeeShare: Failed to parse reference: " + ex.Message);
50+
return null;
51+
}
52+
}
53+
54+
private static Reference ParseReference(string xml)
55+
{
56+
var refObj = new Reference();
57+
// Wrap in a root element if missing, but the C++ code says it writes <KeeShare> root.
58+
// "writer.writeStartElement("KeeShare"); specific(writer);"
59+
60+
try
61+
{
62+
var doc = XDocument.Parse(xml);
63+
var root = doc.Root; // KeeShare
64+
if (root == null || root.Name != "KeeShare") return null;
65+
66+
var typeElem = root.Element("Type");
67+
if (typeElem != null)
68+
{
69+
if (typeElem.Element("Import") != null) refObj.Type |= TypeFlag.ImportFrom;
70+
if (typeElem.Element("Export") != null) refObj.Type |= TypeFlag.ExportTo;
71+
}
72+
73+
var groupElem = root.Element("Group");
74+
if (groupElem != null)
75+
{
76+
var uuidBytes = Convert.FromBase64String(groupElem.Value);
77+
refObj.Uuid = new PwUuid(uuidBytes);
78+
}
79+
80+
var pathElem = root.Element("Path");
81+
if (pathElem != null)
82+
{
83+
refObj.Path = Encoding.UTF8.GetString(Convert.FromBase64String(pathElem.Value));
84+
}
85+
86+
var passElem = root.Element("Password");
87+
if (passElem != null)
88+
{
89+
refObj.Password = Encoding.UTF8.GetString(Convert.FromBase64String(passElem.Value));
90+
}
91+
92+
var keepGroupsElem = root.Element("KeepGroups");
93+
if (keepGroupsElem != null)
94+
{
95+
refObj.KeepGroups = string.Equals(keepGroupsElem.Value, "True", StringComparison.OrdinalIgnoreCase);
96+
}
97+
}
98+
catch (Exception ex)
99+
{
100+
System.Diagnostics.Debug.WriteLine("KeeShare: Failed to parse reference XML: " + ex.Message);
101+
return null;
102+
}
103+
104+
return refObj;
105+
}
106+
}
107+
}
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
using System;
2+
using System.IO;
3+
using System.Security.Cryptography;
4+
using System.Text;
5+
using System.Xml.Linq;
6+
7+
namespace keepass2android.KeeShare
8+
{
9+
public class KeeShareSignature
10+
{
11+
public string Signature { get; set; }
12+
public string Signer { get; set; }
13+
public RSAParameters? Key { get; set; }
14+
15+
public static KeeShareSignature Parse(string xml)
16+
{
17+
var sigObj = new KeeShareSignature();
18+
try
19+
{
20+
var doc = XDocument.Parse(xml);
21+
var root = doc.Root; // KeeShare
22+
if (root == null || root.Name != "KeeShare") return null;
23+
24+
var sigElem = root.Element("Signature");
25+
if (sigElem != null)
26+
{
27+
sigObj.Signature = sigElem.Value;
28+
}
29+
30+
var certElem = root.Element("Certificate");
31+
if (certElem != null)
32+
{
33+
var signerElem = certElem.Element("Signer");
34+
if (signerElem != null)
35+
{
36+
sigObj.Signer = signerElem.Value;
37+
}
38+
39+
var keyElem = certElem.Element("Key");
40+
if (keyElem != null)
41+
{
42+
var keyBase64 = keyElem.Value;
43+
var keyBytes = Convert.FromBase64String(keyBase64);
44+
sigObj.Key = SshRsaKeyParser.Parse(keyBytes);
45+
}
46+
}
47+
}
48+
catch (Exception ex)
49+
{
50+
System.Diagnostics.Debug.WriteLine("KeeShare: Failed to parse signature: " + ex.Message);
51+
return null;
52+
}
53+
return sigObj;
54+
}
55+
}
56+
57+
public static class SshRsaKeyParser
58+
{
59+
public static RSAParameters? Parse(byte[] data)
60+
{
61+
using (var ms = new MemoryStream(data))
62+
using (var reader = new BinaryReader(ms))
63+
{
64+
// Format is [len][ssh-rsa][len][e][len][n]
65+
// Lengths are big-endian uint32.
66+
67+
try
68+
{
69+
var type = ReadString(reader);
70+
if (Encoding.UTF8.GetString(type) != "ssh-rsa")
71+
return null;
72+
73+
var e = ReadMpValue(reader);
74+
var n = ReadMpValue(reader);
75+
76+
return new RSAParameters
77+
{
78+
Exponent = e,
79+
Modulus = n
80+
};
81+
}
82+
catch (Exception ex)
83+
{
84+
System.Diagnostics.Debug.WriteLine("KeeShare: Failed to parse SSH key: " + ex.Message);
85+
return null;
86+
}
87+
}
88+
}
89+
90+
private static byte[] ReadString(BinaryReader reader)
91+
{
92+
var lenBytes = reader.ReadBytes(4);
93+
if (lenBytes.Length < 4) throw new EndOfStreamException();
94+
if (BitConverter.IsLittleEndian) Array.Reverse(lenBytes);
95+
uint len = BitConverter.ToUInt32(lenBytes, 0);
96+
97+
var data = reader.ReadBytes((int)len);
98+
if (data.Length < len) throw new EndOfStreamException();
99+
return data;
100+
}
101+
102+
private static byte[] ReadMpValue(BinaryReader reader)
103+
{
104+
// mpint is also length prefixed.
105+
// But sometimes it has a leading zero byte for sign which we might need to strip for RSAParameters?
106+
// "mpints are represented as a string with the value... The most significant bit of the first byte of data MUST be zero if the number is positive"
107+
// RSAParameters expects unsigned big-endian.
108+
109+
// Wait, QDataStream writeBytes writes [len][data].
110+
// The C++ code:
111+
// rsaKey->get_e().binary_encode(rsaE.data());
112+
// stream.writeBytes(..., rsaE.size());
113+
114+
// Botan's binary_encode writes raw big-endian bytes.
115+
// So this is NOT mpint (which is SSH format), but just raw bytes prefixed by length.
116+
// However, SSH keys often use mpint.
117+
// Let's re-read the C++ code carefully.
118+
119+
/*
120+
QDataStream stream(&rsaKeySerialized, QIODevice::WriteOnly);
121+
stream.writeBytes("ssh-rsa", 7);
122+
stream.writeBytes(reinterpret_cast<const char*>(rsaE.data()), rsaE.size());
123+
stream.writeBytes(reinterpret_cast<const char*>(rsaN.data()), rsaN.size());
124+
*/
125+
126+
// QDataStream::writeBytes writes quint32 len + bytes.
127+
// So it IS [len][data].
128+
// And rsaE/rsaN are raw bytes from BigInt.
129+
130+
return ReadString(reader);
131+
}
132+
}
133+
}

0 commit comments

Comments
 (0)