mirror of
https://github.com/streetwriters/notesnook.git
synced 2026-08-29 18:19:38 +02:00
Compare commits
8 Commits
editor/pag
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c702af1ca | ||
|
|
725df6f6f7 | ||
|
|
c9c4936d9e | ||
|
|
9e251d6f9e | ||
|
|
9190a1de3f | ||
|
|
a3d672362f | ||
|
|
04f9ff41a0 | ||
|
|
54c97584cf |
@@ -214,7 +214,7 @@ async function deleteItemsFromTrash(ids: string[]) {
|
||||
|
||||
await TaskManager.startTask({
|
||||
type: "status",
|
||||
id: "restoreItems",
|
||||
id: "deleteItems",
|
||||
title: strings.inProgressActions.permanentlyDeleting.item(ids.length),
|
||||
action: async (report) => {
|
||||
report({
|
||||
|
||||
@@ -50,6 +50,7 @@ class ThemeStore extends BaseStore<ThemeStore> {
|
||||
|
||||
setTheme = (theme: ThemeDefinition) => {
|
||||
changeDesktopTheme(theme, this.get().followSystemTheme);
|
||||
Config.set("colorScheme", theme.colorScheme);
|
||||
Config.set(`theme:${theme.colorScheme}`, theme);
|
||||
this.set({
|
||||
[getKey(theme)]: theme,
|
||||
|
||||
@@ -30,7 +30,6 @@ import { ErrorText } from "../components/error-text";
|
||||
import { EVENTS, User } from "@notesnook/core";
|
||||
import { RecoveryKeyDialog } from "../dialogs/recovery-key-dialog";
|
||||
import { strings } from "@notesnook/intl";
|
||||
import { useKeyStore } from "../interfaces/key-store";
|
||||
|
||||
type RecoveryMethodType = "key" | "reset";
|
||||
type RecoveryMethodsFormData = Record<string, unknown>;
|
||||
@@ -105,8 +104,8 @@ function useAuthenticateUser({
|
||||
code,
|
||||
userId
|
||||
}: {
|
||||
code: string;
|
||||
userId: string;
|
||||
code?: string;
|
||||
userId?: string;
|
||||
}) {
|
||||
const [isAuthenticating, setIsAuthenticating] = useState(true);
|
||||
const [user, setUser] = useState<User>();
|
||||
@@ -114,6 +113,10 @@ function useAuthenticateUser({
|
||||
async function authenticateUser() {
|
||||
setIsAuthenticating(true);
|
||||
try {
|
||||
if (!code || !userId) {
|
||||
throw new Error("Missing code or userId in query params.");
|
||||
}
|
||||
|
||||
const accessToken = await db.tokenManager.getAccessToken();
|
||||
if (!accessToken) {
|
||||
await db.tokenManager.getAccessTokenFromAuthorizationCode(
|
||||
@@ -181,7 +184,7 @@ function Recovery(props: RecoveryProps) {
|
||||
}}
|
||||
variant={"body"}
|
||||
>
|
||||
{strings.authenticatedAs(user?.email)}
|
||||
{user?.email ? strings.authenticatedAs(user.email) : ""}
|
||||
</Text>
|
||||
<Button
|
||||
sx={{
|
||||
@@ -334,16 +337,25 @@ function RecoveryKeyMethod(props: BaseRecoveryComponentProps<"method:key">) {
|
||||
subtitle: strings.keyRecoveryProgressDesc()
|
||||
}}
|
||||
onSubmit={async (form) => {
|
||||
const user = await db.user.getUser();
|
||||
if (!user) throw new Error(strings.notLoggedIn());
|
||||
|
||||
const recoveryKey = form.recoveryKey;
|
||||
if (recoveryKey.length < 40) {
|
||||
if (
|
||||
!(await db
|
||||
.storage()
|
||||
.encrypt({ key: recoveryKey, salt: user.salt }, "test")
|
||||
.then(() => true)
|
||||
.catch(() => false))
|
||||
) {
|
||||
throw new Error(strings.invalidRecoveryKey());
|
||||
}
|
||||
|
||||
setProgress(0);
|
||||
await db.user.verifyEncryptionKey({
|
||||
key: recoveryKey,
|
||||
salt: user.salt
|
||||
});
|
||||
|
||||
const user = await db.user.getUser();
|
||||
if (!user) throw new Error(strings.notLoggedIn());
|
||||
await useKeyStore.getState().setValue("userEncryptionKey", recoveryKey);
|
||||
navigate("new", form);
|
||||
}}
|
||||
>
|
||||
@@ -365,7 +377,10 @@ function RecoveryKeyMethod(props: BaseRecoveryComponentProps<"method:key">) {
|
||||
>
|
||||
{strings.back()}
|
||||
</Button>
|
||||
<SubmitButton text={strings.startAccountRecovery()} sx={{ flex: 1, mt: 0 }} />
|
||||
<SubmitButton
|
||||
text={strings.startAccountRecovery()}
|
||||
sx={{ flex: 1, mt: 0 }}
|
||||
/>
|
||||
</Flex>
|
||||
|
||||
<Button
|
||||
@@ -405,29 +420,28 @@ function NewPassword(props: BaseRecoveryComponentProps<"new">) {
|
||||
subtitle: strings.resetPasswordWait()
|
||||
}}
|
||||
onSubmit={async (form) => {
|
||||
try {
|
||||
setProgress(0);
|
||||
setProgress(0);
|
||||
const user = await db.user.getUser();
|
||||
if (!user) throw new Error(strings.notLoggedIn());
|
||||
|
||||
if (form.password !== form.confirmPassword)
|
||||
throw new Error("Passwords do not match.");
|
||||
if (!formData?.recoveryKey)
|
||||
throw new Error("Recovery key is required to reset password.");
|
||||
|
||||
if (formData?.userResetRequired && !(await db.user.resetUser()))
|
||||
throw new Error("Failed to reset user.");
|
||||
if (form.password !== form.confirmPassword)
|
||||
throw new Error("Passwords do not match.");
|
||||
|
||||
if (!(await db.user.resetPassword(form.password)))
|
||||
throw new Error("Could not reset account password.");
|
||||
if (formData?.userResetRequired && !(await db.user.resetUser()))
|
||||
throw new Error("Failed to reset user.");
|
||||
|
||||
navigate("final");
|
||||
} catch (e) {
|
||||
if ((e as Error).message === "invalid input") {
|
||||
console.error(e);
|
||||
throw new Error(
|
||||
"Password reset failed because of invalid recovery key"
|
||||
);
|
||||
}
|
||||
if (
|
||||
!(await db.user.resetPassword({
|
||||
encryptionKey: { key: formData?.recoveryKey, salt: user.salt },
|
||||
newPassword: form.password
|
||||
}))
|
||||
)
|
||||
throw new Error("Could not reset account password.");
|
||||
|
||||
throw e;
|
||||
}
|
||||
navigate("final");
|
||||
}}
|
||||
>
|
||||
{(form?: NewPasswordFormData) => (
|
||||
|
||||
@@ -27,7 +27,7 @@ import { IStorage } from "../src/interfaces.js";
|
||||
import { randomBytes } from "crypto";
|
||||
|
||||
export class NodeStorageInterface implements IStorage {
|
||||
storage = {};
|
||||
storage: Record<string, unknown> = {};
|
||||
crypto = new NNCrypto();
|
||||
|
||||
async removeMulti(keys: string[]): Promise<void> {
|
||||
@@ -49,7 +49,7 @@ export class NodeStorageInterface implements IStorage {
|
||||
async readMulti<T>(keys: string[]): Promise<[string, T][]> {
|
||||
const result: [string, T][] = [];
|
||||
keys.forEach((key) => {
|
||||
result.push([key, this.storage[key]]);
|
||||
result.push([key, this.storage[key] as T]);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
@@ -58,7 +58,7 @@ export class NodeStorageInterface implements IStorage {
|
||||
key: string,
|
||||
isArray?: boolean | undefined
|
||||
): Promise<T | undefined> {
|
||||
return this.storage[key];
|
||||
return this.storage[key] as T | undefined;
|
||||
}
|
||||
|
||||
async remove(key: string): Promise<void> {
|
||||
@@ -98,7 +98,11 @@ export class NodeStorageInterface implements IStorage {
|
||||
await this.write(`userEncryptionKey`, keyData.key);
|
||||
}
|
||||
|
||||
async hash(password: string, email: string): Promise<string> {
|
||||
async hash(
|
||||
password: string,
|
||||
email: string,
|
||||
options?: { usesFallback?: boolean }
|
||||
): Promise<string> {
|
||||
const APP_SALT = "oVzKtazBo7d8sb7TBvY9jw";
|
||||
return await this.crypto.hash(password, `${APP_SALT}${email}`);
|
||||
}
|
||||
@@ -113,19 +117,34 @@ export class NodeStorageInterface implements IStorage {
|
||||
password: string,
|
||||
salt?: string | undefined
|
||||
): Promise<SerializedKey> {
|
||||
return { password, salt: salt || randomBytes(16).toString("base64") };
|
||||
const finalSalt = salt || randomBytes(16).toString("base64");
|
||||
return await this.crypto.exportKey(password, finalSalt);
|
||||
}
|
||||
|
||||
generateCryptoKeyPair(): Promise<SerializedKeyPair> {
|
||||
throw new Error("Method not implemented.");
|
||||
async generatePGPKeyPair(): Promise<SerializedKeyPair> {
|
||||
return await this.crypto.exportKeyPair();
|
||||
}
|
||||
generateCryptoKeyFallback(
|
||||
|
||||
async generateCryptoKeyFallback(
|
||||
password: string,
|
||||
salt?: string
|
||||
): Promise<SerializedKey> {
|
||||
return this.generateCryptoKey(password, salt);
|
||||
}
|
||||
|
||||
async deriveCryptoKeyFallback(_credentials: SerializedKey): Promise<void> {}
|
||||
|
||||
async decryptPGPMessage(
|
||||
_privateKeyArmored: string,
|
||||
_encryptedMessage: string
|
||||
): Promise<string> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
deriveCryptoKeyFallback(credentials: SerializedKey): Promise<void> {
|
||||
throw new Error("Method not implemented.");
|
||||
|
||||
async validatePGPKeyPair(_keys: SerializedKeyPair): Promise<{
|
||||
isValid: boolean;
|
||||
message: string;
|
||||
}> {
|
||||
return { isValid: true, message: "ok" };
|
||||
}
|
||||
}
|
||||
|
||||
103
packages/core/__tests__/crypto-primitives.test.ts
Normal file
103
packages/core/__tests__/crypto-primitives.test.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
/*
|
||||
This file is part of the Notesnook project (https://notesnook.com/)
|
||||
|
||||
Copyright (C) 2023 Streetwriters (Private) Limited
|
||||
|
||||
This program 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.
|
||||
|
||||
This program 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 this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { test, expect, describe } from "vitest";
|
||||
import { databaseTest } from "./utils/index.ts";
|
||||
import { randomBytes } from "../src/utils/random.js";
|
||||
|
||||
function validSalt() {
|
||||
return randomBytes(16).toString("base64");
|
||||
}
|
||||
|
||||
describe("Storage encrypt/decrypt via database", () => {
|
||||
test("encrypt + decrypt round-trip through storage", async () => {
|
||||
await databaseTest().then(async (db) => {
|
||||
const key = await db.crypto().generateRandomKey();
|
||||
const plaintext = "test data for storage";
|
||||
const cipher = await db.storage().encrypt(key, plaintext);
|
||||
const decrypted = await db.storage().decrypt(key, cipher);
|
||||
expect(decrypted).toBe(plaintext);
|
||||
});
|
||||
});
|
||||
|
||||
test("deriveCryptoKey stores key, getCryptoKey retrieves it", async () => {
|
||||
await databaseTest().then(async (db) => {
|
||||
const salt = validSalt();
|
||||
await db.storage().deriveCryptoKey({ password: "mypassword", salt });
|
||||
const storedKey = await db.storage().getCryptoKey();
|
||||
expect(storedKey).toBeDefined();
|
||||
expect(typeof storedKey).toBe("string");
|
||||
expect(storedKey!.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
test("generateCryptoKey returns SerializedKey", async () => {
|
||||
await databaseTest().then(async (db) => {
|
||||
const salt = validSalt();
|
||||
const generated = await db.storage().generateCryptoKey("password", salt);
|
||||
expect(generated.salt).toBe(salt);
|
||||
expect(generated.key).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
test("generateCryptoKey without salt generates one", async () => {
|
||||
await databaseTest().then(async (db) => {
|
||||
const generated = await db.storage().generateCryptoKey("password");
|
||||
expect(generated.salt).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
test("encrypt with derived key works after deriveCryptoKey", async () => {
|
||||
await databaseTest().then(async (db) => {
|
||||
const salt = validSalt();
|
||||
const password = "secure-password";
|
||||
|
||||
await db.storage().deriveCryptoKey({ password, salt });
|
||||
const keyStr = await db.storage().getCryptoKey();
|
||||
|
||||
const key = { key: keyStr!, salt };
|
||||
const plaintext = "encrypted with derived key";
|
||||
const cipher = await db.storage().encrypt(key, plaintext);
|
||||
const decrypted = await db.storage().decrypt(key, cipher);
|
||||
expect(decrypted).toBe(plaintext);
|
||||
});
|
||||
});
|
||||
|
||||
test("deriveCryptoKey with empty password or salt is a no-op", async () => {
|
||||
await databaseTest().then(async (db) => {
|
||||
await db.storage().deriveCryptoKey({ password: "", salt: "salt" });
|
||||
const key = await db.storage().getCryptoKey();
|
||||
expect(key).toBeUndefined();
|
||||
|
||||
await db.storage().deriveCryptoKey({ password: "pass", salt: "" });
|
||||
const key2 = await db.storage().getCryptoKey();
|
||||
expect(key2).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
test("encryptMulti/decryptMulti round-trip through storage", async () => {
|
||||
await databaseTest().then(async (db) => {
|
||||
const key = await db.crypto().generateRandomKey();
|
||||
const items = ["hello", "world", "test123"];
|
||||
const ciphers = await db.storage().encryptMulti(key, items);
|
||||
const decrypted = await db.storage().decryptMulti(key, ciphers);
|
||||
expect(decrypted).toEqual(items);
|
||||
});
|
||||
});
|
||||
});
|
||||
407
packages/core/__tests__/encryption-verification.test.ts
Normal file
407
packages/core/__tests__/encryption-verification.test.ts
Normal file
@@ -0,0 +1,407 @@
|
||||
/*
|
||||
This file is part of the Notesnook project (https://notesnook.com/)
|
||||
|
||||
Copyright (C) 2023 Streetwriters (Private) Limited
|
||||
|
||||
This program 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.
|
||||
|
||||
This program 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 this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { test, expect, describe, vi } from "vitest";
|
||||
import { databaseTest, loginFakeUser } from "./utils/index.ts";
|
||||
import { KeyManager } from "../src/api/key-manager.js";
|
||||
import { randomBytes } from "../src/utils/random.js";
|
||||
|
||||
// ─── verifyPassword ────────────────────────────────────────────────
|
||||
|
||||
describe("UserManager.verifyPassword", () => {
|
||||
test("correct password returns true", async () => {
|
||||
await databaseTest().then(async (db) => {
|
||||
await loginFakeUser(db);
|
||||
const result = await db.user.verifyPassword("password");
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test("incorrect password returns false", async () => {
|
||||
await databaseTest().then(async (db) => {
|
||||
await loginFakeUser(db);
|
||||
const result = await db.user.verifyPassword("wrongpassword");
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
test("no user returns false", async () => {
|
||||
await databaseTest().then(async (db) => {
|
||||
const result = await db.user.verifyPassword("anything");
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
test("no master key returns false", async () => {
|
||||
await databaseTest().then(async (db) => {
|
||||
const userSalt = randomBytes(16).toString("base64");
|
||||
await db.user.setUser({
|
||||
id: "user-123",
|
||||
email: "test@example.com",
|
||||
isEmailConfirmed: true,
|
||||
salt: userSalt,
|
||||
mfa: { isEnabled: false, primaryMethod: "app", remainingValidCodes: 0 },
|
||||
subscription: { appId: 0, cancelURL: null, expiry: 0, productId: null, provider: "none", start: 0, plan: "free", status: "trial", updateURL: null, googlePurchaseToken: null }
|
||||
} as any);
|
||||
const result = await db.user.verifyPassword("anything");
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── verifyEncryptionKey ───────────────────────────────────────────
|
||||
|
||||
describe("UserManager.verifyEncryptionKey", () => {
|
||||
describe("user with both legacyDataEncryptionKey and dataEncryptionKey", () => {
|
||||
test("valid key does not throw", async () => {
|
||||
await databaseTest().then(async (db) => {
|
||||
const password = "mypassword";
|
||||
const salt = randomBytes(16).toString("base64");
|
||||
// Set up user with crypto key derived from our password
|
||||
await db.user.setUser({
|
||||
id: "user-123", email: "test@example.com", isEmailConfirmed: true,
|
||||
salt, mfa: { isEnabled: false, primaryMethod: "app", remainingValidCodes: 0 },
|
||||
subscription: { appId: 0, cancelURL: null, expiry: 0, productId: null, provider: "none", start: 0, plan: "free", status: "trial", updateURL: null, googlePurchaseToken: null }
|
||||
} as any);
|
||||
await db.storage().deriveCryptoKey({ password, salt });
|
||||
const masterKey = await db.user.getMasterKey();
|
||||
const km = new KeyManager(db);
|
||||
|
||||
const randomKey = await db.crypto().generateRandomKey();
|
||||
const legacyDEK = await km.wrapKey(randomKey, masterKey!);
|
||||
const dek = await km.wrapKey(randomKey, masterKey!);
|
||||
|
||||
const user = await db.user.getUser();
|
||||
await db.user.setUser({ ...user, legacyDataEncryptionKey: legacyDEK, dataEncryptionKey: dek });
|
||||
|
||||
await expect(
|
||||
db.user.verifyEncryptionKey({ password, salt })
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
test("invalid key throws", async () => {
|
||||
await databaseTest().then(async (db) => {
|
||||
const password = "correct";
|
||||
const salt = randomBytes(16).toString("base64");
|
||||
await db.user.setUser({
|
||||
id: "user-123", email: "test@example.com", isEmailConfirmed: true,
|
||||
salt, mfa: { isEnabled: false, primaryMethod: "app", remainingValidCodes: 0 },
|
||||
subscription: { appId: 0, cancelURL: null, expiry: 0, productId: null, provider: "none", start: 0, plan: "free", status: "trial", updateURL: null, googlePurchaseToken: null }
|
||||
} as any);
|
||||
await db.storage().deriveCryptoKey({ password, salt });
|
||||
const masterKey = await db.user.getMasterKey();
|
||||
const km = new KeyManager(db);
|
||||
|
||||
const randomKey = await db.crypto().generateRandomKey();
|
||||
const legacyDEK = await km.wrapKey(randomKey, masterKey!);
|
||||
const dek = await km.wrapKey(randomKey, masterKey!);
|
||||
|
||||
const user = await db.user.getUser();
|
||||
await db.user.setUser({ ...user, legacyDataEncryptionKey: legacyDEK, dataEncryptionKey: dek });
|
||||
|
||||
await expect(
|
||||
db.user.verifyEncryptionKey({ password: "wrongpassword", salt })
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("user with neither legacyDataEncryptionKey nor dataEncryptionKey", () => {
|
||||
test("valid key using attachmentsKey verifier does not throw", async () => {
|
||||
await databaseTest().then(async (db) => {
|
||||
const password = "mypassword";
|
||||
const salt = randomBytes(16).toString("base64");
|
||||
await db.user.setUser({
|
||||
id: "user-123", email: "test@example.com", isEmailConfirmed: true,
|
||||
salt, mfa: { isEnabled: false, primaryMethod: "app", remainingValidCodes: 0 },
|
||||
subscription: { appId: 0, cancelURL: null, expiry: 0, productId: null, provider: "none", start: 0, plan: "free", status: "trial", updateURL: null, googlePurchaseToken: null }
|
||||
} as any);
|
||||
await db.storage().deriveCryptoKey({ password, salt });
|
||||
const masterKey = await db.user.getMasterKey();
|
||||
const km = new KeyManager(db);
|
||||
|
||||
const key = await db.crypto().generateRandomKey();
|
||||
const attachmentsKey = await km.wrapKey(key, masterKey!);
|
||||
|
||||
const user = await db.user.getUser();
|
||||
await db.user.setUser({ ...user, attachmentsKey });
|
||||
|
||||
await expect(
|
||||
db.user.verifyEncryptionKey({ password, salt })
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
test("invalid key using attachmentsKey verifier throws", async () => {
|
||||
await databaseTest().then(async (db) => {
|
||||
const password = "correct";
|
||||
const salt = randomBytes(16).toString("base64");
|
||||
await db.user.setUser({
|
||||
id: "user-123", email: "test@example.com", isEmailConfirmed: true,
|
||||
salt, mfa: { isEnabled: false, primaryMethod: "app", remainingValidCodes: 0 },
|
||||
subscription: { appId: 0, cancelURL: null, expiry: 0, productId: null, provider: "none", start: 0, plan: "free", status: "trial", updateURL: null, googlePurchaseToken: null }
|
||||
} as any);
|
||||
await db.storage().deriveCryptoKey({ password, salt });
|
||||
const masterKey = await db.user.getMasterKey();
|
||||
const km = new KeyManager(db);
|
||||
|
||||
const key = await db.crypto().generateRandomKey();
|
||||
const attachmentsKey = await km.wrapKey(key, masterKey!);
|
||||
|
||||
const user = await db.user.getUser();
|
||||
await db.user.setUser({ ...user, attachmentsKey });
|
||||
|
||||
await expect(
|
||||
db.user.verifyEncryptionKey({ password: "wrongpassword", salt })
|
||||
).rejects.toThrow(
|
||||
"Your data cannot be decrypted using the provided encryption key."
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("uses monographPasswordsKey when no attachmentsKey exists", async () => {
|
||||
await databaseTest().then(async (db) => {
|
||||
const password = "mypassword";
|
||||
const salt = randomBytes(16).toString("base64");
|
||||
await db.user.setUser({
|
||||
id: "user-123", email: "test@example.com", isEmailConfirmed: true,
|
||||
salt, mfa: { isEnabled: false, primaryMethod: "app", remainingValidCodes: 0 },
|
||||
subscription: { appId: 0, cancelURL: null, expiry: 0, productId: null, provider: "none", start: 0, plan: "free", status: "trial", updateURL: null, googlePurchaseToken: null }
|
||||
} as any);
|
||||
await db.storage().deriveCryptoKey({ password, salt });
|
||||
const masterKey = await db.user.getMasterKey();
|
||||
const km = new KeyManager(db);
|
||||
|
||||
const key = await db.crypto().generateRandomKey();
|
||||
const monographKey = await km.wrapKey(key, masterKey!);
|
||||
|
||||
const user = await db.user.getUser();
|
||||
// Ensure no attachmentsKey, only monographPasswordsKey
|
||||
await db.user.setUser({ ...user, attachmentsKey: undefined, monographPasswordsKey: monographKey });
|
||||
|
||||
await expect(
|
||||
db.user.verifyEncryptionKey({ password, salt })
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
test("fetches verifier from server when no local keys exist", async () => {
|
||||
const http = (await import("../src/utils/http.js")).default;
|
||||
const mockGet = vi.spyOn(http, "get").mockResolvedValue(undefined);
|
||||
|
||||
await databaseTest().then(async (db) => {
|
||||
const password = "mypassword";
|
||||
const salt = randomBytes(16).toString("base64");
|
||||
await db.user.setUser({
|
||||
id: "user-123", email: "test@example.com", isEmailConfirmed: true,
|
||||
salt, mfa: { isEnabled: false, primaryMethod: "app", remainingValidCodes: 0 },
|
||||
subscription: { appId: 0, cancelURL: null, expiry: 0, productId: null, provider: "none", start: 0, plan: "free", status: "trial", updateURL: null, googlePurchaseToken: null }
|
||||
} as any);
|
||||
await db.storage().deriveCryptoKey({ password, salt });
|
||||
|
||||
// Set a token so getAccessToken returns something
|
||||
await db.kv().write("token", {
|
||||
access_token: "fake-token",
|
||||
t: Date.now(),
|
||||
expires_in: 3600,
|
||||
scope: "notesnook.sync IdentityServerApi",
|
||||
refresh_token: "fake-refresh"
|
||||
});
|
||||
|
||||
// No attachmentsKey, no monographPasswordsKey
|
||||
// Server returns undefined — should throw
|
||||
await expect(
|
||||
db.user.verifyEncryptionKey({ password, salt })
|
||||
).rejects.toThrow(
|
||||
"Encryption key cannot be verified: no encryption verifier found."
|
||||
);
|
||||
});
|
||||
|
||||
mockGet.mockRestore();
|
||||
});
|
||||
|
||||
test("uses server-provided verifier cipher when available", async () => {
|
||||
const http = (await import("../src/utils/http.js")).default;
|
||||
|
||||
await databaseTest().then(async (db) => {
|
||||
const password = "mypassword";
|
||||
const salt = randomBytes(16).toString("base64");
|
||||
await db.user.setUser({
|
||||
id: "user-123", email: "test@example.com", isEmailConfirmed: true,
|
||||
salt, mfa: { isEnabled: false, primaryMethod: "app", remainingValidCodes: 0 },
|
||||
subscription: { appId: 0, cancelURL: null, expiry: 0, productId: null, provider: "none", start: 0, plan: "free", status: "trial", updateURL: null, googlePurchaseToken: null }
|
||||
} as any);
|
||||
await db.storage().deriveCryptoKey({ password, salt });
|
||||
|
||||
await db.kv().write("token", {
|
||||
access_token: "fake-token",
|
||||
t: Date.now(),
|
||||
expires_in: 3600,
|
||||
scope: "notesnook.sync IdentityServerApi",
|
||||
refresh_token: "fake-refresh"
|
||||
});
|
||||
|
||||
// Create a verifier cipher that the correct key can decrypt
|
||||
const verifierCipher = await db.storage().encrypt(
|
||||
{ password, salt },
|
||||
"test-data"
|
||||
);
|
||||
const mockGet = vi.spyOn(http, "get").mockResolvedValueOnce(verifierCipher);
|
||||
|
||||
await expect(
|
||||
db.user.verifyEncryptionKey({ password, salt })
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith(
|
||||
expect.stringContaining("/users/verifier"),
|
||||
"fake-token"
|
||||
);
|
||||
mockGet.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
test("server verifier rejects wrong key", async () => {
|
||||
const http = (await import("../src/utils/http.js")).default;
|
||||
|
||||
await databaseTest().then(async (db) => {
|
||||
const salt = randomBytes(16).toString("base64");
|
||||
await db.user.setUser({
|
||||
id: "user-123", email: "test@example.com", isEmailConfirmed: true,
|
||||
salt, mfa: { isEnabled: false, primaryMethod: "app", remainingValidCodes: 0 },
|
||||
subscription: { appId: 0, cancelURL: null, expiry: 0, productId: null, provider: "none", start: 0, plan: "free", status: "trial", updateURL: null, googlePurchaseToken: null }
|
||||
} as any);
|
||||
await db.storage().deriveCryptoKey({ password: "correct-password", salt });
|
||||
|
||||
await db.kv().write("token", {
|
||||
access_token: "fake-token",
|
||||
t: Date.now(),
|
||||
expires_in: 3600,
|
||||
scope: "notesnook.sync IdentityServerApi",
|
||||
refresh_token: "fake-refresh"
|
||||
});
|
||||
|
||||
// Verifier encrypted with correct password
|
||||
const verifierCipher = await db.storage().encrypt(
|
||||
{ password: "correct-password", salt },
|
||||
"test-data"
|
||||
);
|
||||
const mockGet = vi.spyOn(http, "get").mockResolvedValueOnce(verifierCipher);
|
||||
|
||||
// Wrong key should fail verification
|
||||
await expect(
|
||||
db.user.verifyEncryptionKey({ password: "wrong-password", salt })
|
||||
).rejects.toThrow(
|
||||
"Your data cannot be decrypted using the provided encryption key."
|
||||
);
|
||||
|
||||
mockGet.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("user with only one of legacy DEK or DEK", () => {
|
||||
test("succeeds when only dataEncryptionKey exists", async () => {
|
||||
await databaseTest().then(async (db) => {
|
||||
const password = "password";
|
||||
const salt = randomBytes(16).toString("base64");
|
||||
await db.user.setUser({
|
||||
id: "user-123", email: "test@example.com", isEmailConfirmed: true,
|
||||
salt, mfa: { isEnabled: false, primaryMethod: "app", remainingValidCodes: 0 },
|
||||
subscription: { appId: 0, cancelURL: null, expiry: 0, productId: null, provider: "none", start: 0, plan: "free", status: "trial", updateURL: null, googlePurchaseToken: null }
|
||||
} as any);
|
||||
await db.storage().deriveCryptoKey({ password, salt });
|
||||
const masterKey = await db.user.getMasterKey();
|
||||
const km = new KeyManager(db);
|
||||
|
||||
const randomKey = await db.crypto().generateRandomKey();
|
||||
const dek = await km.wrapKey(randomKey, masterKey!);
|
||||
|
||||
const user = await db.user.getUser();
|
||||
await db.user.setUser({ ...user, dataEncryptionKey: dek });
|
||||
|
||||
await expect(
|
||||
db.user.verifyEncryptionKey({ password, salt })
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
test("succeeds when only legacyDataEncryptionKey exists", async () => {
|
||||
await databaseTest().then(async (db) => {
|
||||
const password = "password";
|
||||
const salt = randomBytes(16).toString("base64");
|
||||
await db.user.setUser({
|
||||
id: "user-123", email: "test@example.com", isEmailConfirmed: true,
|
||||
salt, mfa: { isEnabled: false, primaryMethod: "app", remainingValidCodes: 0 },
|
||||
subscription: { appId: 0, cancelURL: null, expiry: 0, productId: null, provider: "none", start: 0, plan: "free", status: "trial", updateURL: null, googlePurchaseToken: null }
|
||||
} as any);
|
||||
await db.storage().deriveCryptoKey({ password, salt });
|
||||
const masterKey = await db.user.getMasterKey();
|
||||
const km = new KeyManager(db);
|
||||
|
||||
const randomKey = await db.crypto().generateRandomKey();
|
||||
const legacyDEK = await km.wrapKey(randomKey, masterKey!);
|
||||
|
||||
const user = await db.user.getUser();
|
||||
await db.user.setUser({ ...user, legacyDataEncryptionKey: legacyDEK });
|
||||
|
||||
await expect(
|
||||
db.user.verifyEncryptionKey({ password, salt })
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
test("invalid key throws even when only a single DEK exists", async () => {
|
||||
await databaseTest().then(async (db) => {
|
||||
const password = "correct";
|
||||
const salt = randomBytes(16).toString("base64");
|
||||
await db.user.setUser({
|
||||
id: "user-123", email: "test@example.com", isEmailConfirmed: true,
|
||||
salt, mfa: { isEnabled: false, primaryMethod: "app", remainingValidCodes: 0 },
|
||||
subscription: { appId: 0, cancelURL: null, expiry: 0, productId: null, provider: "none", start: 0, plan: "free", status: "trial", updateURL: null, googlePurchaseToken: null }
|
||||
} as any);
|
||||
await db.storage().deriveCryptoKey({ password, salt });
|
||||
const masterKey = await db.user.getMasterKey();
|
||||
const km = new KeyManager(db);
|
||||
|
||||
const randomKey = await db.crypto().generateRandomKey();
|
||||
const dek = await km.wrapKey(randomKey, masterKey!);
|
||||
|
||||
const user = await db.user.getUser();
|
||||
await db.user.setUser({ ...user, dataEncryptionKey: dek });
|
||||
|
||||
await expect(
|
||||
db.user.verifyEncryptionKey({ password: "wrongpassword", salt })
|
||||
).rejects.toThrow(
|
||||
"Your data cannot be decrypted using the provided encryption key."
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("no user", () => {
|
||||
test("throws when no user exists", async () => {
|
||||
await databaseTest().then(async (db) => {
|
||||
await expect(
|
||||
db.user.verifyEncryptionKey({ password: "test", salt: "salt" })
|
||||
).rejects.toThrow("User not found.");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
274
packages/core/__tests__/key-manager.test.ts
Normal file
274
packages/core/__tests__/key-manager.test.ts
Normal file
@@ -0,0 +1,274 @@
|
||||
/*
|
||||
This file is part of the Notesnook project (https://notesnook.com/)
|
||||
|
||||
Copyright (C) 2023 Streetwriters (Private) Limited
|
||||
|
||||
This program 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.
|
||||
|
||||
This program 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 this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { test, expect, describe } from "vitest";
|
||||
import { databaseTest, loginFakeUser } from "./utils/index.ts";
|
||||
import { KeyManager } from "../src/api/key-manager.js";
|
||||
import { randomBytes } from "../src/utils/random.js";
|
||||
|
||||
function validSalt() {
|
||||
return randomBytes(16).toString("base64");
|
||||
}
|
||||
|
||||
describe("KeyManager", () => {
|
||||
describe("wrapKey + unwrapKey (symmetric key)", () => {
|
||||
test("round-trip: wrap then unwrap returns original key", async () => {
|
||||
await databaseTest().then(async (db) => {
|
||||
await loginFakeUser(db);
|
||||
const km = new KeyManager(db);
|
||||
|
||||
const masterKey = await db.user.getMasterKey();
|
||||
expect(masterKey).toBeDefined();
|
||||
|
||||
const originalKey = await db.crypto().generateRandomKey();
|
||||
const wrapped = await km.wrapKey(originalKey, masterKey!);
|
||||
|
||||
expect(wrapped).toBeDefined();
|
||||
expect(wrapped).toHaveProperty("cipher");
|
||||
|
||||
const unwrapped = await km.unwrapKey(wrapped, masterKey!);
|
||||
expect(unwrapped).toEqual(originalKey);
|
||||
});
|
||||
});
|
||||
|
||||
test("unwrap with wrong key throws", async () => {
|
||||
await databaseTest().then(async (db) => {
|
||||
await loginFakeUser(db);
|
||||
const km = new KeyManager(db);
|
||||
|
||||
const masterKey = await db.user.getMasterKey();
|
||||
const wrongKey = await db.storage().generateCryptoKey("wrongpassword", validSalt());
|
||||
|
||||
const originalKey = await db.crypto().generateRandomKey();
|
||||
const wrapped = await km.wrapKey(originalKey, masterKey!);
|
||||
|
||||
await expect(km.unwrapKey(wrapped, wrongKey)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
test("wrapped key is a Cipher with base64 format", async () => {
|
||||
await databaseTest().then(async (db) => {
|
||||
await loginFakeUser(db);
|
||||
const km = new KeyManager(db);
|
||||
|
||||
const masterKey = await db.user.getMasterKey();
|
||||
const originalKey = await db.crypto().generateRandomKey();
|
||||
const wrapped = await km.wrapKey(originalKey, masterKey!);
|
||||
|
||||
expect(wrapped).toHaveProperty("format", "base64");
|
||||
expect(wrapped).toHaveProperty("alg");
|
||||
expect(typeof (wrapped as any).cipher).toBe("string");
|
||||
expect(typeof (wrapped as any).iv).toBe("string");
|
||||
expect(typeof (wrapped as any).salt).toBe("string");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("rewrapKey", () => {
|
||||
test("symmetric: rewrap succeeds with new key", async () => {
|
||||
await databaseTest().then(async (db) => {
|
||||
const km = new KeyManager(db);
|
||||
|
||||
const oldKey = await db.storage().generateCryptoKey("oldpassword", validSalt());
|
||||
const newKey = await db.storage().generateCryptoKey("newpassword", validSalt());
|
||||
|
||||
const originalKey = await db.crypto().generateRandomKey();
|
||||
const wrapped = await km.wrapKey(originalKey, oldKey);
|
||||
|
||||
const rewrapped = await km.rewrapKey(wrapped, oldKey, newKey);
|
||||
|
||||
const unwrapped = await km.unwrapKey(rewrapped, newKey);
|
||||
expect(unwrapped).toEqual(originalKey);
|
||||
});
|
||||
});
|
||||
|
||||
test("symmetric: rewrap fails when unwrap with wrong old key", async () => {
|
||||
await databaseTest().then(async (db) => {
|
||||
const km = new KeyManager(db);
|
||||
|
||||
const correctOldKey = await db.storage().generateCryptoKey("correct", validSalt());
|
||||
const wrongOldKey = await db.storage().generateCryptoKey("wrong", validSalt());
|
||||
const newKey = await db.storage().generateCryptoKey("newpassword", validSalt());
|
||||
|
||||
const originalKey = await db.crypto().generateRandomKey();
|
||||
const wrapped = await km.wrapKey(originalKey, correctOldKey);
|
||||
|
||||
await expect(km.rewrapKey(wrapped, wrongOldKey, newKey)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
test("rewrap produces different ciphertext than original", async () => {
|
||||
await databaseTest().then(async (db) => {
|
||||
const km = new KeyManager(db);
|
||||
|
||||
const key1 = await db.storage().generateCryptoKey("password1", validSalt());
|
||||
const key2 = await db.storage().generateCryptoKey("password2", validSalt());
|
||||
|
||||
const originalKey = await db.crypto().generateRandomKey();
|
||||
const wrapped = await km.wrapKey(originalKey, key1);
|
||||
const rewrapped = await km.rewrapKey(wrapped, key1, key2);
|
||||
|
||||
expect((rewrapped as any).cipher).not.toBe((wrapped as any).cipher);
|
||||
expect((rewrapped as any).iv).not.toBe((wrapped as any).iv);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("get() caching", () => {
|
||||
test("get() populates cache and returns cached value", async () => {
|
||||
await databaseTest().then(async (db) => {
|
||||
await loginFakeUser(db);
|
||||
const km = new KeyManager(db);
|
||||
|
||||
const key1 = await km.get("attachmentsKey", { useCache: false, refetchUser: false });
|
||||
expect(key1).toBeDefined();
|
||||
|
||||
const key2 = await km.get("attachmentsKey", { useCache: true, refetchUser: false });
|
||||
expect(key2).toBeDefined();
|
||||
expect(key2).toEqual(key1);
|
||||
});
|
||||
});
|
||||
|
||||
test("clearCache() forces fresh fetch on next get()", async () => {
|
||||
await databaseTest().then(async (db) => {
|
||||
await loginFakeUser(db);
|
||||
const km = new KeyManager(db);
|
||||
|
||||
await km.get("attachmentsKey", { useCache: true, refetchUser: false });
|
||||
km.clearCache();
|
||||
|
||||
const key = await km.get("attachmentsKey", { useCache: false, refetchUser: false });
|
||||
expect(key).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("get() edge cases", () => {
|
||||
test("get() returns undefined when user has no key for that ID", async () => {
|
||||
await databaseTest().then(async (db) => {
|
||||
await loginFakeUser(db);
|
||||
const km = new KeyManager(db);
|
||||
|
||||
const key = await km.get("dataEncryptionKey", { useCache: false, refetchUser: false });
|
||||
expect(key).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
test("get() returns undefined when no user exists", async () => {
|
||||
await databaseTest().then(async (db) => {
|
||||
const km = new KeyManager(db);
|
||||
const key = await km.get("attachmentsKey", { useCache: false, refetchUser: false });
|
||||
expect(key).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
test("get() returns all key types when they exist", async () => {
|
||||
await databaseTest().then(async (db) => {
|
||||
await loginFakeUser(db);
|
||||
const km = new KeyManager(db);
|
||||
|
||||
const masterKey = await db.user.getMasterKey();
|
||||
const randomKey = await db.crypto().generateRandomKey();
|
||||
|
||||
const user = await db.user.getUser();
|
||||
await db.user.setUser({
|
||||
...user,
|
||||
attachmentsKey: await km.wrapKey(randomKey, masterKey!),
|
||||
monographPasswordsKey: await km.wrapKey(randomKey, masterKey!),
|
||||
dataEncryptionKey: await km.wrapKey(randomKey, masterKey!)
|
||||
});
|
||||
|
||||
const attachmentsKey = await km.get("attachmentsKey", { useCache: false, refetchUser: false });
|
||||
const monographKey = await km.get("monographPasswordsKey", { useCache: false, refetchUser: false });
|
||||
const dek = await km.get("dataEncryptionKey", { useCache: false, refetchUser: false });
|
||||
|
||||
expect(attachmentsKey).toBeDefined();
|
||||
expect(monographKey).toBeDefined();
|
||||
expect(dek).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("wrapKey/unwrapKey with password-derived key", () => {
|
||||
test("wrap with password key, derive same password, unwrap succeeds", async () => {
|
||||
await databaseTest().then(async (db) => {
|
||||
const km = new KeyManager(db);
|
||||
const salt = validSalt();
|
||||
const passwordKey = await db.storage().generateCryptoKey("mypassword", salt);
|
||||
|
||||
const originalKey = await db.crypto().generateRandomKey();
|
||||
const wrapped = await km.wrapKey(originalKey, passwordKey);
|
||||
|
||||
const samePasswordKey = await db.storage().generateCryptoKey("mypassword", salt);
|
||||
const unwrapped = await km.unwrapKey(wrapped, samePasswordKey);
|
||||
expect(unwrapped).toEqual(originalKey);
|
||||
});
|
||||
});
|
||||
|
||||
test("wrap with different password fails to unwrap", async () => {
|
||||
await databaseTest().then(async (db) => {
|
||||
const km = new KeyManager(db);
|
||||
|
||||
const key1 = await db.storage().generateCryptoKey("password1", validSalt());
|
||||
const key2 = await db.storage().generateCryptoKey("password2", validSalt());
|
||||
|
||||
const originalKey = await db.crypto().generateRandomKey();
|
||||
const wrapped = await km.wrapKey(originalKey, key1);
|
||||
|
||||
await expect(km.unwrapKey(wrapped, key2)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Data integrity", () => {
|
||||
test("wrap/unwrap preserves large symmetric key", async () => {
|
||||
await databaseTest().then(async (db) => {
|
||||
const km = new KeyManager(db);
|
||||
await loginFakeUser(db);
|
||||
|
||||
const masterKey = await db.user.getMasterKey();
|
||||
const largeKey = await db.storage().generateCryptoKey("x".repeat(1000), validSalt());
|
||||
|
||||
const wrapped = await km.wrapKey(largeKey, masterKey!);
|
||||
const unwrapped = await km.unwrapKey(wrapped, masterKey!);
|
||||
expect(unwrapped).toEqual(largeKey);
|
||||
});
|
||||
});
|
||||
|
||||
test("multiple wrap/unwrap cycles with different keys", async () => {
|
||||
await databaseTest().then(async (db) => {
|
||||
const km = new KeyManager(db);
|
||||
|
||||
let currentKey = await db.storage().generateCryptoKey("initial", validSalt());
|
||||
const originalData = await db.crypto().generateRandomKey();
|
||||
|
||||
const key2 = await db.storage().generateCryptoKey("second", validSalt());
|
||||
const key3 = await db.storage().generateCryptoKey("third", validSalt());
|
||||
|
||||
let wrapped = await km.wrapKey(originalData, currentKey);
|
||||
wrapped = await km.rewrapKey(wrapped, currentKey, key2);
|
||||
wrapped = await km.rewrapKey(wrapped, key2, key3);
|
||||
wrapped = await km.rewrapKey(wrapped, key3, currentKey);
|
||||
|
||||
const unwrapped = await km.unwrapKey(wrapped, currentKey);
|
||||
expect(unwrapped).toEqual(originalData);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
423
packages/core/__tests__/password-change.test.ts
Normal file
423
packages/core/__tests__/password-change.test.ts
Normal file
@@ -0,0 +1,423 @@
|
||||
/*
|
||||
This file is part of the Notesnook project (https://notesnook.com/)
|
||||
|
||||
Copyright (C) 2023 Streetwriters (Private) Limited
|
||||
|
||||
This program 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.
|
||||
|
||||
This program 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 this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { test, expect, describe, vi } from "vitest";
|
||||
import { databaseTest } from "./utils/index.ts";
|
||||
import { KeyManager } from "../src/api/key-manager.js";
|
||||
import { randomBytes } from "../src/utils/random.js";
|
||||
|
||||
const FULL_USER: any = {
|
||||
id: "user-123",
|
||||
email: "test@example.com",
|
||||
isEmailConfirmed: true,
|
||||
salt: "",
|
||||
mfa: { isEnabled: false, primaryMethod: "app", remainingValidCodes: 0 },
|
||||
subscription: { appId: 0, cancelURL: null, expiry: 0, productId: null, provider: "none", start: 0, plan: "free", status: "trial", updateURL: null, googlePurchaseToken: null }
|
||||
};
|
||||
|
||||
vi.mock("../src/utils/http.js", () => ({
|
||||
default: {
|
||||
get: vi.fn().mockResolvedValue(undefined),
|
||||
post: vi.fn().mockResolvedValue(undefined),
|
||||
patch: { json: vi.fn().mockResolvedValue(undefined) },
|
||||
delete: vi.fn().mockResolvedValue(undefined)
|
||||
}
|
||||
}));
|
||||
|
||||
async function setupLoggedInUser(db: any, password: string = "oldpassword") {
|
||||
const salt = randomBytes(16).toString("base64");
|
||||
const user = { ...FULL_USER, salt };
|
||||
await db.user.setUser(user);
|
||||
await db.storage().deriveCryptoKey({ password, salt });
|
||||
|
||||
await db.kv().write("token", {
|
||||
access_token: "fake-token",
|
||||
t: Date.now(),
|
||||
expires_in: 3600,
|
||||
scope: "notesnook.sync offline_access IdentityServerApi",
|
||||
refresh_token: "fake-refresh"
|
||||
});
|
||||
|
||||
const km = new KeyManager(db);
|
||||
const masterKey = await db.user.getMasterKey();
|
||||
const randomKey = await db.crypto().generateRandomKey();
|
||||
|
||||
const attachmentsKey = await km.wrapKey(randomKey, masterKey!);
|
||||
const monographPasswordsKey = await km.wrapKey(randomKey, masterKey!);
|
||||
const dek = await km.wrapKey(randomKey, masterKey!);
|
||||
const legacyDEK = await km.wrapKey(randomKey, masterKey!);
|
||||
|
||||
await db.user.setUser({
|
||||
...user,
|
||||
attachmentsKey,
|
||||
monographPasswordsKey,
|
||||
dataEncryptionKey: dek,
|
||||
legacyDataEncryptionKey: legacyDEK
|
||||
});
|
||||
|
||||
return { password, salt, masterKey, randomKey };
|
||||
}
|
||||
|
||||
// ─── _updatePassword (change) ──────────────────────────────────────
|
||||
|
||||
describe("UserManager._updatePassword (change)", () => {
|
||||
test("successful password change rewraps all keys and derives new master key", async () => {
|
||||
await databaseTest().then(async (db) => {
|
||||
await setupLoggedInUser(db, "oldpassword");
|
||||
const result = await db.user.changePassword("oldpassword", "newpassword");
|
||||
expect(result).toBe(true);
|
||||
const newMasterKey = await db.user.getMasterKey();
|
||||
expect(newMasterKey).toBeDefined();
|
||||
expect(newMasterKey!.key).not.toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
test("wrong old password throws 'Incorrect old password'", async () => {
|
||||
await databaseTest().then(async (db) => {
|
||||
await setupLoggedInUser(db, "oldpassword");
|
||||
await expect(
|
||||
db.user.changePassword("wrongpassword", "newpassword")
|
||||
).rejects.toThrow("Incorrect old password");
|
||||
});
|
||||
});
|
||||
|
||||
test("new master key decrypts dataEncryptionKey and old master key throws (regression)", async () => {
|
||||
await databaseTest().then(async (db) => {
|
||||
const { salt } = await setupLoggedInUser(db, "oldpassword");
|
||||
|
||||
// Simulate a full password change with old -> new passwords.
|
||||
const result = await db.user.changePassword("oldpassword", "newpassword");
|
||||
expect(result).toBe(true);
|
||||
|
||||
const user = await db.user.getUser();
|
||||
|
||||
// The stored master key must now be the one derived from the NEW password.
|
||||
const newMasterKey = await db.user.getMasterKey();
|
||||
expect(newMasterKey).toBeDefined();
|
||||
expect(newMasterKey!.key).toBe(
|
||||
(await db.storage().generateCryptoKey("newpassword", salt)).key
|
||||
);
|
||||
|
||||
// (a) The NEW master key must decrypt the rewrapped dataEncryptionKey.
|
||||
await expect(
|
||||
db.storage().decrypt(newMasterKey!, user!.dataEncryptionKey!)
|
||||
).resolves.toBeDefined();
|
||||
|
||||
// (b) The OLD master key must NOT be able to decrypt it anymore.
|
||||
// We derive it without persisting it to the key store.
|
||||
const oldMasterKey = await db.storage().generateCryptoKey(
|
||||
"oldpassword",
|
||||
salt
|
||||
);
|
||||
await expect(
|
||||
db.storage().decrypt(oldMasterKey, user!.dataEncryptionKey!)
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
test("empty new password throws 'New password is required'", async () => {
|
||||
await databaseTest().then(async (db) => {
|
||||
await setupLoggedInUser(db, "oldpassword");
|
||||
await expect(
|
||||
db.user.changePassword("oldpassword", "")
|
||||
).rejects.toThrow("New password is required");
|
||||
});
|
||||
});
|
||||
|
||||
test("no logged in user throws", async () => {
|
||||
await databaseTest().then(async (db) => {
|
||||
await expect(
|
||||
db.user.changePassword("oldpassword", "newpassword")
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
test("keys are rewrapped: old master key can no longer unwrap", async () => {
|
||||
await databaseTest().then(async (db) => {
|
||||
const { masterKey: oldMasterKey } = await setupLoggedInUser(db, "oldpassword");
|
||||
await db.user.changePassword("oldpassword", "newpassword");
|
||||
|
||||
const user = await db.user.getUser();
|
||||
const km = new KeyManager(db);
|
||||
|
||||
if (user.attachmentsKey) {
|
||||
await expect(
|
||||
km.unwrapKey(user.attachmentsKey, oldMasterKey!)
|
||||
).rejects.toThrow();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("new master key can unwrap all rewrapped keys", async () => {
|
||||
await databaseTest().then(async (db) => {
|
||||
await setupLoggedInUser(db, "oldpassword");
|
||||
await db.user.changePassword("oldpassword", "newpassword");
|
||||
|
||||
const newMasterKey = await db.user.getMasterKey();
|
||||
const user = await db.user.getUser();
|
||||
const km = new KeyManager(db);
|
||||
|
||||
if (user.attachmentsKey) {
|
||||
const key = await km.unwrapKey(user.attachmentsKey, newMasterKey!);
|
||||
expect(key).toBeDefined();
|
||||
}
|
||||
if (user.monographPasswordsKey) {
|
||||
const key = await km.unwrapKey(user.monographPasswordsKey, newMasterKey!);
|
||||
expect(key).toBeDefined();
|
||||
}
|
||||
if (user.dataEncryptionKey) {
|
||||
const key = await km.unwrapKey(user.dataEncryptionKey, newMasterKey!);
|
||||
expect(key).toBeDefined();
|
||||
}
|
||||
if (user.legacyDataEncryptionKey) {
|
||||
const key = await km.unwrapKey(user.legacyDataEncryptionKey, newMasterKey!);
|
||||
expect(key).toBeDefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("rewrapped user keys are stored with new ciphertext", async () => {
|
||||
await databaseTest().then(async (db) => {
|
||||
await setupLoggedInUser(db, "oldpassword");
|
||||
const km = new KeyManager(db);
|
||||
|
||||
// Fetch the attachmentsKey BEFORE password change
|
||||
const oldKey = await km.get("attachmentsKey", {
|
||||
useCache: false,
|
||||
refetchUser: false
|
||||
});
|
||||
expect(oldKey).toBeDefined();
|
||||
const oldCipher = (oldKey as any).cipher;
|
||||
|
||||
// Change password — keys are rewrapped and user is updated
|
||||
await db.user.changePassword("oldpassword", "newpassword");
|
||||
|
||||
// The new user object in KV should have a rewrapped attachmentsKey
|
||||
// (with new ciphertext)
|
||||
const newKey = await km.get("attachmentsKey", {
|
||||
useCache: false,
|
||||
refetchUser: false
|
||||
});
|
||||
expect(newKey).toBeDefined();
|
||||
// The re-fetched cipher MUST differ from the old one because the key
|
||||
// was re-wrapped with a new master key (different ciphertext).
|
||||
expect((newKey as any).cipher).not.toBe(oldCipher);
|
||||
|
||||
// Verify the new key can be unwrapped with the new master key
|
||||
const newMasterKey = await db.user.getMasterKey();
|
||||
const unwrapped = await km.unwrapKey(newKey, newMasterKey!);
|
||||
expect(unwrapped).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── _updatePassword (reset) ───────────────────────────────────────
|
||||
|
||||
describe("UserManager._updatePassword (reset)", () => {
|
||||
test("successful reset with explicit encryption key", async () => {
|
||||
await databaseTest().then(async (db) => {
|
||||
const { password, salt } = await setupLoggedInUser(db, "oldpassword");
|
||||
const result = await db.user.resetPassword({
|
||||
newPassword: "newpassword",
|
||||
encryptionKey: { password, salt }
|
||||
});
|
||||
expect(result).toBe(true);
|
||||
const newMasterKey = await db.user.getMasterKey();
|
||||
expect(newMasterKey).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
test("reset without encryption key throws", async () => {
|
||||
await databaseTest().then(async (db) => {
|
||||
await setupLoggedInUser(db, "oldpassword");
|
||||
await expect(
|
||||
db.user.resetPassword({ newPassword: "newpassword" } as any)
|
||||
).rejects.toThrow("Encryption key is required.");
|
||||
});
|
||||
});
|
||||
|
||||
test("empty new password throws 'New password is required'", async () => {
|
||||
await databaseTest().then(async (db) => {
|
||||
const { password, salt } = await setupLoggedInUser(db, "oldpassword");
|
||||
await expect(
|
||||
db.user.resetPassword({ newPassword: "", encryptionKey: { password, salt } })
|
||||
).rejects.toThrow("New password is required");
|
||||
});
|
||||
});
|
||||
|
||||
test("reset with wrong encryption key throws during verification", async () => {
|
||||
await databaseTest().then(async (db) => {
|
||||
await setupLoggedInUser(db, "oldpassword");
|
||||
await expect(
|
||||
db.user.resetPassword({
|
||||
newPassword: "newpassword",
|
||||
encryptionKey: { password: "wrongpassword", salt: "wrongsalt" }
|
||||
})
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Key migration scenarios ───────────────────────────────────────
|
||||
|
||||
describe("Key migration during password change", () => {
|
||||
test("legacy user (no DEK, no legacy DEK) gets new DEK and legacy DEK created", async () => {
|
||||
await databaseTest().then(async (db) => {
|
||||
const password = "oldpassword";
|
||||
const salt = randomBytes(16).toString("base64");
|
||||
await db.user.setUser({ ...FULL_USER, salt });
|
||||
await db.storage().deriveCryptoKey({ password, salt });
|
||||
|
||||
await db.kv().write("token", {
|
||||
access_token: "fake-token", t: Date.now(), expires_in: 3600,
|
||||
scope: "notesnook.sync offline_access IdentityServerApi", refresh_token: "fake-refresh"
|
||||
});
|
||||
|
||||
const km = new KeyManager(db);
|
||||
const masterKey = await db.user.getMasterKey();
|
||||
const randomKey = await db.crypto().generateRandomKey();
|
||||
const attachmentsKey = await km.wrapKey(randomKey, masterKey!);
|
||||
|
||||
await db.user.setUser({ ...FULL_USER, salt, attachmentsKey });
|
||||
|
||||
const result = await db.user.changePassword("oldpassword", "newpassword");
|
||||
expect(result).toBe(true);
|
||||
|
||||
const user = await db.user.getUser();
|
||||
expect(user.dataEncryptionKey).toBeDefined();
|
||||
expect(user.legacyDataEncryptionKey).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
test("user with both DEK and legacy DEK: both are rewrapped", async () => {
|
||||
await databaseTest().then(async (db) => {
|
||||
await setupLoggedInUser(db, "oldpassword");
|
||||
const userBefore = await db.user.getUser();
|
||||
const originalDEKCipher = userBefore.dataEncryptionKey?.cipher;
|
||||
const originalLegacyDEKCipher = userBefore.legacyDataEncryptionKey?.cipher;
|
||||
|
||||
await db.user.changePassword("oldpassword", "newpassword");
|
||||
|
||||
const userAfter = await db.user.getUser();
|
||||
expect(userAfter.dataEncryptionKey?.cipher).not.toBe(originalDEKCipher);
|
||||
expect(userAfter.legacyDataEncryptionKey?.cipher).not.toBe(originalLegacyDEKCipher);
|
||||
});
|
||||
});
|
||||
|
||||
test("user with only DEK (no legacy): password change succeeds verification", async () => {
|
||||
await databaseTest().then(async (db) => {
|
||||
const password = "oldpassword";
|
||||
const salt = randomBytes(16).toString("base64");
|
||||
await db.user.setUser({ ...FULL_USER, salt });
|
||||
await db.storage().deriveCryptoKey({ password, salt });
|
||||
|
||||
await db.kv().write("token", {
|
||||
access_token: "fake-token", t: Date.now(), expires_in: 3600,
|
||||
scope: "notesnook.sync offline_access IdentityServerApi", refresh_token: "fake-refresh"
|
||||
});
|
||||
|
||||
const km = new KeyManager(db);
|
||||
const masterKey = await db.user.getMasterKey();
|
||||
const randomKey = await db.crypto().generateRandomKey();
|
||||
const dek = await km.wrapKey(randomKey, masterKey!);
|
||||
|
||||
await db.user.setUser({ ...FULL_USER, salt, dataEncryptionKey: dek });
|
||||
|
||||
// A single DEK is now a valid verifier, so the change should succeed
|
||||
// and the DEK should be rewrapped with the new password.
|
||||
const result = await db.user.changePassword("oldpassword", "newpassword");
|
||||
expect(result).toBe(true);
|
||||
|
||||
const userAfter = await db.user.getUser();
|
||||
expect(userAfter.dataEncryptionKey).toBeDefined();
|
||||
expect(userAfter.dataEncryptionKey?.cipher).not.toBe(dek.cipher);
|
||||
});
|
||||
});
|
||||
|
||||
test("inbox keys are rewrapped during password change", async () => {
|
||||
await databaseTest().then(async (db) => {
|
||||
await setupLoggedInUser(db, "oldpassword");
|
||||
|
||||
const km = new KeyManager(db);
|
||||
const masterKey = await db.user.getMasterKey();
|
||||
// Use a fake keypair since generatePGPKeyPair depends on unavailable sodium API
|
||||
const fakeKeyPair = { publicKey: "fake-public-key", privateKey: "fake-private-key" };
|
||||
const wrappedInboxKeys = await km.wrapKey(fakeKeyPair as any, masterKey!);
|
||||
|
||||
const user = await db.user.getUser();
|
||||
await db.user.setUser({ ...user, inboxKeys: wrappedInboxKeys });
|
||||
|
||||
await db.user.changePassword("oldpassword", "newpassword");
|
||||
|
||||
const userAfter = await db.user.getUser();
|
||||
const newMasterKey = await db.user.getMasterKey();
|
||||
expect(userAfter.inboxKeys).toBeDefined();
|
||||
const unwrappedKeypair = await km.unwrapKey(userAfter.inboxKeys, newMasterKey!);
|
||||
expect((unwrappedKeypair as any).publicKey).toBe(fakeKeyPair.publicKey);
|
||||
expect((unwrappedKeypair as any).privateKey).toBe(fakeKeyPair.privateKey);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── getDataEncryptionKeys ─────────────────────────────────────────
|
||||
|
||||
describe("UserManager.getDataEncryptionKeys", () => {
|
||||
test("returns LEGACY version when no DEK exists", async () => {
|
||||
await databaseTest().then(async (db) => {
|
||||
const salt = randomBytes(16).toString("base64");
|
||||
await db.user.setUser({ ...FULL_USER, salt });
|
||||
await db.storage().deriveCryptoKey({ password: "password", salt });
|
||||
|
||||
const keys = await db.user.getDataEncryptionKeys();
|
||||
expect(keys).toBeDefined();
|
||||
expect(keys!.length).toBe(1);
|
||||
expect(keys![0].version).toBe(0); // KEY_VERSION.LEGACY
|
||||
});
|
||||
});
|
||||
|
||||
test("returns DEK version when DEK exists", async () => {
|
||||
await databaseTest().then(async (db) => {
|
||||
await setupLoggedInUser(db);
|
||||
const keys = await db.user.getDataEncryptionKeys();
|
||||
expect(keys).toBeDefined();
|
||||
expect(keys!.length).toBeGreaterThanOrEqual(1);
|
||||
const dekVersion = keys!.find((k: any) => k.version === 1);
|
||||
expect(dekVersion).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
test("returns both LEGACY and DEK when both exist", async () => {
|
||||
await databaseTest().then(async (db) => {
|
||||
await setupLoggedInUser(db);
|
||||
const keys = await db.user.getDataEncryptionKeys();
|
||||
expect(keys).toBeDefined();
|
||||
expect(keys!.length).toBe(2);
|
||||
const versions = keys!.map((k: any) => k.version);
|
||||
expect(versions).toContain(0);
|
||||
expect(versions).toContain(1);
|
||||
});
|
||||
});
|
||||
|
||||
test("returns undefined when no master key exists", async () => {
|
||||
await databaseTest().then(async (db) => {
|
||||
const keys = await db.user.getDataEncryptionKeys();
|
||||
expect(keys).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -24,7 +24,7 @@ import TokenManager from "./token-manager.js";
|
||||
import { EV, EVENTS } from "../common.js";
|
||||
import { HealthCheck } from "./healthcheck.js";
|
||||
import Database from "./index.js";
|
||||
import { SerializedKeyPair, SerializedKey } from "@notesnook/crypto";
|
||||
import { SerializedKeyPair, SerializedKey, Cipher } from "@notesnook/crypto";
|
||||
import { logger } from "../logger.js";
|
||||
import { KEY_VERSION, KeyVersion } from "./sync/types.js";
|
||||
import {
|
||||
@@ -394,9 +394,13 @@ class UserManager {
|
||||
);
|
||||
}
|
||||
|
||||
resetPassword(newPassword: string) {
|
||||
resetPassword(options: {
|
||||
newPassword: string;
|
||||
encryptionKey: SerializedKey;
|
||||
}) {
|
||||
return this._updatePassword("reset", {
|
||||
new_password: newPassword
|
||||
new_password: options.newPassword,
|
||||
encryptionKey: options.encryptionKey
|
||||
});
|
||||
}
|
||||
|
||||
@@ -622,6 +626,47 @@ class UserManager {
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchEncryptionVerifier(): Promise<
|
||||
Cipher<"base64"> | undefined
|
||||
> {
|
||||
const token = await this.tokenManager.getAccessToken();
|
||||
return http.get(`${constants.API_HOST}/users/verifier`, token);
|
||||
}
|
||||
|
||||
async verifyEncryptionKey(key: SerializedKey) {
|
||||
const user = await this.getUser();
|
||||
if (!user) throw new Error("User not found.");
|
||||
|
||||
const verifiers = [
|
||||
user.attachmentsKey,
|
||||
user.monographPasswordsKey,
|
||||
user.legacyDataEncryptionKey,
|
||||
user.dataEncryptionKey,
|
||||
user.inboxKeys?.private
|
||||
].filter((v): v is Cipher<"base64"> => !!v);
|
||||
|
||||
if (verifiers.length === 0) {
|
||||
const verifier = await this.fetchEncryptionVerifier();
|
||||
if (verifier) verifiers.push(verifier);
|
||||
else
|
||||
throw new Error(
|
||||
"Encryption key cannot be verified: no encryption verifier found."
|
||||
);
|
||||
}
|
||||
|
||||
for (const verifier of verifiers) {
|
||||
const decryptedData = await this.db
|
||||
.storage()
|
||||
.decrypt(key, verifier)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
if (!decryptedData)
|
||||
throw new Error(
|
||||
"Your data cannot be decrypted using the provided encryption key."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async _updatePassword(
|
||||
type: "change" | "reset",
|
||||
data: {
|
||||
@@ -630,15 +675,77 @@ class UserManager {
|
||||
encryptionKey?: SerializedKey;
|
||||
}
|
||||
) {
|
||||
const { new_password, old_password } = data;
|
||||
if (!new_password) throw new Error("New password is required.");
|
||||
|
||||
const token = await this.tokenManager.getAccessToken();
|
||||
const user = await this.getUser();
|
||||
if (!token || !user) throw new Error("You are not logged in.");
|
||||
|
||||
const { email, salt } = user;
|
||||
|
||||
const { new_password, old_password } = data;
|
||||
if (old_password && !(await this.verifyPassword(old_password)))
|
||||
throw new Error("Incorrect old password.");
|
||||
if (old_password && !data.encryptionKey)
|
||||
data.encryptionKey = await this.getMasterKey();
|
||||
|
||||
if (!data.encryptionKey) throw new Error("Encryption key is required.");
|
||||
|
||||
// we must be 100% sure that the provided encryption key is valid before
|
||||
// proceeding
|
||||
await this.verifyEncryptionKey(data.encryptionKey);
|
||||
|
||||
const updateUserPayload: Partial<User> = {};
|
||||
const newMasterKey = await this.db
|
||||
.storage()
|
||||
.generateCryptoKey(new_password, salt);
|
||||
|
||||
if (user.attachmentsKey) {
|
||||
updateUserPayload.attachmentsKey = await this.keyManager.rewrapKey(
|
||||
user.attachmentsKey,
|
||||
data.encryptionKey,
|
||||
newMasterKey
|
||||
);
|
||||
}
|
||||
if (user.monographPasswordsKey) {
|
||||
updateUserPayload.monographPasswordsKey = await this.keyManager.rewrapKey(
|
||||
user.monographPasswordsKey,
|
||||
data.encryptionKey,
|
||||
newMasterKey
|
||||
);
|
||||
}
|
||||
if (user.inboxKeys) {
|
||||
updateUserPayload.inboxKeys = await this.keyManager.rewrapKey(
|
||||
user.inboxKeys,
|
||||
data.encryptionKey,
|
||||
newMasterKey
|
||||
);
|
||||
}
|
||||
|
||||
if (user.legacyDataEncryptionKey)
|
||||
updateUserPayload.legacyDataEncryptionKey =
|
||||
await this.keyManager.rewrapKey(
|
||||
user.legacyDataEncryptionKey,
|
||||
data.encryptionKey,
|
||||
newMasterKey
|
||||
);
|
||||
if (user.dataEncryptionKey)
|
||||
updateUserPayload.dataEncryptionKey = await this.keyManager.rewrapKey(
|
||||
user.dataEncryptionKey,
|
||||
data.encryptionKey,
|
||||
newMasterKey
|
||||
);
|
||||
|
||||
if (!user.legacyDataEncryptionKey && !user.dataEncryptionKey) {
|
||||
updateUserPayload.dataEncryptionKey = await this.keyManager.wrapKey(
|
||||
await this.db.crypto().generateRandomKey(),
|
||||
newMasterKey
|
||||
);
|
||||
updateUserPayload.legacyDataEncryptionKey = await this.keyManager.wrapKey(
|
||||
data.encryptionKey,
|
||||
newMasterKey
|
||||
);
|
||||
}
|
||||
|
||||
const oldPassword = old_password
|
||||
? // we don't lowercase email here to allow user accounts with
|
||||
@@ -649,61 +756,6 @@ class UserManager {
|
||||
})
|
||||
: null;
|
||||
|
||||
if (!new_password) throw new Error("New password is required.");
|
||||
|
||||
data.encryptionKey = data.encryptionKey || (await this.getMasterKey());
|
||||
|
||||
const updateUserPayload: Partial<User> = {};
|
||||
if (data.encryptionKey) {
|
||||
const newMasterKey = await this.db
|
||||
.storage()
|
||||
.generateCryptoKey(new_password, salt);
|
||||
if (user.attachmentsKey) {
|
||||
updateUserPayload.attachmentsKey = await this.keyManager.rewrapKey(
|
||||
user.attachmentsKey,
|
||||
data.encryptionKey,
|
||||
newMasterKey
|
||||
);
|
||||
}
|
||||
if (user.monographPasswordsKey) {
|
||||
updateUserPayload.monographPasswordsKey =
|
||||
await this.keyManager.rewrapKey(
|
||||
user.monographPasswordsKey,
|
||||
data.encryptionKey,
|
||||
newMasterKey
|
||||
);
|
||||
}
|
||||
if (user.inboxKeys) {
|
||||
updateUserPayload.inboxKeys = await this.keyManager.rewrapKey(
|
||||
user.inboxKeys,
|
||||
data.encryptionKey,
|
||||
newMasterKey
|
||||
);
|
||||
}
|
||||
|
||||
if (user.legacyDataEncryptionKey)
|
||||
updateUserPayload.legacyDataEncryptionKey =
|
||||
await this.keyManager.rewrapKey(
|
||||
user.legacyDataEncryptionKey,
|
||||
data.encryptionKey,
|
||||
newMasterKey
|
||||
);
|
||||
if (user.dataEncryptionKey)
|
||||
updateUserPayload.dataEncryptionKey = await this.keyManager.rewrapKey(
|
||||
user.dataEncryptionKey,
|
||||
data.encryptionKey,
|
||||
newMasterKey
|
||||
);
|
||||
else {
|
||||
updateUserPayload.dataEncryptionKey = await this.keyManager.wrapKey(
|
||||
await this.db.crypto().generateRandomKey(),
|
||||
newMasterKey
|
||||
);
|
||||
updateUserPayload.legacyDataEncryptionKey =
|
||||
await this.keyManager.wrapKey(data.encryptionKey, newMasterKey);
|
||||
}
|
||||
}
|
||||
|
||||
await http.patch.json(
|
||||
`${constants.API_HOST}/users/password/${type}`,
|
||||
{
|
||||
|
||||
276
packages/crypto/__tests__/crypto.test.ts
Normal file
276
packages/crypto/__tests__/crypto.test.ts
Normal file
@@ -0,0 +1,276 @@
|
||||
/*
|
||||
This file is part of the Notesnook project (https://notesnook.com/)
|
||||
|
||||
Copyright (C) 2023 Streetwriters (Private) Limited
|
||||
|
||||
This program 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.
|
||||
|
||||
This program 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 this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { test, expect, describe } from "vitest";
|
||||
import { NNCrypto } from "../src/index.js";
|
||||
import { randomBytes } from "crypto";
|
||||
|
||||
const crypto = new NNCrypto();
|
||||
|
||||
function validSalt() {
|
||||
return randomBytes(16).toString("base64");
|
||||
}
|
||||
|
||||
// ─── Key Derivation ────────────────────────────────────────────────
|
||||
|
||||
describe("KeyUtils.deriveKey", () => {
|
||||
test("same password + salt produces same key", async () => {
|
||||
const salt = validSalt();
|
||||
const key1 = await crypto.deriveKey("password123", salt);
|
||||
const key2 = await crypto.deriveKey("password123", salt);
|
||||
expect(key1.key).toEqual(key2.key);
|
||||
expect(key1.salt).toBe(key2.salt);
|
||||
});
|
||||
|
||||
test("different passwords produce different keys", async () => {
|
||||
const salt = validSalt();
|
||||
const key1 = await crypto.deriveKey("password1", salt);
|
||||
const key2 = await crypto.deriveKey("password2", salt);
|
||||
expect(key1.key).not.toEqual(key2.key);
|
||||
});
|
||||
|
||||
test("different salts produce different keys", async () => {
|
||||
const salt1 = validSalt();
|
||||
const salt2 = validSalt();
|
||||
const key1 = await crypto.deriveKey("password", salt1);
|
||||
const key2 = await crypto.deriveKey("password", salt2);
|
||||
expect(key1.key).not.toEqual(key2.key);
|
||||
});
|
||||
|
||||
test("without salt generates a random salt", async () => {
|
||||
const key1 = await crypto.deriveKey("password");
|
||||
const key2 = await crypto.deriveKey("password");
|
||||
expect(key1.salt).not.toBe(key2.salt);
|
||||
expect(key1.key).not.toEqual(key2.key);
|
||||
});
|
||||
|
||||
test("exportKey returns SerializedKey with base64 key and salt", async () => {
|
||||
const salt = validSalt();
|
||||
const exported = await crypto.exportKey("password", salt);
|
||||
expect(exported.key).toBeDefined();
|
||||
expect(exported.salt).toBe(salt);
|
||||
expect(typeof exported.key).toBe("string");
|
||||
expect(exported.key!.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("exportKey without salt generates one", async () => {
|
||||
const exported = await crypto.exportKey("password");
|
||||
expect(exported.key).toBeDefined();
|
||||
expect(exported.salt).toBeDefined();
|
||||
expect(typeof exported.salt).toBe("string");
|
||||
});
|
||||
|
||||
test("deriveKey produces 32-byte key", async () => {
|
||||
const salt = validSalt();
|
||||
const key = await crypto.deriveKey("password", salt);
|
||||
// libsodium crypto_aead_xchacha20poly1305_ietf_KEYBYTES = 32
|
||||
expect(key.key.length).toBe(32);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Symmetric Encryption Round-Trip ───────────────────────────────
|
||||
|
||||
describe("Encryption/Decryption round-trip", () => {
|
||||
test("text encrypt + decrypt round-trip", async () => {
|
||||
const key = await crypto.exportKey("testpassword", validSalt());
|
||||
const plaintext = "Hello, Notesnook!";
|
||||
const cipher = await crypto.encrypt(key, plaintext, "text", "base64");
|
||||
|
||||
expect(cipher.cipher).toBeDefined();
|
||||
expect(cipher.iv).toBeDefined();
|
||||
expect(cipher.salt).toBeDefined();
|
||||
expect(cipher.format).toBe("base64");
|
||||
expect(cipher.alg).toContain("xcha");
|
||||
expect(cipher.length).toBe(plaintext.length);
|
||||
|
||||
const decrypted = await crypto.decrypt(key, cipher, "text");
|
||||
expect(decrypted).toBe(plaintext);
|
||||
});
|
||||
|
||||
test("base64 input encrypt + decrypt round-trip", async () => {
|
||||
const key = await crypto.exportKey("testpassword", validSalt());
|
||||
const plaintext = "SGVsbG8gV29ybGQ=";
|
||||
const cipher = await crypto.encrypt(key, plaintext, "base64", "base64");
|
||||
const decrypted = await crypto.decrypt(key, cipher, "base64");
|
||||
expect(decrypted).toBe(plaintext);
|
||||
});
|
||||
|
||||
test("empty string encrypt + decrypt round-trip", async () => {
|
||||
const key = await crypto.exportKey("testpassword", validSalt());
|
||||
const plaintext = "";
|
||||
const cipher = await crypto.encrypt(key, plaintext, "text", "base64");
|
||||
const decrypted = await crypto.decrypt(key, cipher, "text");
|
||||
expect(decrypted).toBe(plaintext);
|
||||
});
|
||||
|
||||
test("unicode string encrypt + decrypt round-trip", async () => {
|
||||
const key = await crypto.exportKey("testpassword", validSalt());
|
||||
const plaintext = "Hello \u{1F30D}! Testing \u{00E9}mojis and \u{65E5}\u{672C}\u{8A9E}";
|
||||
const cipher = await crypto.encrypt(key, plaintext, "text", "base64");
|
||||
const decrypted = await crypto.decrypt(key, cipher, "text");
|
||||
expect(decrypted).toBe(plaintext);
|
||||
});
|
||||
|
||||
test("long text encrypt + decrypt round-trip", async () => {
|
||||
const key = await crypto.exportKey("testpassword", validSalt());
|
||||
const plaintext = "A".repeat(100000);
|
||||
const cipher = await crypto.encrypt(key, plaintext, "text", "base64");
|
||||
const decrypted = await crypto.decrypt(key, cipher, "text");
|
||||
expect(decrypted).toBe(plaintext);
|
||||
});
|
||||
|
||||
test("decrypt with wrong key throws", async () => {
|
||||
const salt = validSalt();
|
||||
const key1 = await crypto.exportKey("password1", salt);
|
||||
const key2 = await crypto.exportKey("password2", salt);
|
||||
const cipher = await crypto.encrypt(key1, "secret", "text", "base64");
|
||||
|
||||
await expect(crypto.decrypt(key2, cipher, "text")).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("each encryption produces unique ciphertext (random nonce)", async () => {
|
||||
const key = await crypto.exportKey("testpassword", validSalt());
|
||||
const cipher1 = await crypto.encrypt(key, "same data", "text", "base64");
|
||||
const cipher2 = await crypto.encrypt(key, "same data", "text", "base64");
|
||||
expect(cipher1.cipher).not.toBe(cipher2.cipher);
|
||||
expect(cipher1.iv).not.toBe(cipher2.iv);
|
||||
});
|
||||
|
||||
test("cipher with salt in key but no salt on key uses cipher salt", async () => {
|
||||
const key = await crypto.exportKey("testpassword", validSalt());
|
||||
const cipher = await crypto.encrypt(key, "data", "text", "base64");
|
||||
|
||||
// key without salt - should use cipher's salt
|
||||
const keyWithoutSalt = { key: key.key };
|
||||
const decrypted = await crypto.decrypt(keyWithoutSalt, cipher, "text");
|
||||
expect(decrypted).toBe("data");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Multi-Encryption ──────────────────────────────────────────────
|
||||
|
||||
describe("encryptMulti / decryptMulti round-trip", () => {
|
||||
test("multiple items encrypt + decrypt round-trip", async () => {
|
||||
const key = await crypto.exportKey("testpassword", validSalt());
|
||||
const items = ["item1", "item2", "item3", "hello world"];
|
||||
const ciphers = await crypto.encryptMulti(key, items, "text", "base64");
|
||||
|
||||
expect(ciphers.length).toBe(items.length);
|
||||
|
||||
const decrypted = await crypto.decryptMulti(key, ciphers, "text");
|
||||
expect(decrypted).toEqual(items);
|
||||
});
|
||||
|
||||
test("empty array encrypt + decrypt round-trip", async () => {
|
||||
const key = await crypto.exportKey("testpassword", validSalt());
|
||||
const ciphers = await crypto.encryptMulti(key, [], "text", "base64");
|
||||
expect(ciphers.length).toBe(0);
|
||||
|
||||
const decrypted = await crypto.decryptMulti(key, ciphers, "text");
|
||||
expect(decrypted.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Password Hashing ──────────────────────────────────────────────
|
||||
|
||||
describe("Password.hash", () => {
|
||||
test("same password + salt produces same hash", async () => {
|
||||
const hash1 = await crypto.hash("mypassword", "salt@email.com");
|
||||
const hash2 = await crypto.hash("mypassword", "salt@email.com");
|
||||
expect(hash1).toBe(hash2);
|
||||
});
|
||||
|
||||
test("different passwords produce different hashes", async () => {
|
||||
const hash1 = await crypto.hash("password1", "salt@email.com");
|
||||
const hash2 = await crypto.hash("password2", "salt@email.com");
|
||||
expect(hash1).not.toBe(hash2);
|
||||
});
|
||||
|
||||
test("different salts produce different hashes", async () => {
|
||||
const hash1 = await crypto.hash("password", "email1@example.com");
|
||||
const hash2 = await crypto.hash("password", "email2@example.com");
|
||||
expect(hash1).not.toBe(hash2);
|
||||
});
|
||||
|
||||
test("hash is a non-empty string", async () => {
|
||||
const hash = await crypto.hash("password", "email@example.com");
|
||||
expect(typeof hash).toBe("string");
|
||||
expect(hash.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("empty password produces a hash", async () => {
|
||||
const hash = await crypto.hash("", "email@example.com");
|
||||
expect(typeof hash).toBe("string");
|
||||
expect(hash.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Streaming Encryption ──────────────────────────────────────────
|
||||
|
||||
describe("Streaming Encryption/Decryption", () => {
|
||||
test("stream encrypt + decrypt round-trip", async () => {
|
||||
const key = await crypto.exportKey("testpassword", validSalt());
|
||||
|
||||
const { iv, stream: encryptStream } =
|
||||
await crypto.createEncryptionStream(key);
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const chunks: Uint8Array[] = [];
|
||||
|
||||
const writer = encryptStream.writable.getWriter();
|
||||
const reader = encryptStream.readable.getReader();
|
||||
|
||||
writer.write({ data: encoder.encode("chunk1-"), final: false });
|
||||
writer.write({ data: encoder.encode("chunk2-"), final: false });
|
||||
writer.write({ data: encoder.encode("chunk3-final"), final: true });
|
||||
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
chunks.push(value);
|
||||
}
|
||||
|
||||
const decryptStream = await crypto.createDecryptionStream(key, iv);
|
||||
const decryptWriter = decryptStream.writable.getWriter();
|
||||
const decryptReader = decryptStream.readable.getReader();
|
||||
|
||||
for (const chunk of chunks) {
|
||||
decryptWriter.write(chunk);
|
||||
}
|
||||
|
||||
const decryptedChunks: Uint8Array[] = [];
|
||||
while (true) {
|
||||
const { value, done } = await decryptReader.read();
|
||||
if (done) break;
|
||||
decryptedChunks.push(value);
|
||||
}
|
||||
|
||||
const decrypted = new Uint8Array(
|
||||
decryptedChunks.reduce((acc, chunk) => acc + chunk.length, 0)
|
||||
);
|
||||
let offset = 0;
|
||||
for (const chunk of decryptedChunks) {
|
||||
decrypted.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
expect(decoder.decode(decrypted)).toBe("chunk1-chunk2-chunk3-final");
|
||||
});
|
||||
});
|
||||
1454
packages/crypto/package-lock.json
generated
1454
packages/crypto/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -20,12 +20,15 @@
|
||||
"scripts": {
|
||||
"build": "tsdown src/index.ts",
|
||||
"prepublishOnly": "npm run build",
|
||||
"watch": "npm run build -- --watch"
|
||||
"watch": "npm run build -- --watch",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@notesnook/sodium": "file:../sodium"
|
||||
},
|
||||
"devDependencies": {},
|
||||
"devDependencies": {
|
||||
"vitest": "2.1.8"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/streetwriters/notesnook.git",
|
||||
|
||||
26
packages/crypto/vitest.config.ts
Normal file
26
packages/crypto/vitest.config.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
This file is part of the Notesnook project (https://notesnook.com/)
|
||||
|
||||
Copyright (C) 2023 Streetwriters (Private) Limited
|
||||
|
||||
This program 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.
|
||||
|
||||
This program 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 this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["__tests__/**/*.test.ts"]
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user