Skip to content

Comments

Desktop,Mobile,Cli: Support accepting shares with a new key format#12829

Merged
laurent22 merged 46 commits intolaurent22:devfrom
personalizedrefrigerator:pr/all/refactor-rsa-logic
Sep 8, 2025
Merged

Desktop,Mobile,Cli: Support accepting shares with a new key format#12829
laurent22 merged 46 commits intolaurent22:devfrom
personalizedrefrigerator:pr/all/refactor-rsa-logic

Conversation

@personalizedrefrigerator
Copy link
Collaborator

@personalizedrefrigerator personalizedrefrigerator commented Jul 31, 2025

Summary

This pull request:

  • Refactors the E2EE logic to support:
    • Interacting with encrypted shares that use different key formats.
    • Migrating the ppk field in info.json to a new key format (RSA-OAEP-4096). Note: This migration logic has automated tests, but is disabled.

New key formats (currently disabled)

This pull request allows sharing folders with accounts that use RSA-OAEP for public key encryption. Logic is also added to migrate existing users to this new key format, but is currently disabled (requires a migration step — see note above).

OWASP recommends using elliptical curve cryptography (ECC), where possible, over RSA for asymmetric encryption. However, Joplin’s current mobile cryptographic dependencies (e.g. react-native-quick-crypto) support RSA, but don't seem to support ECC for data encryption/decryption. OWASP also states that “if ECC is not available and RSA must be used, then ensure that the key is at least 2048 bits.”

The parameters for RSA-OAEP can be found in WebCryptoRsa.ts:

  • Key size: Either 4096 bits or 2048 bits
    • .RsaV2: 2048
    • .RsaV3: 4096
  • Hash: SHA-256
  • Public exponent: 65537 ([1, 0, 1]).

These parameters are based on the RSA DOM example and parameters suggested in the MDN web documentation, which recommend a key size of either at least 2048 or 4096 bits, a public exponent of 65537, and a hash of one of sha-256, sha-384, or sha-512.

Key size notes:

  • A key size of 2048 results in a plaintext size of 190 bytes1. Joplin's master keys are 512 bytes hex-encoded strings and 256 bytes when decoded. As such, when the encryption method is .RsaV2, this pull request breaks the input data into blocks and encrypts/decrypts each block individually. This is the same as the approach taken by NodeRSA's long data handler. Unlike NodeRSA, this pull request limits the maximum number of blocks to two. See comments at the top of LongDataWrapper.ts for possible concerns with this approach.
  • A key size of 4096 is large enough to encrypt Joplin master keys in a single block.

This part of the pull request should be reviewed very carefully before being globally enabled (which would be done in a separate pull request).

Testing plan

  1. Start Joplin Server
  2. Create three Joplin Server accounts (or re-use three existing accounts): self@localhost, self2@localhost, and self3@localhost.
  3. Connect to Joplin Server from the desktop app, enable encryption on the accounts if not done so already.
  4. Share a notebook from self@localhost to self2@localhost.
  5. Open self2@localhost and verify that a share invitation banner is visible, then quit Joplin.
  6. Enable the RSAv2 migration and recompile.
  7. Restart the desktop app.
  8. Verify that the migration completed.
  9. Accept the share.
  10. Verify that the share accepts successfully.
    • Note: When first following the testing plan, an error dialog was shown, since the invitation was created with the user’s old public key. As a workaround, the migration logic was updated to cache the old public key as a fallback when decrypting existing share invitations. After making this change, the share was accepted successfully.
  11. Re-disable the PPK migration and recompile.
  12. Switch to self3@localhost (in a different profile), sign in to Joplin Server if not done so already.
  13. Switch back to self2@localhost, share a folder with self3@localhost and sync.
  14. Switch to self1@localhost, share a folder with self3@localhost and sync.
  15. Switch back to self3@localhost, accept the share from self2@localhost.
  16. Re-enable the RSAv2 migration and recompile.
  17. Restart the desktop app.
  18. Accept the share from self1
  19. Sync and verify that both shares were successfully accepted.

