Skip to content

Commit 98ffa3a

Browse files
Android: Support payloads > 245 bytes
Plain RSA encryption only supports payloads of 245 bytes. Work around this by generating an AES key, encrypting the AES key with RSA, then store the RSA-encrypted AES key and the AES-encrypted payload. Backward-compatibility is maintained by using a magic prefix for the new format. Fixes #263 Implemented with the help of Github Copilot (Claude Sonnet 4.6)
1 parent 80944df commit 98ffa3a

3 files changed

Lines changed: 241 additions & 33 deletions

File tree

qtkeychain/androidkeystore.cpp

Lines changed: 84 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -271,13 +271,7 @@ bool InputStream::readAll(QByteArray &out, QString *errorString) const
271271
const jclass cls = env->GetObjectClass(obj);
272272
const jmethodID readMethod = env->GetMethodID(cls, "read", "()I");
273273
env->DeleteLocalRef(cls);
274-
if (!readMethod) {
275-
if (env->ExceptionCheck())
276-
env->ExceptionClear();
277-
if (errorString)
278-
*errorString = QStringLiteral("Could not find InputStream.read() method");
279-
return false;
280-
}
274+
Q_ASSERT(readMethod);
281275

282276
out.clear();
283277
while (true) {
@@ -310,11 +304,7 @@ bool OutputStream::close() const
310304
const jclass cls = env->GetObjectClass(object());
311305
const jmethodID closeMethod = env->GetMethodID(cls, "close", "()V");
312306
env->DeleteLocalRef(cls);
313-
if (!closeMethod) {
314-
if (env->ExceptionCheck())
315-
env->ExceptionClear();
316-
return false;
317-
}
307+
Q_ASSERT(closeMethod);
318308
env->CallVoidMethod(object(), closeMethod);
319309
if (env->ExceptionCheck()) {
320310
env->ExceptionClear();
@@ -342,6 +332,88 @@ bool Cipher::init(int opMode, const Key &key) const
342332
return handleExceptions();
343333
}
344334

335+
bool Cipher::init(int opMode, const Key &key,
336+
const java::security::spec::AlgorithmParameterSpec &params) const
337+
{
338+
callMethod<void>("init",
339+
"(ILjava/security/Key;Ljava/security/spec/AlgorithmParameterSpec;)V",
340+
opMode, key.object(), params.object());
341+
return handleExceptions();
342+
}
343+
344+
bool Cipher::doFinal(const QByteArray &input, QByteArray &output, QString *errorString) const
345+
{
346+
QAndroidJniEnvironment env;
347+
const jobject obj = object();
348+
const jclass cls = env->GetObjectClass(obj);
349+
const jmethodID method = env->GetMethodID(cls, "doFinal", "([B)[B");
350+
env->DeleteLocalRef(cls);
351+
Q_ASSERT(method);
352+
const jobject resultObj =
353+
env->CallObjectMethod(obj, method, toArray(input).object());
354+
if (env->ExceptionCheck()) {
355+
const jthrowable exception = env->ExceptionOccurred();
356+
env->ExceptionClear();
357+
if (errorString && exception) {
358+
*errorString = QAndroidJniObject(exception)
359+
.callObjectMethod<jstring>("toString")
360+
.toString();
361+
env->DeleteLocalRef(exception);
362+
}
363+
if (resultObj)
364+
env->DeleteLocalRef(resultObj);
365+
return false;
366+
}
367+
output = fromArray(static_cast<jbyteArray>(resultObj));
368+
env->DeleteLocalRef(resultObj);
369+
return true;
370+
}
371+
372+
SecureRandom::SecureRandom()
373+
: Object(QAndroidJniObject("java/security/SecureRandom"))
374+
{
375+
handleExceptions();
376+
}
377+
378+
bool SecureRandom::nextBytes(QByteArray &bytes) const
379+
{
380+
QAndroidJniEnvironment env;
381+
const jsize size = static_cast<jsize>(bytes.size());
382+
const jbyteArray array = env->NewByteArray(size);
383+
if (!array)
384+
return false;
385+
const jobject obj = object();
386+
const jclass cls = env->GetObjectClass(obj);
387+
const jmethodID method = env->GetMethodID(cls, "nextBytes", "([B)V");
388+
env->DeleteLocalRef(cls);
389+
Q_ASSERT(method);
390+
env->CallVoidMethod(obj, method, array);
391+
if (env->ExceptionCheck()) {
392+
env->ExceptionClear();
393+
env->DeleteLocalRef(array);
394+
return false;
395+
}
396+
bytes = fromArray(array);
397+
env->DeleteLocalRef(array);
398+
return true;
399+
}
400+
401+
SecretKeySpec::SecretKeySpec(const QByteArray &key, const QString &algorithm)
402+
: Key(QAndroidJniObject("javax/crypto/spec/SecretKeySpec",
403+
"([BLjava/lang/String;)V",
404+
toArray(key).object(), fromString(algorithm).object()))
405+
{
406+
handleExceptions();
407+
}
408+
409+
GCMParameterSpec::GCMParameterSpec(int tLen, const QByteArray &iv)
410+
: AlgorithmParameterSpec(QAndroidJniObject("javax/crypto/spec/GCMParameterSpec",
411+
"(I[B)V",
412+
static_cast<jint>(tLen), toArray(iv).object()))
413+
{
414+
handleExceptions();
415+
}
416+
345417
CipherOutputStream::CipherOutputStream(const OutputStream &stream, const Cipher &cipher)
346418
: FilterOutputStream(QAndroidJniObject("javax/crypto/CipherOutputStream",
347419
"(Ljava/io/OutputStream;Ljavax/crypto/Cipher;)V",

qtkeychain/androidkeystore_p.h

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,13 @@ class PublicKey : public Key
200200
PublicKey(const Key &init) : Key(init) { }
201201
};
202202

203+
class SecureRandom : public java::lang::Object
204+
{
205+
public:
206+
SecureRandom();
207+
bool nextBytes(QByteArray &bytes) const;
208+
};
209+
203210
class KeyPair : public java::lang::Object
204211
{
205212
public:
@@ -320,6 +327,22 @@ class KeyPairGeneratorSpec : public java::security::spec::AlgorithmParameterSpec
320327
namespace javax {
321328
namespace crypto {
322329

330+
class SecretKeySpec : public java::security::Key
331+
{
332+
public:
333+
using Key::Key;
334+
335+
explicit SecretKeySpec(const QByteArray &key, const QString &algorithm);
336+
};
337+
338+
class GCMParameterSpec : public java::security::spec::AlgorithmParameterSpec
339+
{
340+
public:
341+
using AlgorithmParameterSpec::AlgorithmParameterSpec;
342+
343+
explicit GCMParameterSpec(int tLen, const QByteArray &iv);
344+
};
345+
323346
class Cipher : public java::lang::Object
324347
{
325348
public:
@@ -330,6 +353,10 @@ class Cipher : public java::lang::Object
330353

331354
static Cipher getInstance(const QString &transformation);
332355
bool init(int opMode, const java::security::Key &key) const;
356+
bool init(int opMode, const java::security::Key &key,
357+
const java::security::spec::AlgorithmParameterSpec &params) const;
358+
bool doFinal(const QByteArray &input, QByteArray &output,
359+
QString *errorString = nullptr) const;
333360
};
334361

335362
class CipherInputStream : public java::io::FilterInputStream

qtkeychain/keychain_android.cpp

Lines changed: 130 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ using android::content::Context;
2222
using android::security::KeyPairGeneratorSpec;
2323

2424
using java::io::ByteArrayInputStream;
25-
using java::io::ByteArrayOutputStream;
25+
using java::security::SecureRandom;
2626
using java::security::KeyPair;
2727
using java::security::KeyPairGenerator;
2828
using java::security::KeyStore;
@@ -32,7 +32,8 @@ using java::util::Calendar;
3232

3333
using javax::crypto::Cipher;
3434
using javax::crypto::CipherInputStream;
35-
using javax::crypto::CipherOutputStream;
35+
using javax::crypto::GCMParameterSpec;
36+
using javax::crypto::SecretKeySpec;
3637
using javax::security::auth::x500::X500Principal;
3738

3839
namespace {
@@ -42,6 +43,10 @@ inline QString makeAlias(const QString &service, const QString &key)
4243
return service + QLatin1Char('/') + key;
4344
}
4445

46+
// Magic prefix identifying the hybrid RSA+AES-GCM format (v2).
47+
// Legacy entries have no prefix and are raw RSA ciphertext.
48+
const QByteArray kHybridMagic("QKCA", 4);
49+
4550
} // namespace
4651

4752
void ReadPasswordJobPrivate::scheduledStart()
@@ -70,20 +75,78 @@ void ReadPasswordJobPrivate::scheduledStart()
7075
return;
7176
}
7277

73-
const auto cipher = Cipher::getInstance(QStringLiteral("RSA/ECB/PKCS1Padding"));
78+
QByteArray plainData;
7479

75-
if (!cipher || !cipher.init(Cipher::DECRYPT_MODE, entry.getPrivateKey())) {
76-
q->emitFinishedWithError(Error::OtherError, tr("Could not create decryption cipher"));
77-
return;
78-
}
80+
if (encryptedData.startsWith(kHybridMagic)) {
81+
// Hybrid format: kHybridMagic(4) + encKeyLen(4 BE) + RSA(AESkey) + IV(12) + AES-GCM ciphertext
82+
const int minSize = kHybridMagic.size() + 4 + 1 + 12 + 16;
83+
if (encryptedData.size() < minSize) {
84+
q->emitFinishedWithError(Error::OtherError, tr("Encrypted data is too short"));
85+
return;
86+
}
7987

80-
QByteArray plainData;
81-
const CipherInputStream inputStream(ByteArrayInputStream(encryptedData), cipher);
88+
const int lenOffset = kHybridMagic.size();
89+
const quint32 encKeyLen =
90+
(static_cast<quint32>(static_cast<unsigned char>(encryptedData[lenOffset])) << 24)
91+
| (static_cast<quint32>(static_cast<unsigned char>(encryptedData[lenOffset + 1])) << 16)
92+
| (static_cast<quint32>(static_cast<unsigned char>(encryptedData[lenOffset + 2])) << 8)
93+
| (static_cast<quint32>(static_cast<unsigned char>(encryptedData[lenOffset + 3])));
8294

83-
QString readError;
84-
if (!inputStream.readAll(plainData, &readError)) {
85-
q->emitFinishedWithError(Error::OtherError, tr("Could not decrypt data: %1").arg(readError));
86-
return;
95+
const int dataOffset = lenOffset + 4;
96+
if (encryptedData.size() < dataOffset + (int)encKeyLen + 12 + 16) {
97+
q->emitFinishedWithError(Error::OtherError, tr("Encrypted data is too short"));
98+
return;
99+
}
100+
101+
const QByteArray encryptedKey = encryptedData.mid(dataOffset, encKeyLen);
102+
const QByteArray iv = encryptedData.mid(dataOffset + encKeyLen, 12);
103+
const QByteArray encryptedPayload = encryptedData.mid(dataOffset + encKeyLen + 12);
104+
105+
// Decrypt the AES key with RSA
106+
const auto rsaCipher = Cipher::getInstance(QStringLiteral("RSA/ECB/PKCS1Padding"));
107+
if (!rsaCipher || !rsaCipher.init(Cipher::DECRYPT_MODE, entry.getPrivateKey())) {
108+
q->emitFinishedWithError(Error::OtherError, tr("Could not create RSA decryption cipher"));
109+
return;
110+
}
111+
112+
QByteArray aesKeyBytes;
113+
QString decryptError;
114+
if (!rsaCipher.doFinal(encryptedKey, aesKeyBytes, &decryptError)) {
115+
q->emitFinishedWithError(Error::OtherError,
116+
tr("Could not decrypt AES key: %1").arg(decryptError));
117+
return;
118+
}
119+
120+
// Decrypt the payload with AES-GCM
121+
const SecretKeySpec aesKey(aesKeyBytes, QStringLiteral("AES"));
122+
const GCMParameterSpec gcmSpec(128, iv);
123+
const auto aesCipher = Cipher::getInstance(QStringLiteral("AES/GCM/NoPadding"));
124+
if (!aesCipher || !aesCipher.init(Cipher::DECRYPT_MODE, aesKey, gcmSpec)) {
125+
q->emitFinishedWithError(Error::OtherError,
126+
tr("Could not create AES decryption cipher"));
127+
return;
128+
}
129+
130+
if (!aesCipher.doFinal(encryptedPayload, plainData, &decryptError)) {
131+
q->emitFinishedWithError(Error::OtherError,
132+
tr("Could not decrypt data: %1").arg(decryptError));
133+
return;
134+
}
135+
} else {
136+
// Legacy format: raw RSA-encrypted blob (only works for data <= ~245 bytes)
137+
const auto cipher = Cipher::getInstance(QStringLiteral("RSA/ECB/PKCS1Padding"));
138+
if (!cipher || !cipher.init(Cipher::DECRYPT_MODE, entry.getPrivateKey())) {
139+
q->emitFinishedWithError(Error::OtherError, tr("Could not create decryption cipher"));
140+
return;
141+
}
142+
143+
const CipherInputStream inputStream(ByteArrayInputStream(encryptedData), cipher);
144+
QString readError;
145+
if (!inputStream.readAll(plainData, &readError)) {
146+
q->emitFinishedWithError(Error::OtherError,
147+
tr("Could not decrypt data: %1").arg(readError));
148+
return;
149+
}
87150
}
88151

89152
mode = plainTextStore.readMode(q->key());
@@ -157,23 +220,69 @@ void WritePasswordJobPrivate::scheduledStart()
157220
}
158221

159222
const RSAPublicKey publicKey = entry.getCertificate().getPublicKey();
160-
const auto cipher = Cipher::getInstance(QStringLiteral("RSA/ECB/PKCS1Padding"));
161223

162-
if (!cipher || !cipher.init(Cipher::ENCRYPT_MODE, publicKey)) {
163-
q->emitFinishedWithError(Error::OtherError, tr("Could not create encryption cipher"));
224+
// Generate a random AES-256 key
225+
QByteArray aesKeyBytes(32, '\0');
226+
SecureRandom secureRandom;
227+
if (!secureRandom || !secureRandom.nextBytes(aesKeyBytes)) {
228+
q->emitFinishedWithError(Error::OtherError, tr("Could not generate AES key"));
164229
return;
165230
}
166231

167-
ByteArrayOutputStream outputStream;
168-
CipherOutputStream cipherOutputStream(outputStream, cipher);
232+
// Generate a random 12-byte IV for AES-GCM
233+
QByteArray iv(12, '\0');
234+
if (!secureRandom.nextBytes(iv)) {
235+
q->emitFinishedWithError(Error::OtherError, tr("Could not generate IV"));
236+
return;
237+
}
169238

170-
if (!cipherOutputStream.write(data) || !cipherOutputStream.close()) {
171-
q->emitFinishedWithError(Error::OtherError, tr("Could not encrypt data"));
239+
// Encrypt the payload with AES/GCM/NoPadding
240+
const SecretKeySpec aesKey(aesKeyBytes, QStringLiteral("AES"));
241+
const GCMParameterSpec gcmSpec(128, iv);
242+
const auto aesCipher = Cipher::getInstance(QStringLiteral("AES/GCM/NoPadding"));
243+
if (!aesCipher || !aesCipher.init(Cipher::ENCRYPT_MODE, aesKey, gcmSpec)) {
244+
q->emitFinishedWithError(Error::OtherError, tr("Could not create AES encryption cipher"));
172245
return;
173246
}
174247

248+
QByteArray encryptedPayload;
249+
QString encryptError;
250+
if (!aesCipher.doFinal(data, encryptedPayload, &encryptError)) {
251+
q->emitFinishedWithError(Error::OtherError,
252+
tr("Could not encrypt data: %1").arg(encryptError));
253+
return;
254+
}
255+
256+
// Encrypt the AES key with RSA (32 bytes always fits within RSA-2048 limit)
257+
const auto rsaCipher = Cipher::getInstance(QStringLiteral("RSA/ECB/PKCS1Padding"));
258+
if (!rsaCipher || !rsaCipher.init(Cipher::ENCRYPT_MODE, publicKey)) {
259+
q->emitFinishedWithError(Error::OtherError, tr("Could not create RSA encryption cipher"));
260+
return;
261+
}
262+
263+
QByteArray encryptedKey;
264+
if (!rsaCipher.doFinal(aesKeyBytes, encryptedKey, &encryptError)) {
265+
q->emitFinishedWithError(Error::OtherError,
266+
tr("Could not encrypt AES key: %1").arg(encryptError));
267+
return;
268+
}
269+
270+
// Assemble blob: kHybridMagic(4) + encKeyLen(4 BE) + encryptedKey + iv(12) + encryptedPayload
271+
const quint32 encKeyLen = static_cast<quint32>(encryptedKey.size());
272+
QByteArray blob;
273+
blob.reserve(kHybridMagic.size() + 4 + encryptedKey.size() + iv.size()
274+
+ encryptedPayload.size());
275+
blob += kHybridMagic;
276+
blob += static_cast<char>((encKeyLen >> 24) & 0xFF);
277+
blob += static_cast<char>((encKeyLen >> 16) & 0xFF);
278+
blob += static_cast<char>((encKeyLen >> 8) & 0xFF);
279+
blob += static_cast<char>(encKeyLen & 0xFF);
280+
blob += encryptedKey;
281+
blob += iv;
282+
blob += encryptedPayload;
283+
175284
PlainTextStore plainTextStore(q->service(), q->settings());
176-
plainTextStore.write(q->key(), outputStream.toByteArray(), mode);
285+
plainTextStore.write(q->key(), blob, mode);
177286

178287
if (plainTextStore.error() != NoError)
179288
q->emitFinishedWithError(plainTextStore.error(), plainTextStore.errorString());

0 commit comments

Comments
 (0)