Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

All: Resolves #8619: Add libsodium-based encryption method #8674

Closed
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 packages/lib/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"@types/fs-extra": "11.0.1",
"@types/jest": "29.5.3",
"@types/js-yaml": "4.0.5",
"@types/libsodium-wrappers": "0.7.10",
"@types/node": "18.16.18",
"@types/node-rsa": "1.1.1",
"@types/react": "18.0.24",
Expand Down Expand Up @@ -63,6 +64,7 @@
"immer": "7.0.15",
"js-yaml": "4.1.0",
"levenshtein": "1.0.5",
"libsodium-wrappers": "0.7.11",
"markdown-it": "13.0.1",
"md5": "2.3.0",
"md5-file": "5.0.0",
Expand Down
98 changes: 78 additions & 20 deletions packages/lib/services/e2ee/EncryptionService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export enum EncryptionMethod {
SJCL1a = 5,
Custom = 6,
SJCL1b = 7,
Sodium1 = 8,
}

export interface EncryptOptions {
Expand Down Expand Up @@ -70,7 +71,7 @@ export default class EncryptionService {
// changed easily since the chunk size is incorporated into the encrypted data.
private chunkSize_ = 5000;
private decryptedMasterKeys_: Record<string, DecryptedMasterKey> = {};
public defaultEncryptionMethod_ = EncryptionMethod.SJCL1a; // public because used in tests
public defaultEncryptionMethod_ = EncryptionMethod.Sodium1; // public because used in tests
private defaultMasterKeyEncryptionMethod_ = EncryptionMethod.SJCL4;

private headerTemplates_ = {
Expand Down Expand Up @@ -264,15 +265,35 @@ export default class EncryptionService {
return error;
}

private async deriveSodiumKey(key: string): Promise<Uint8Array> {
const sodium = await shim.libSodiumModule();
const binaryMasterKey = sodium.from_base64(key);

// TODO(REQUIRED): Switch to the streams API: https://doc.libsodium.org/secret-key_cryptography/secretstream
// TODO(REQUIRED): Is this really okay to do??? Our master keys are *much* longer (384 bytes) than
// the 32 bytes required by libsodium.
// Key derivation by generichash is suggested by
// https://github.com/jedisct1/libsodium/issues/347#issuecomment-372721843
// but the post is quite old.
// ...is this really okay?
const subkey = sodium.crypto_generichash(
sodium.crypto_aead_chacha20poly1305_IETF_KEYBYTES,
binaryMasterKey
);

return subkey;
}

public async encrypt(method: EncryptionMethod, key: string, plainText: string): Promise<string> {
if (!method) throw new Error('Encryption method is required');
if (!key) throw new Error('Encryption key is required');

const sjcl = shim.sjclModule;
const sodium = await shim.libSodiumModule();

const handlers: Record<EncryptionMethod, ()=> string> = {
const handlers: Record<EncryptionMethod, ()=> Promise<string>> = {
// 2020-01-23: Deprecated and no longer secure due to the use og OCB2 mode - do not use.
[EncryptionMethod.SJCL]: () => {
[EncryptionMethod.SJCL]: async () => {
try {
// Good demo to understand each parameter: https://bitwiseshiftleft.github.io/sjcl/demo/
return sjcl.json.encrypt(key, plainText, {
Expand All @@ -292,7 +313,7 @@ export default class EncryptionService {
// 2020-03-06: Added method to fix https://github.com/laurent22/joplin/issues/2591
// Also took the opportunity to change number of key derivations, per Isaac Potoczny's suggestion
// 2023-06-10: Deprecated in favour of SJCL1b
[EncryptionMethod.SJCL1a]: () => {
[EncryptionMethod.SJCL1a]: async () => {
try {
// We need to escape the data because SJCL uses encodeURIComponent to process the data and it only
// accepts UTF-8 data, or else it throws an error. And the notes might occasionally contain
Expand All @@ -313,7 +334,7 @@ export default class EncryptionService {

// 2023-06-10: Changed AES-128 to AES-256 per TheQuantumPhysicist's suggestions
// https://github.com/laurent22/joplin/issues/7686
[EncryptionMethod.SJCL1b]: () => {
[EncryptionMethod.SJCL1b]: async () => {
try {
// We need to escape the data because SJCL uses encodeURIComponent to process the data and it only
// accepts UTF-8 data, or else it throws an error. And the notes might occasionally contain
Expand All @@ -334,7 +355,7 @@ export default class EncryptionService {

// 2020-01-23: Deprecated - see above.
// Was used to encrypt master keys
[EncryptionMethod.SJCL2]: () => {
[EncryptionMethod.SJCL2]: async () => {
try {
return sjcl.json.encrypt(key, plainText, {
v: 1,
Expand All @@ -352,7 +373,7 @@ export default class EncryptionService {
// Don't know why we have this - it's not used anywhere. It must be
// kept however, in case some note somewhere is encrypted using this
// method.
[EncryptionMethod.SJCL3]: () => {
[EncryptionMethod.SJCL3]: async () => {
try {
// Good demo to understand each parameter: https://bitwiseshiftleft.github.io/sjcl/demo/
return sjcl.json.encrypt(key, plainText, {
Expand All @@ -370,7 +391,7 @@ export default class EncryptionService {
},

// Same as above but more secure (but slower) to encrypt master keys
[EncryptionMethod.SJCL4]: () => {
[EncryptionMethod.SJCL4]: async () => {
try {
return sjcl.json.encrypt(key, plainText, {
v: 1,
Expand All @@ -385,13 +406,32 @@ export default class EncryptionService {
}
},

[EncryptionMethod.Custom]: () => {
[EncryptionMethod.Sodium1]: async () => {
const subkey = await this.deriveSodiumKey(key);
const publicNonce = sodium.randombytes_buf(sodium.crypto_aead_xchacha20poly1305_IETF_NPUBBYTES);

// Doc: https://doc.libsodium.org/secret-key_cryptography/aead/chacha20-poly1305/xchacha20-poly1305_construction#combined-mode
const cipherText = sodium.crypto_secretbox_easy(
plainText,
publicNonce,
subkey
);
return JSON.stringify({
nonce: sodium.to_base64(publicNonce),

// TODO(OPTIONAL): More efficient encoding (base64 is equivalent to what we had
// before, but better would be ideal.)
cipherText: sodium.to_base64(cipherText),
});
},

[EncryptionMethod.Custom]: async () => {
// This is handled elsewhere but as a sanity check, throw an exception
throw new Error('Custom encryption method is not supported here');
},
};

return handlers[method]();
return await handlers[method]();
}

public async decrypt(method: EncryptionMethod, key: string, cipherText: string) {
Expand All @@ -401,17 +441,35 @@ export default class EncryptionService {
const sjcl = shim.sjclModule;
if (!this.isValidEncryptionMethod(method)) throw new Error(`Unknown decryption method: ${method}`);

try {
const output = sjcl.json.decrypt(key, cipherText);
// If libsodium
if (method === EncryptionMethod.Sodium1) {
const sodium = await shim.libSodiumModule();
const data = JSON.parse(cipherText);

if (method === EncryptionMethod.SJCL1a || method === EncryptionMethod.SJCL1b) {
return unescape(output);
} else {
return output;
// TODO(required): is from_base64 safe when given untrusted data?
const ct = sodium.from_base64(data.cipherText);
const nonce = sodium.from_base64(data.nonce);

const subkey = await this.deriveSodiumKey(key);

// TODO(required): does this work with invalid utf-8?
const result = sodium.crypto_secretbox_open_easy(
ct, nonce, subkey, 'text'
);
return result;
} else {
try {
const output = sjcl.json.decrypt(key, cipherText);

if (method === EncryptionMethod.SJCL1a || method === EncryptionMethod.SJCL1b) {
return unescape(output);
} else {
return output;
}
} catch (error) {
// SJCL returns a string as error which means stack trace is missing so convert to an error object here
throw new Error(error.message);
}
} catch (error) {
// SJCL returns a string as error which means stack trace is missing so convert to an error object here
throw new Error(error.message);
}
}

Expand Down Expand Up @@ -655,7 +713,7 @@ export default class EncryptionService {
}

public isValidEncryptionMethod(method: EncryptionMethod) {
return [EncryptionMethod.SJCL, EncryptionMethod.SJCL1a, EncryptionMethod.SJCL1b, EncryptionMethod.SJCL2, EncryptionMethod.SJCL3, EncryptionMethod.SJCL4].indexOf(method) >= 0;
return [EncryptionMethod.SJCL, EncryptionMethod.SJCL1a, EncryptionMethod.SJCL1b, EncryptionMethod.SJCL2, EncryptionMethod.SJCL3, EncryptionMethod.SJCL4, EncryptionMethod.Sodium1].indexOf(method) >= 0;
}

public async itemIsEncrypted(item: any) {
Expand Down
16 changes: 16 additions & 0 deletions packages/lib/shim.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import * as React from 'react';
import { NoteEntity, ResourceEntity } from './services/database/types';
import type * as Sodium from 'libsodium-wrappers';

let isTestingEnv_ = false;

Expand Down Expand Up @@ -219,6 +220,8 @@ const shim = {

sjclModule: null as any,

libSodiumModule: async (): Promise<typeof Sodium> => null,

randomBytes: async (_count: number) => {
throw new Error('Not implemented');
},
Expand Down Expand Up @@ -389,4 +392,17 @@ const shim = {

};

// TODO(required): Move (above, where libSodiumModule is defined).
// Or even into EncryptionWorker -- there doesn't seem to
// be a need to include this in shim (unless it breaks the CLI app).
let sodiumReady = false;
const sodium = require('libsodium-wrappers');
shim.libSodiumModule = async () => {
if (!sodiumReady) {
await sodium.ready;
sodiumReady = true;
}
return sodium;
};

export default shim;
25 changes: 25 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -4685,6 +4685,7 @@ __metadata:
"@types/fs-extra": 11.0.1
"@types/jest": 29.5.3
"@types/js-yaml": 4.0.5
"@types/libsodium-wrappers": 0.7.10
"@types/nanoid": 3.0.0
"@types/node": 18.16.18
"@types/node-rsa": 1.1.1
Expand Down Expand Up @@ -4715,6 +4716,7 @@ __metadata:
jest: 29.5.0
js-yaml: 4.1.0
levenshtein: 1.0.5
libsodium-wrappers: 0.7.11
markdown-it: 13.0.1
md5: 2.3.0
md5-file: 5.0.0
Expand Down Expand Up @@ -7745,6 +7747,13 @@ __metadata:
languageName: node
linkType: hard

"@types/libsodium-wrappers@npm:0.7.10":
version: 0.7.10
resolution: "@types/libsodium-wrappers@npm:0.7.10"
checksum: 717054ebcb5fa553e378144b8d564bed8b691905c0d4e90b95c64d77ba24ec9fe798cb2c58cd61dad545ceacb1f05ab69b5597217f9829f2da7a23f0688d11d0
languageName: node
linkType: hard

"@types/linkify-it@npm:*":
version: 3.0.2
resolution: "@types/linkify-it@npm:3.0.2"
Expand Down Expand Up @@ -21906,6 +21915,22 @@ __metadata:
languageName: node
linkType: hard

"libsodium-wrappers@npm:0.7.11":
version: 0.7.11
resolution: "libsodium-wrappers@npm:0.7.11"
dependencies:
libsodium: ^0.7.11
checksum: 6a6ef47b2213e3fb4687196c28fee4c9885f70d89547d845e62d96014d3d5ad9f59cb05fadc601debc0031a3cfd0b9b416d7efbeb5bf66db6aa0ed69f55a6293
languageName: node
linkType: hard

"libsodium@npm:^0.7.11":
version: 0.7.11
resolution: "libsodium@npm:0.7.11"
checksum: 0a3493ac1829d1e346178b6984c4eb449dc77157c906876441386c0c653142e3fa56f623ce980bb50e580196578689298c9cd406ce6d514904090e370c6bc0f7
languageName: node
linkType: hard

"libtap@npm:^1.4.0":
version: 1.4.0
resolution: "libtap@npm:1.4.0"
Expand Down