It has been verified that the integration tests (see ppkTestUtils.ts) pass on Android 13 and on web (in Chrome).

Footnotes

  1. https://crypto.stackexchange.com/a/42100

@personalizedrefrigerator personalizedrefrigerator changed the title Desktop,Mobile,Cli: Sharing: Add support for accepting shares that use a new key format Chore: Desktop,Mobile,Cli: Support accepting shares with a new key format Jul 31, 2025
See inline comments -- this should improve UX during migration if:
1. Some sharer shares a notebook with client A, targeting client A's
   PPK.
2. Client A's PPK is migrated.
3. Client A tries to accept the share.
Copy link
Owner

@laurent22 laurent22 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks Henry, that looks good I think. I've left a few comments so please take a look

generateKeyPair: async () => {
const keys = new NodeRSA();
keys.setOptions(legacyRSAOptions);
const keySize = 2048;
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any reason not to choose a large key size to make it more future proof? I don't assume that would accept performance much?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note that this is in the legacyRsa object — it should match the behavior of the old RSA logic. Previously, generateKeyPair took a keySize that was always 2048.

The old generateKeyPair looked like this:

	generateKeyPair: async (keySize: number): Promise<RSAKeyPair> => {
		const keys = new NodeRSA();
		keys.setOptions(nodeRSAOptions);
		keys.generateKeyPair(keySize, 65537);

		// Sanity check
		if (!keys.isPrivate()) throw new Error('No private key was generated');
		if (!keys.isPublic()) throw new Error('No public key was generated');

		return keys;
	},

The keySize was inlined into generateKeyPair to allow the new RSA logic to use a keySize of 4096 (while keeping the legacy behavior the same).

return text.match(/^[a-f0-9]+$/) && text.length % 2 === 0;
};

const modulusLengthBits = 4096;
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have you checked the performance of RSA 4096 on mobile? As I understand it can be very slow? And it looks like RSA 2048 is still considered ok till 2030: https://community.rsa.com/s/article/RSA-cryptography-and-NIST-guidance

But it would be good to use 4096 already, provided it doesn't massively impact mobile performance (in my case the app already freezes on startup for 3 seconds, maybe because of encryption/decryption, so I wouldn't want that to becomes 10 seconds+)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for pointing this out! That would certainly be good to check.

Regarding using a key size of 2048,

  • 2048 results in a plaintext size smaller than Joplin's master keys. According to https://crypto.stackexchange.com/a/42100, a key size of 2048 results in a plaintext size of 190 bytes. Joplin's master keys are 512 bytes hex-encoded, 256 bytes when decoded.
    • Note: This was also an issue with the old RSA padding format, at least on mobile. Previously, NodeRSA handled the larger input by breaking the input into chunks (in a way that seems to match ECB mode), and encrypting each chunk individually. On mobile, we previously had support for decrypting input data provided in this format, but not encrypting. As a result, the mobile app could not encrypt Joplin master keys (and thus could not create encrypted shares).

Copy link
Collaborator Author

@personalizedrefrigerator personalizedrefrigerator Aug 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

provided it doesn't massively impact mobile performance

I've added performance logging to ppkTestUtils:

  • On an Android emulator, with a key size of 4096, the loadKeys/encrypt/decrypt operations seem to be reasonably fast (<30ms each).

I plan to test on a physical device later today.

Update:

On a physical device, the previously-tested operations have similar performance.

Performance markers from an Android 13 device, with Joplin in development mode:

