Compare commits

...

8 Commits

Author SHA1 Message Date
Abdullah Atta
4c702af1ca core: minor refactor + add test for password change (#10291) 2026-08-27 09:55:24 +05:00
Masatoshi Ogiwara
725df6f6f7 web: fix duplicate task id in deleteItemsFromTrash (#10286)
deleteItemsFromTrash reused the "restoreItems" task id from
restoreItemsFromTrash (copy-paste), so a permanent-delete and a
restore running close together shared the same status entry: the one
that finished first cleared the other's progress indicator, and the
displayed status text could reflect the wrong operation. Give the
delete task its own "deleteItems" id.

Signed-off-by: zigzagdev <msts.oo0131@gmail.com>
2026-08-27 09:03:58 +05:00
Abdullah Atta
c9c4936d9e Merge pull request #10251 from streetwriters/core/fix-password-recovery
Fix account recovery
2026-08-25 19:22:07 +05:00
Abdullah Atta
9e251d6f9e core: fix non-legacy users can't change password 2026-08-25 16:12:07 +05:00
01zulfi
9190a1de3f web: persist theme's color scheme when setting a new theme (#10270)
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-08-25 09:46:58 +05:00
Abdullah Atta
a3d672362f core: add tests covering password reset & other crypto apis 2026-08-17 19:12:44 +05:00
Abdullah Atta
04f9ff41a0 web: verify encryption key during password reset 2026-08-17 19:12:28 +05:00
Abdullah Atta
54c97584cf core: add encryption key verification on password change 2026-08-17 19:12:07 +05:00
13 changed files with 3151 additions and 101 deletions

View File

@@ -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({

View File

@@ -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,

View File

@@ -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) => (

View File

@@ -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" };
}
}

View 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);
});
});
});

View 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.");
});
});
});
});

View 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);
});
});
});
});

View 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();
});
});
});

View File

@@ -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}`,
{

View 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");
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -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",

View 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"]
}
});