mirror of
https://github.com/streetwriters/notesnook.git
synced 2026-08-29 10:09:26 +02:00
core: add tests covering password reset & other crypto apis
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
382
packages/core/__tests__/encryption-verification.test.ts
Normal file
382
packages/core/__tests__/encryption-verification.test.ts
Normal file
@@ -0,0 +1,382 @@
|
||||
/*
|
||||
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("Failed to fetch encryption verifier.");
|
||||
});
|
||||
|
||||
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("throws 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 })
|
||||
).rejects.toThrow(
|
||||
"Cannot verify the provided encryption key as user has only a single encryption key."
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("throws 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 })
|
||||
).rejects.toThrow(
|
||||
"Cannot verify the provided encryption key as user has only a single 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
387
packages/core/__tests__/password-change.test.ts
Normal file
387
packages/core/__tests__/password-change.test.ts
Normal file
@@ -0,0 +1,387 @@
|
||||
/*
|
||||
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("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 fails 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 });
|
||||
|
||||
// verifyEncryptionKey throws when only one of legacy DEK or DEK exists
|
||||
await expect(
|
||||
db.user.changePassword("oldpassword", "newpassword")
|
||||
).rejects.toThrow(
|
||||
"Cannot verify the provided encryption key as user has only a single encryption key."
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
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