:38:27: Performance: ppkTestUtils/loadKeys/shortData--legacy: Start at 7.7s
Logger.js:261 12:38:27: Performance: ppkTestUtils/loadKeys/shortData--legacy: End at 7.7s (took 1.92ms)
Logger.js:261 12:38:27: Performance: ppkTestUtils/decrypt.0/shortData--legacy: Start at 7.7s
Logger.js:261 12:38:27: Performance: ppkTestUtils/decrypt.0/shortData--legacy: End at 7.77s (took 67.48ms)
Logger.js:261 12:38:27: Performance: ppkTestUtils/encrypt/shortData--legacy: Start at 7.77s
Logger.js:261 12:38:27: Performance: ppkTestUtils/encrypt/shortData--legacy: End at 7.79s (took 18.61ms)
Logger.js:261 12:38:27: Performance: ppkTestUtils/decrypt.1/shortData--legacy: Start at 7.79s
Logger.js:261 12:38:28: Performance: ppkTestUtils/decrypt.1/shortData--legacy: End at 7.82s (took 24.11ms)
Logger.js:261 12:38:28: Performance: ppkTestUtils/loadKeys/longData--legacy: Start at 7.82s
Logger.js:261 12:38:28: Performance: ppkTestUtils/loadKeys/longData--legacy: End at 7.82s (took 1.1ms)
Logger.js:261 12:38:28: Performance: ppkTestUtils/decrypt.0/longData--legacy: Start at 7.82s
Logger.js:261 12:38:28: Performance: ppkTestUtils/decrypt.0/longData--legacy: End at 7.91s (took 88.55ms)
Logger.js:261 12:38:28: Performance: ppkTestUtils/encrypt/longData--legacy: Start at 7.91s
Logger.js:261 12:38:28: Performance: ppkTestUtils/encrypt/longData--legacy: End at 7.92s (took 5.55ms)
Logger.js:261 12:38:28: Performance: ppkTestUtils/decrypt.1/longData--legacy: Start at 7.92s
Logger.js:261 12:38:28: Performance: ppkTestUtils/decrypt.1/longData--legacy: End at 7.94s (took 26.69ms)
Logger.js:261 12:38:28: Performance: ppkTestUtils/loadKeys/blockAlignedData--legacy: Start at 7.95s
Logger.js:261 12:38:28: Performance: ppkTestUtils/loadKeys/blockAlignedData--legacy: End at 7.95s (took 1.04ms)
Logger.js:261 12:38:28: Performance: ppkTestUtils/decrypt.0/blockAlignedData--legacy: Start at 7.96s
Logger.js:261 12:38:28: Performance: ppkTestUtils/decrypt.0/blockAlignedData--legacy: End at 8.02s (took 65.98ms)
Logger.js:261 12:38:28: Performance: ppkTestUtils/encrypt/blockAlignedData--legacy: Start at 8.02s
Logger.js:261 12:38:28: Performance: ppkTestUtils/encrypt/blockAlignedData--legacy: End at 8.03s (took 6.73ms)
Logger.js:261 12:38:28: Performance: ppkTestUtils/decrypt.1/blockAlignedData--legacy: Start at 8.03s
Logger.js:261 12:38:28: Performance: ppkTestUtils/decrypt.1/blockAlignedData--legacy: End at 8.06s (took 24.14ms)
Logger.js:261 12:38:28: Performance: ppkTestUtils/loadKeys/shortKey--legacy: Start at 8.06s
Logger.js:261 12:38:28: Performance: ppkTestUtils/loadKeys/shortKey--legacy: End at 8.06s (took 1.05ms)
Logger.js:261 12:38:28: Performance: ppkTestUtils/decrypt.0/shortKey--legacy: Start at 8.06s
Logger.js:261 12:38:28: Performance: ppkTestUtils/decrypt.0/shortKey--legacy: End at 8.07s (took 8.21ms)
Logger.js:261 12:38:28: Performance: ppkTestUtils/encrypt/shortKey--legacy: Start at 8.07s
Logger.js:261 12:38:28: Performance: ppkTestUtils/encrypt/shortKey--legacy: End at 8.07s (took 5.78ms)
Logger.js:261 12:38:28: Performance: ppkTestUtils/decrypt.1/shortKey--legacy: Start at 8.08s
Logger.js:261 12:38:28: Performance: ppkTestUtils/decrypt.1/shortKey--legacy: End at 8.08s (took 8.44ms)
Logger.js:261 12:38:28: Performance: ppkTestUtils/loadKeys/legacyData--legacy: Start at 8.09s
Logger.js:261 12:38:28: Performance: ppkTestUtils/loadKeys/legacyData--legacy: End at 8.09s (took 1ms)
Logger.js:261 12:38:28: Performance: ppkTestUtils/decrypt.0/legacyData--legacy: Start at 8.09s
Logger.js:261 12:38:28: Performance: ppkTestUtils/decrypt.0/legacyData--legacy: End at 8.11s (took 25.31ms)
Logger.js:261 12:38:28: Performance: ppkTestUtils/encrypt/legacyData--legacy: Start at 8.12s
Logger.js:261 12:38:28: Performance: ppkTestUtils/encrypt/legacyData--legacy: End at 8.12s (took 6.15ms)
Logger.js:261 12:38:28: Performance: ppkTestUtils/decrypt.1/legacyData--legacy: Start at 8.12s
Logger.js:261 12:38:28: Performance: ppkTestUtils/decrypt.1/legacyData--legacy: End at 8.15s (took 23.17ms)
Logger.js:261 12:38:28: Performance: ppkTestUtils/loadKeys/v2Data--rsa-v2: Start at 8.15s
Logger.js:261 12:38:28: Performance: ppkTestUtils/loadKeys/v2Data--rsa-v2: End at 8.16s (took 14.11ms)
Logger.js:261 12:38:28: Performance: ppkTestUtils/decrypt.0/v2Data--rsa-v2: Start at 8.17s
Logger.js:261 12:38:28: Performance: ppkTestUtils/decrypt.0/v2Data--rsa-v2: End at 8.21s (took 47.62ms)
Logger.js:261 12:38:28: Performance: ppkTestUtils/encrypt/v2Data--rsa-v2: Start at 8.21s
Logger.js:261 12:38:28: Performance: ppkTestUtils/encrypt/v2Data--rsa-v2: End at 8.22s (took 6.1ms)
Logger.js:261 12:38:28: Performance: ppkTestUtils/decrypt.1/v2Data--rsa-v2: Start at 8.22s
Logger.js:261 12:38:28: Performance: ppkTestUtils/decrypt.1/v2Data--rsa-v2: End at 8.25s (took 29.57ms)
Logger.js:261 12:38:28: Performance: ppkTestUtils/loadKeys/v2DataWebHexadecimal--rsa-v2: Start at 8.25s
Logger.js:261 12:38:28: Performance: ppkTestUtils/loadKeys/v2DataWebHexadecimal--rsa-v2: End at 8.26s (took 1.67ms)
Logger.js:261 12:38:28: Performance: ppkTestUtils/decrypt.0/v2DataWebHexadecimal--rsa-v2: Start at 8.26s
Logger.js:261 12:38:28: Performance: ppkTestUtils/decrypt.0/v2DataWebHexadecimal--rsa-v2: End at 8.29s (took 34.74ms)
Logger.js:261 12:38:28: Performance: ppkTestUtils/encrypt/v2DataWebHexadecimal--rsa-v2: Start at 8.29s
Logger.js:261 12:38:28: Performance: ppkTestUtils/encrypt/v2DataWebHexadecimal--rsa-v2: End at 8.3s (took 2.26ms)
Logger.js:261 12:38:28: Performance: ppkTestUtils/decrypt.1/v2DataWebHexadecimal--rsa-v2: Start at 8.3s
Logger.js:261 12:38:28: Performance: ppkTestUtils/decrypt.1/v2DataWebHexadecimal--rsa-v2: End at 8.33s (took 29.65ms)
Logger.js:261 12:38:33: Performance: ppkTestUtils/loadKeys/local data--rsa-v2: Start at 13.22s
Logger.js:261 12:38:33: Performance: ppkTestUtils/loadKeys/local data--rsa-v2: End at 13.22s (took 2.43ms)
Logger.js:261 12:38:33: Performance: ppkTestUtils/decrypt.0/local data--rsa-v2: Start at 13.22s
Logger.js:261 12:38:33: Performance: ppkTestUtils/decrypt.0/local data--rsa-v2: End at 13.25s (took 33.58ms)
Logger.js:261 12:38:33: Performance: ppkTestUtils/encrypt/local data--rsa-v2: Start at 13.25s
Logger.js:261 12:38:33: Performance: ppkTestUtils/encrypt/local data--rsa-v2: End at 13.26s (took 2.14ms)
Logger.js:261 12:38:33: Performance: ppkTestUtils/decrypt.1/local data--rsa-v2: Start at 13.26s
Logger.js:261 12:38:33: Performance: ppkTestUtils/decrypt.1/local data--rsa-v2: End at 13.29s (took 29.41ms)

However, at least in development mode, the generateKey operation (which runs during sync, when syncInfo.ppk is unset and when resetting the master key password) is very slow (trials: 12.69s, 3.46s, 8.16s, 7.11s).

Update 2:

generateKey also seems to be slow in release mode on the same Android device (trials: 3.9s, 2.66s, 4.15s, 7.26s, 4.66s, 4.45, 8.64, 9.86).

private textToBuffer_(textUtf8: string) {
// To avoid data loss when restoring (whether everything is capital or lowercase), work only
// with lowercase hexadecimal.
const isHex = isLowercaseHexadecimalString(textUtf8);
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure I understand the logic here - is that for compatibility with the previous version? Which means some data is hex and some is not?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Currently, Joplin seems to only provide non-hex-encoded data during tests (assuming that Joplin master keys are always hex-encoded).

An alternative to this might be to:

  • throw when provided non-hex-encoded data, both here and in the legacy RSA implementation. This would help ensure that the input data remains hexadecimal encoded in the future.
  • Add tests and/or runtime assertions elsewhere to help ensure that Joplin master keys remain hex-encoded in the future (even if the legacy RSA implementation is still in-use).
    • Without hexadecimal decoding, Joplin master keys are larger than the maximum plaintext size (see notes above).

@personalizedrefrigerator personalizedrefrigerator marked this pull request as ready for review August 13, 2025 14:57
- Rename RsaLegacy -> RsaV1
- Add note that RsaV2 can block the mobile app UI when generating keys,
  but might not after migrating to the New Architecture.
- Disable key generation with RsaV2 in the mobile app startup tests.
- Verify that key generation is tested with whatever public key
  algorithm is the default in the mobile app startup tests.
During pull request review, it was suggested that an RSA-OAEP-based
encryption option be provided that uses a length-2048 key (due to the
performance overhead of generating length-4096 keys). This commit adds
such an encryption scheme as "RsaV2". The old RsaV2 has been renamed to
"RsaV3".

As before, migrations to both RsaV2 and RsaV3 are disabled.

Possible concerns:
- See comment at the top of "LongDataWrapper.ts".
- MDN, referring to the key size: "Some organizations are now recommending that it should be 4096."
  - https://developer.mozilla.org/en-US/docs/Web/API/RsaHashedKeyGenParams#moduluslength
@personalizedrefrigerator personalizedrefrigerator changed the title Chore: Desktop,Mobile,Cli: Support accepting shares with a new key format Desktop,Mobile,Cli: Support accepting shares with a new key format Aug 15, 2025
@laurent22 laurent22 added the v3.5 label Aug 20, 2025
@laurent22
Copy link
Owner

I think we can go ahead and merge this, as soon as the conflicts are fixed

@laurent22 laurent22 merged commit fdde04e into laurent22:dev Sep 8, 2025
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants