mirror of
https://github.com/streetwriters/notesnook.git
synced 2026-08-30 02:29:18 +02:00
Compare commits
8 Commits
fix/androi
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c702af1ca | ||
|
|
725df6f6f7 | ||
|
|
c9c4936d9e | ||
|
|
9e251d6f9e | ||
|
|
9190a1de3f | ||
|
|
a3d672362f | ||
|
|
04f9ff41a0 | ||
|
|
54c97584cf |
@@ -214,7 +214,7 @@ async function deleteItemsFromTrash(ids: string[]) {
|
||||
|
||||
await TaskManager.startTask({
|
||||
type: "status",
|
||||
id: "restoreItems",
|
||||
id: "deleteItems",
|
||||
title: strings.inProgressActions.permanentlyDeleting.item(ids.length),
|
||||
action: async (report) => {
|
||||
report({
|
||||
|
||||
@@ -50,6 +50,7 @@ class ThemeStore extends BaseStore<ThemeStore> {
|
||||
|
||||
setTheme = (theme: ThemeDefinition) => {
|
||||
changeDesktopTheme(theme, this.get().followSystemTheme);
|
||||
Config.set("colorScheme", theme.colorScheme);
|
||||
Config.set(`theme:${theme.colorScheme}`, theme);
|
||||
this.set({
|
||||
[getKey(theme)]: theme,
|
||||
|
||||
@@ -30,7 +30,6 @@ import { ErrorText } from "../components/error-text";
|
||||
import { EVENTS, User } from "@notesnook/core";
|
||||
import { RecoveryKeyDialog } from "../dialogs/recovery-key-dialog";
|
||||
import { strings } from "@notesnook/intl";
|
||||
import { useKeyStore } from "../interfaces/key-store";
|
||||
|
||||
type RecoveryMethodType = "key" | "reset";
|
||||
type RecoveryMethodsFormData = Record<string, unknown>;
|
||||
@@ -105,8 +104,8 @@ function useAuthenticateUser({
|
||||
code,
|
||||
userId
|
||||
}: {
|
||||
code: string;
|
||||
userId: string;
|
||||
code?: string;
|
||||
userId?: string;
|
||||
}) {
|
||||
const [isAuthenticating, setIsAuthenticating] = useState(true);
|
||||
const [user, setUser] = useState<User>();
|
||||
@@ -114,6 +113,10 @@ function useAuthenticateUser({
|
||||
async function authenticateUser() {
|
||||
setIsAuthenticating(true);
|
||||
try {
|
||||
if (!code || !userId) {
|
||||
throw new Error("Missing code or userId in query params.");
|
||||
}
|
||||
|
||||
const accessToken = await db.tokenManager.getAccessToken();
|
||||
if (!accessToken) {
|
||||
await db.tokenManager.getAccessTokenFromAuthorizationCode(
|
||||
@@ -181,7 +184,7 @@ function Recovery(props: RecoveryProps) {
|
||||
}}
|
||||
variant={"body"}
|
||||
>
|
||||
{strings.authenticatedAs(user?.email)}
|
||||
{user?.email ? strings.authenticatedAs(user.email) : ""}
|
||||
</Text>
|
||||
<Button
|
||||
sx={{
|
||||
@@ -334,16 +337,25 @@ function RecoveryKeyMethod(props: BaseRecoveryComponentProps<"method:key">) {
|
||||
subtitle: strings.keyRecoveryProgressDesc()
|
||||
}}
|
||||
onSubmit={async (form) => {
|
||||
const user = await db.user.getUser();
|
||||
if (!user) throw new Error(strings.notLoggedIn());
|
||||
|
||||
const recoveryKey = form.recoveryKey;
|
||||
if (recoveryKey.length < 40) {
|
||||
if (
|
||||
!(await db
|
||||
.storage()
|
||||
.encrypt({ key: recoveryKey, salt: user.salt }, "test")
|
||||
.then(() => true)
|
||||
.catch(() => false))
|
||||
) {
|
||||
throw new Error(strings.invalidRecoveryKey());
|
||||
}
|
||||
|
||||
setProgress(0);
|
||||
await db.user.verifyEncryptionKey({
|
||||
key: recoveryKey,
|
||||
salt: user.salt
|
||||
});
|
||||
|
||||
const user = await db.user.getUser();
|
||||
if (!user) throw new Error(strings.notLoggedIn());
|
||||
await useKeyStore.getState().setValue("userEncryptionKey", recoveryKey);
|
||||
navigate("new", form);
|
||||
}}
|
||||
>
|
||||
@@ -365,7 +377,10 @@ function RecoveryKeyMethod(props: BaseRecoveryComponentProps<"method:key">) {
|
||||
>
|
||||
{strings.back()}
|
||||
</Button>
|
||||
<SubmitButton text={strings.startAccountRecovery()} sx={{ flex: 1, mt: 0 }} />
|
||||
<SubmitButton
|
||||
text={strings.startAccountRecovery()}
|
||||
sx={{ flex: 1, mt: 0 }}
|
||||
/>
|
||||
</Flex>
|
||||
|
||||
<Button
|
||||
@@ -405,29 +420,28 @@ function NewPassword(props: BaseRecoveryComponentProps<"new">) {
|
||||
subtitle: strings.resetPasswordWait()
|
||||
}}
|
||||
onSubmit={async (form) => {
|
||||
try {
|
||||
setProgress(0);
|
||||
setProgress(0);
|
||||
const user = await db.user.getUser();
|
||||
if (!user) throw new Error(strings.notLoggedIn());
|
||||
|
||||
if (form.password !== form.confirmPassword)
|
||||
throw new Error("Passwords do not match.");
|
||||
if (!formData?.recoveryKey)
|
||||
throw new Error("Recovery key is required to reset password.");
|
||||
|
||||
if (formData?.userResetRequired && !(await db.user.resetUser()))
|
||||
throw new Error("Failed to reset user.");
|
||||
if (form.password !== form.confirmPassword)
|
||||
throw new Error("Passwords do not match.");
|
||||
|
||||
if (!(await db.user.resetPassword(form.password)))
|
||||
throw new Error("Could not reset account password.");
|
||||
if (formData?.userResetRequired && !(await db.user.resetUser()))
|
||||
throw new Error("Failed to reset user.");
|
||||
|
||||
navigate("final");
|
||||
} catch (e) {
|
||||
if ((e as Error).message === "invalid input") {
|
||||
console.error(e);
|
||||
throw new Error(
|
||||
"Password reset failed because of invalid recovery key"
|
||||
);
|
||||
}
|
||||
if (
|
||||
!(await db.user.resetPassword({
|
||||
encryptionKey: { key: formData?.recoveryKey, salt: user.salt },
|
||||
newPassword: form.password
|
||||
}))
|
||||
)
|
||||
throw new Error("Could not reset account password.");
|
||||
|
||||
throw e;
|
||||
}
|
||||
navigate("final");
|
||||
}}
|
||||
>
|
||||
{(form?: NewPasswordFormData) => (
|
||||
|
||||
@@ -27,7 +27,7 @@ import { IStorage } from "../src/interfaces.js";
|
||||
import { randomBytes } from "crypto";
|
||||
|
||||
export class NodeStorageInterface implements IStorage {
|
||||
storage = {};
|
||||
storage: Record<string, unknown> = {};
|
||||
crypto = new NNCrypto();
|
||||
|
||||
async removeMulti(keys: string[]): Promise<void> {
|
||||
@@ -49,7 +49,7 @@ export class NodeStorageInterface implements IStorage {
|
||||
async readMulti<T>(keys: string[]): Promise<[string, T][]> {
|
||||
const result: [string, T][] = [];
|
||||
keys.forEach((key) => {
|
||||
result.push([key, this.storage[key]]);
|
||||
result.push([key, this.storage[key] as T]);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
@@ -58,7 +58,7 @@ export class NodeStorageInterface implements IStorage {
|
||||
key: string,
|
||||
isArray?: boolean | undefined
|
||||
): Promise<T | undefined> {
|
||||
return this.storage[key];
|
||||
return this.storage[key] as T | undefined;
|
||||
}
|
||||
|
||||
async remove(key: string): Promise<void> {
|
||||
@@ -98,7 +98,11 @@ export class NodeStorageInterface implements IStorage {
|
||||
await this.write(`userEncryptionKey`, keyData.key);
|
||||
}
|
||||
|
||||
async hash(password: string, email: string): Promise<string> {
|
||||
async hash(
|
||||
password: string,
|
||||
email: string,
|
||||
options?: { usesFallback?: boolean }
|
||||
): Promise<string> {
|
||||
const APP_SALT = "oVzKtazBo7d8sb7TBvY9jw";
|
||||
return await this.crypto.hash(password, `${APP_SALT}${email}`);
|
||||
}
|
||||
@@ -113,19 +117,34 @@ export class NodeStorageInterface implements IStorage {
|
||||
password: string,
|
||||
salt?: string | undefined
|
||||
): Promise<SerializedKey> {
|
||||
return { password, salt: salt || randomBytes(16).toString("base64") };
|
||||
const finalSalt = salt || randomBytes(16).toString("base64");
|
||||
return await this.crypto.exportKey(password, finalSalt);
|
||||
}
|
||||
|
||||
generateCryptoKeyPair(): Promise<SerializedKeyPair> {
|
||||
throw new Error("Method not implemented.");
|
||||
async generatePGPKeyPair(): Promise<SerializedKeyPair> {
|
||||
return await this.crypto.exportKeyPair();
|
||||
}
|
||||
generateCryptoKeyFallback(
|
||||
|
||||
async generateCryptoKeyFallback(
|
||||
password: string,
|
||||
salt?: string
|
||||
): Promise<SerializedKey> {
|
||||
return this.generateCryptoKey(password, salt);
|
||||
}
|
||||
|
||||
async deriveCryptoKeyFallback(_credentials: SerializedKey): Promise<void> {}
|
||||
|
||||
async decryptPGPMessage(
|
||||
_privateKeyArmored: string,
|
||||
_encryptedMessage: string
|
||||
): Promise<string> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
deriveCryptoKeyFallback(credentials: SerializedKey): Promise<void> {
|
||||
throw new Error("Method not implemented.");
|
||||
|
||||
async validatePGPKeyPair(_keys: SerializedKeyPair): Promise<{
|
||||
isValid: boolean;
|
||||
message: string;
|
||||
}> {
|
||||
return { isValid: true, message: "ok" };
|
||||
}
|
||||
}
|
||||
|
||||
103
packages/core/__tests__/crypto-primitives.test.ts
Normal file
103
packages/core/__tests__/crypto-primitives.test.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
/*
|
||||
This file is part of the Notesnook project (https://notesnook.com/)
|
||||
|
||||
Copyright (C) 2023 Streetwriters (Private) Limited
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { test, expect, describe } from "vitest";
|
||||
import { databaseTest } from "./utils/index.ts";
|
||||
import { randomBytes } from "../src/utils/random.js";
|
||||
|
||||
function validSalt() {
|
||||
return randomBytes(16).toString("base64");
|
||||
}
|
||||
|
||||
describe("Storage encrypt/decrypt via database", () => {
|
||||
test("encrypt + decrypt round-trip through storage", async () => {
|
||||
await databaseTest().then(async (db) => {
|
||||
const key = await db.crypto().generateRandomKey();
|
||||
const plaintext = "test data for storage";
|
||||
const cipher = await db.storage().encrypt(key, plaintext);
|
||||
const decrypted = await db.storage().decrypt(key, cipher);
|
||||
expect(decrypted).toBe(plaintext);
|
||||
});
|
||||
});
|
||||
|
||||
test("deriveCryptoKey stores key, getCryptoKey retrieves it", async () => {
|
||||
await databaseTest().then(async (db) => {
|
||||
const salt = validSalt();
|
||||
await db.storage().deriveCryptoKey({ password: "mypassword", salt });
|
||||
const storedKey = await db.storage().getCryptoKey();
|
||||
expect(storedKey).toBeDefined();
|
||||
expect(typeof storedKey).toBe("string");
|
||||
expect(storedKey!.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
test("generateCryptoKey returns SerializedKey", async () => {
|
||||
await databaseTest().then(async (db) => {
|
||||
const salt = validSalt();
|
||||
const generated = await db.storage().generateCryptoKey("password", salt);
|
||||
expect(generated.salt).toBe(salt);
|
||||
expect(generated.key).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
test("generateCryptoKey without salt generates one", async () => {
|
||||
await databaseTest().then(async (db) => {
|
||||
const generated = await db.storage().generateCryptoKey("password");
|
||||
expect(generated.salt).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
test("encrypt with derived key works after deriveCryptoKey", async () => {
|
||||
await databaseTest().then(async (db) => {
|
||||
const salt = validSalt();
|
||||
const password = "secure-password";
|
||||
|
||||
await db.storage().deriveCryptoKey({ password, salt });
|
||||
const keyStr = await db.storage().getCryptoKey();
|
||||
|
||||
const key = { key: keyStr!, salt };
|
||||
const plaintext = "encrypted with derived key";
|
||||
const cipher = await db.storage().encrypt(key, plaintext);
|
||||
const decrypted = await db.storage().decrypt(key, cipher);
|
||||
expect(decrypted).toBe(plaintext);
|
||||
});
|
||||
});
|
||||
|
||||
test("deriveCryptoKey with empty password or salt is a no-op", async () => {
|
||||
await databaseTest().then(async (db) => {
|
||||
await db.storage().deriveCryptoKey({ password: "", salt: "salt" });
|
||||
const key = await db.storage().getCryptoKey();
|
||||
expect(key).toBeUndefined();
|
||||
|
||||
await db.storage().deriveCryptoKey({ password: "pass", salt: "" });
|
||||
const key2 = await db.storage().getCryptoKey();
|
||||
expect(key2).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
test("encryptMulti/decryptMulti round-trip through storage", async () => {
|
||||
await databaseTest().then(async (db) => {
|
||||
const key = await db.crypto().generateRandomKey();
|
||||
const items = ["hello", "world", "test123"];
|
||||
const ciphers = await db.storage().encryptMulti(key, items);
|
||||
const decrypted = await db.storage().decryptMulti(key, ciphers);
|
||||
expect(decrypted).toEqual(items);
|
||||
});
|
||||
});
|
||||
});
|
||||
407
packages/core/__tests__/encryption-verification.test.ts
Normal file
407
packages/core/__tests__/encryption-verification.test.ts
Normal file
@@ -0,0 +1,407 @@
|
||||
/*
|
||||
This file is part of the Notesnook project (https://notesnook.com/)
|
||||
|
||||
Copyright (C) 2023 Streetwriters (Private) Limited
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { test, expect, describe, vi } from "vitest";
|
||||
import { databaseTest, loginFakeUser } from "./utils/index.ts";
|
||||
import { KeyManager } from "../src/api/key-manager.js";
|
||||
import { randomBytes } from "../src/utils/random.js";
|
||||
|
||||
// ─── verifyPassword ────────────────────────────────────────────────
|
||||
|
||||
describe("UserManager.verifyPassword", () => {
|
||||
test("correct password returns true", async () => {
|
||||
await databaseTest().then(async (db) => {
|
||||
await loginFakeUser(db);
|
||||
const result = await db.user.verifyPassword("password");
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test("incorrect password returns false", async () => {
|
||||
await databaseTest().then(async (db) => {
|
||||
await loginFakeUser(db);
|
||||
const result = await db.user.verifyPassword("wrongpassword");
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
test("no user returns false", async () => {
|
||||
await databaseTest().then(async (db) => {
|
||||
const result = await db.user.verifyPassword("anything");
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
test("no master key returns false", async () => {
|
||||
await databaseTest().then(async (db) => {
|
||||
const userSalt = randomBytes(16).toString("base64");
|
||||
await db.user.setUser({
|
||||
id: "user-123",
|
||||
email: "test@example.com",
|
||||
isEmailConfirmed: true,
|
||||
salt: userSalt,
|
||||
mfa: { isEnabled: false, primaryMethod: "app", remainingValidCodes: 0 },
|
||||
subscription: { appId: 0, cancelURL: null, expiry: 0, productId: null, provider: "none", start: 0, plan: "free", status: "trial", updateURL: null, googlePurchaseToken: null }
|
||||
} as any);
|
||||
const result = await db.user.verifyPassword("anything");
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── verifyEncryptionKey ───────────────────────────────────────────
|
||||
|
||||
describe("UserManager.verifyEncryptionKey", () => {
|
||||
describe("user with both legacyDataEncryptionKey and dataEncryptionKey", () => {
|
||||
test("valid key does not throw", async () => {
|
||||
await databaseTest().then(async (db) => {
|
||||
const password = "mypassword";
|
||||
const salt = randomBytes(16).toString("base64");
|
||||
// Set up user with crypto key derived from our password
|
||||
await db.user.setUser({
|
||||
id: "user-123", email: "test@example.com", isEmailConfirmed: true,
|
||||
salt, mfa: { isEnabled: false, primaryMethod: "app", remainingValidCodes: 0 },
|
||||
subscription: { appId: 0, cancelURL: null, expiry: 0, productId: null, provider: "none", start: 0, plan: "free", status: "trial", updateURL: null, googlePurchaseToken: null }
|
||||
} as any);
|
||||
await db.storage().deriveCryptoKey({ password, salt });
|
||||
const masterKey = await db.user.getMasterKey();
|
||||
const km = new KeyManager(db);
|
||||
|
||||
const randomKey = await db.crypto().generateRandomKey();
|
||||
const legacyDEK = await km.wrapKey(randomKey, masterKey!);
|
||||
const dek = await km.wrapKey(randomKey, masterKey!);
|
||||
|
||||
const user = await db.user.getUser();
|
||||
await db.user.setUser({ ...user, legacyDataEncryptionKey: legacyDEK, dataEncryptionKey: dek });
|
||||
|
||||
await expect(
|
||||
db.user.verifyEncryptionKey({ password, salt })
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
test("invalid key throws", async () => {
|
||||
await databaseTest().then(async (db) => {
|
||||
const password = "correct";
|
||||
const salt = randomBytes(16).toString("base64");
|
||||
await db.user.setUser({
|
||||
id: "user-123", email: "test@example.com", isEmailConfirmed: true,
|
||||
salt, mfa: { isEnabled: false, primaryMethod: "app", remainingValidCodes: 0 },
|
||||
subscription: { appId: 0, cancelURL: null, expiry: 0, productId: null, provider: "none", start: 0, plan: "free", status: "trial", updateURL: null, googlePurchaseToken: null }
|
||||
} as any);
|
||||
await db.storage().deriveCryptoKey({ password, salt });
|
||||
const masterKey = await db.user.getMasterKey();
|
||||
const km = new KeyManager(db);
|
||||
|
||||
const randomKey = await db.crypto().generateRandomKey();
|
||||
const legacyDEK = await km.wrapKey(randomKey, masterKey!);
|
||||
const dek = await km.wrapKey(randomKey, masterKey!);
|
||||
|
||||
const user = await db.user.getUser();
|
||||
await db.user.setUser({ ...user, legacyDataEncryptionKey: legacyDEK, dataEncryptionKey: dek });
|
||||
|
||||
await expect(
|
||||
db.user.verifyEncryptionKey({ password: "wrongpassword", salt })
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("user with neither legacyDataEncryptionKey nor dataEncryptionKey", () => {
|
||||
test("valid key using attachmentsKey verifier does not throw", async () => {
|
||||
await databaseTest().then(async (db) => {
|
||||
const password = "mypassword";
|
||||
const salt = randomBytes(16).toString("base64");
|
||||
await db.user.setUser({
|
||||
id: "user-123", email: "test@example.com", isEmailConfirmed: true,
|
||||
salt, mfa: { isEnabled: false, primaryMethod: "app", remainingValidCodes: 0 },
|
||||
subscription: { appId: 0, cancelURL: null, expiry: 0, productId: null, provider: "none", start: 0, plan: "free", status: "trial", updateURL: null, googlePurchaseToken: null }
|
||||
} as any);
|
||||
await db.storage().deriveCryptoKey({ password, salt });
|
||||
const masterKey = await db.user.getMasterKey();
|
||||
const km = new KeyManager(db);
|
||||
|
||||
const key = await db.crypto().generateRandomKey();
|
||||
const attachmentsKey = await km.wrapKey(key, masterKey!);
|
||||
|
||||
const user = await db.user.getUser();
|
||||
await db.user.setUser({ ...user, attachmentsKey });
|
||||
|
||||
await expect(
|
||||
db.user.verifyEncryptionKey({ password, salt })
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
test("invalid key using attachmentsKey verifier throws", async () => {
|
||||
await databaseTest().then(async (db) => {
|
||||
const password = "correct";
|
||||
const salt = randomBytes(16).toString("base64");
|
||||
await db.user.setUser({
|
||||
id: "user-123", email: "test@example.com", isEmailConfirmed: true,
|
||||
salt, mfa: { isEnabled: false, primaryMethod: "app", remainingValidCodes: 0 },
|
||||
subscription: { appId: 0, cancelURL: null, expiry: 0, productId: null, provider: "none", start: 0, plan: "free", status: "trial", updateURL: null, googlePurchaseToken: null }
|
||||
} as any);
|
||||
await db.storage().deriveCryptoKey({ password, salt });
|
||||
const masterKey = await db.user.getMasterKey();
|
||||
const km = new KeyManager(db);
|
||||
|
||||
const key = await db.crypto().generateRandomKey();
|
||||
const attachmentsKey = await km.wrapKey(key, masterKey!);
|
||||
|
||||
const user = await db.user.getUser();
|
||||
await db.user.setUser({ ...user, attachmentsKey });
|
||||
|
||||
await expect(
|
||||
db.user.verifyEncryptionKey({ password: "wrongpassword", salt })
|
||||
).rejects.toThrow(
|
||||
"Your data cannot be decrypted using the provided encryption key."
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("uses monographPasswordsKey when no attachmentsKey exists", async () => {
|
||||
await databaseTest().then(async (db) => {
|
||||
const password = "mypassword";
|
||||
const salt = randomBytes(16).toString("base64");
|
||||
await db.user.setUser({
|
||||
id: "user-123", email: "test@example.com", isEmailConfirmed: true,
|
||||
salt, mfa: { isEnabled: false, primaryMethod: "app", remainingValidCodes: 0 },
|
||||
subscription: { appId: 0, cancelURL: null, expiry: 0, productId: null, provider: "none", start: 0, plan: "free", status: "trial", updateURL: null, googlePurchaseToken: null }
|
||||
} as any);
|
||||
await db.storage().deriveCryptoKey({ password, salt });
|
||||
const masterKey = await db.user.getMasterKey();
|
||||
const km = new KeyManager(db);
|
||||
|
||||
const key = await db.crypto().generateRandomKey();
|
||||
const monographKey = await km.wrapKey(key, masterKey!);
|
||||
|
||||
const user = await db.user.getUser();
|
||||
// Ensure no attachmentsKey, only monographPasswordsKey
|
||||
await db.user.setUser({ ...user, attachmentsKey: undefined, monographPasswordsKey: monographKey });
|
||||
|
||||
await expect(
|
||||
db.user.verifyEncryptionKey({ password, salt })
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
test("fetches verifier from server when no local keys exist", async () => {
|
||||
const http = (await import("../src/utils/http.js")).default;
|
||||
const mockGet = vi.spyOn(http, "get").mockResolvedValue(undefined);
|
||||
|
||||
await databaseTest().then(async (db) => {
|
||||
const password = "mypassword";
|
||||
const salt = randomBytes(16).toString("base64");
|
||||
await db.user.setUser({
|
||||
id: "user-123", email: "test@example.com", isEmailConfirmed: true,
|
||||
salt, mfa: { isEnabled: false, primaryMethod: "app", remainingValidCodes: 0 },
|
||||
subscription: { appId: 0, cancelURL: null, expiry: 0, productId: null, provider: "none", start: 0, plan: "free", status: "trial", updateURL: null, googlePurchaseToken: null }
|
||||
} as any);
|
||||
await db.storage().deriveCryptoKey({ password, salt });
|
||||
|
||||
// Set a token so getAccessToken returns something
|
||||
await db.kv().write("token", {
|
||||
access_token: "fake-token",
|
||||
t: Date.now(),
|
||||
expires_in: 3600,
|
||||
scope: "notesnook.sync IdentityServerApi",
|
||||
refresh_token: "fake-refresh"
|
||||
});
|
||||
|
||||
// No attachmentsKey, no monographPasswordsKey
|
||||
// Server returns undefined — should throw
|
||||
await expect(
|
||||
db.user.verifyEncryptionKey({ password, salt })
|
||||
).rejects.toThrow(
|
||||
"Encryption key cannot be verified: no encryption verifier found."
|
||||
);
|
||||
});
|
||||
|
||||
mockGet.mockRestore();
|
||||
});
|
||||
|
||||
test("uses server-provided verifier cipher when available", async () => {
|
||||
const http = (await import("../src/utils/http.js")).default;
|
||||
|
||||
await databaseTest().then(async (db) => {
|
||||
const password = "mypassword";
|
||||
const salt = randomBytes(16).toString("base64");
|
||||
await db.user.setUser({
|
||||
id: "user-123", email: "test@example.com", isEmailConfirmed: true,
|
||||
salt, mfa: { isEnabled: false, primaryMethod: "app", remainingValidCodes: 0 },
|
||||
subscription: { appId: 0, cancelURL: null, expiry: 0, productId: null, provider: "none", start: 0, plan: "free", status: "trial", updateURL: null, googlePurchaseToken: null }
|
||||
} as any);
|
||||
await db.storage().deriveCryptoKey({ password, salt });
|
||||
|
||||
await db.kv().write("token", {
|
||||
access_token: "fake-token",
|
||||
t: Date.now(),
|
||||
expires_in: 3600,
|
||||
scope: "notesnook.sync IdentityServerApi",
|
||||
refresh_token: "fake-refresh"
|
||||
});
|
||||
|
||||
// Create a verifier cipher that the correct key can decrypt
|
||||
const verifierCipher = await db.storage().encrypt(
|
||||
{ password, salt },
|
||||
"test-data"
|
||||
);
|
||||
const mockGet = vi.spyOn(http, "get").mockResolvedValueOnce(verifierCipher);
|
||||
|
||||
await expect(
|
||||
db.user.verifyEncryptionKey({ password, salt })
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith(
|
||||
expect.stringContaining("/users/verifier"),
|
||||
"fake-token"
|
||||
);
|
||||
mockGet.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
test("server verifier rejects wrong key", async () => {
|
||||
const http = (await import("../src/utils/http.js")).default;
|
||||
|
||||
await databaseTest().then(async (db) => {
|
||||
const salt = randomBytes(16).toString("base64");
|
||||
await db.user.setUser({
|
||||
id: "user-123", email: "test@example.com", isEmailConfirmed: true,
|
||||
salt, mfa: { isEnabled: false, primaryMethod: "app", remainingValidCodes: 0 },
|
||||
subscription: { appId: 0, cancelURL: null, expiry: 0, productId: null, provider: "none", start: 0, plan: "free", status: "trial", updateURL: null, googlePurchaseToken: null }
|
||||
} as any);
|
||||
await db.storage().deriveCryptoKey({ password: "correct-password", salt });
|
||||
|
||||
await db.kv().write("token", {
|
||||
access_token: "fake-token",
|
||||
t: Date.now(),
|
||||
expires_in: 3600,
|
||||
scope: "notesnook.sync IdentityServerApi",
|
||||
refresh_token: "fake-refresh"
|
||||
});
|
||||
|
||||
// Verifier encrypted with correct password
|
||||
const verifierCipher = await db.storage().encrypt(
|
||||
{ password: "correct-password", salt },
|
||||
"test-data"
|
||||
);
|
||||
const mockGet = vi.spyOn(http, "get").mockResolvedValueOnce(verifierCipher);
|
||||
|
||||
// Wrong key should fail verification
|
||||
await expect(
|
||||
db.user.verifyEncryptionKey({ password: "wrong-password", salt })
|
||||
).rejects.toThrow(
|
||||
"Your data cannot be decrypted using the provided encryption key."
|
||||
);
|
||||
|
||||
mockGet.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("user with only one of legacy DEK or DEK", () => {
|
||||
test("succeeds when only dataEncryptionKey exists", async () => {
|
||||
await databaseTest().then(async (db) => {
|
||||
const password = "password";
|
||||
const salt = randomBytes(16).toString("base64");
|
||||
await db.user.setUser({
|
||||
id: "user-123", email: "test@example.com", isEmailConfirmed: true,
|
||||
salt, mfa: { isEnabled: false, primaryMethod: "app", remainingValidCodes: 0 },
|
||||
subscription: { appId: 0, cancelURL: null, expiry: 0, productId: null, provider: "none", start: 0, plan: "free", status: "trial", updateURL: null, googlePurchaseToken: null }
|
||||
} as any);
|
||||
await db.storage().deriveCryptoKey({ password, salt });
|
||||
const masterKey = await db.user.getMasterKey();
|
||||
const km = new KeyManager(db);
|
||||
|
||||
const randomKey = await db.crypto().generateRandomKey();
|
||||
const dek = await km.wrapKey(randomKey, masterKey!);
|
||||
|
||||
const user = await db.user.getUser();
|
||||
await db.user.setUser({ ...user, dataEncryptionKey: dek });
|
||||
|
||||
await expect(
|
||||
db.user.verifyEncryptionKey({ password, salt })
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
test("succeeds when only legacyDataEncryptionKey exists", async () => {
|
||||
await databaseTest().then(async (db) => {
|
||||
const password = "password";
|
||||
const salt = randomBytes(16).toString("base64");
|
||||
await db.user.setUser({
|
||||
id: "user-123", email: "test@example.com", isEmailConfirmed: true,
|
||||
salt, mfa: { isEnabled: false, primaryMethod: "app", remainingValidCodes: 0 },
|
||||
subscription: { appId: 0, cancelURL: null, expiry: 0, productId: null, provider: "none", start: 0, plan: "free", status: "trial", updateURL: null, googlePurchaseToken: null }
|
||||
} as any);
|
||||
await db.storage().deriveCryptoKey({ password, salt });
|
||||
const masterKey = await db.user.getMasterKey();
|
||||
const km = new KeyManager(db);
|
||||
|
||||
const randomKey = await db.crypto().generateRandomKey();
|
||||
const legacyDEK = await km.wrapKey(randomKey, masterKey!);
|
||||
|
||||
const user = await db.user.getUser();
|
||||
await db.user.setUser({ ...user, legacyDataEncryptionKey: legacyDEK });
|
||||
|
||||
await expect(
|
||||
db.user.verifyEncryptionKey({ password, salt })
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
test("invalid key throws even when only a single DEK exists", async () => {
|
||||
await databaseTest().then(async (db) => {
|
||||
const password = "correct";
|
||||
const salt = randomBytes(16).toString("base64");
|
||||
await db.user.setUser({
|
||||
id: "user-123", email: "test@example.com", isEmailConfirmed: true,
|
||||
salt, mfa: { isEnabled: false, primaryMethod: "app", remainingValidCodes: 0 },
|
||||
subscription: { appId: 0, cancelURL: null, expiry: 0, productId: null, provider: "none", start: 0, plan: "free", status: "trial", updateURL: null, googlePurchaseToken: null }
|
||||
} as any);
|
||||
await db.storage().deriveCryptoKey({ password, salt });
|
||||
const masterKey = await db.user.getMasterKey();
|
||||
const km = new KeyManager(db);
|
||||
|
||||
const randomKey = await db.crypto().generateRandomKey();
|
||||
const dek = await km.wrapKey(randomKey, masterKey!);
|
||||
|
||||
const user = await db.user.getUser();
|
||||
await db.user.setUser({ ...user, dataEncryptionKey: dek });
|
||||
|
||||
await expect(
|
||||
db.user.verifyEncryptionKey({ password: "wrongpassword", salt })
|
||||
).rejects.toThrow(
|
||||
"Your data cannot be decrypted using the provided encryption key."
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("no user", () => {
|
||||
test("throws when no user exists", async () => {
|
||||
await databaseTest().then(async (db) => {
|
||||
await expect(
|
||||
db.user.verifyEncryptionKey({ password: "test", salt: "salt" })
|
||||
).rejects.toThrow("User not found.");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
274
packages/core/__tests__/key-manager.test.ts
Normal file
274
packages/core/__tests__/key-manager.test.ts
Normal file
@@ -0,0 +1,274 @@
|
||||
/*
|
||||
This file is part of the Notesnook project (https://notesnook.com/)
|
||||
|
||||
Copyright (C) 2023 Streetwriters (Private) Limited
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { test, expect, describe } from "vitest";
|
||||
import { databaseTest, loginFakeUser } from "./utils/index.ts";
|
||||
import { KeyManager } from "../src/api/key-manager.js";
|
||||
import { randomBytes } from "../src/utils/random.js";
|
||||
|
||||
function validSalt() {
|
||||
return randomBytes(16).toString("base64");
|
||||
}
|
||||
|
||||
describe("KeyManager", () => {
|
||||
describe("wrapKey + unwrapKey (symmetric key)", () => {
|
||||
test("round-trip: wrap then unwrap returns original key", async () => {
|
||||
await databaseTest().then(async (db) => {
|
||||
await loginFakeUser(db);
|
||||
const km = new KeyManager(db);
|
||||
|
||||
const masterKey = await db.user.getMasterKey();
|
||||
expect(masterKey).toBeDefined();
|
||||
|
||||
const originalKey = await db.crypto().generateRandomKey();
|
||||
const wrapped = await km.wrapKey(originalKey, masterKey!);
|
||||
|
||||
expect(wrapped).toBeDefined();
|
||||
expect(wrapped).toHaveProperty("cipher");
|
||||
|
||||
const unwrapped = await km.unwrapKey(wrapped, masterKey!);
|
||||
expect(unwrapped).toEqual(originalKey);
|
||||
});
|
||||
});
|
||||
|
||||
test("unwrap with wrong key throws", async () => {
|
||||
await databaseTest().then(async (db) => {
|
||||
await loginFakeUser(db);
|
||||
const km = new KeyManager(db);
|
||||
|
||||
const masterKey = await db.user.getMasterKey();
|
||||
const wrongKey = await db.storage().generateCryptoKey("wrongpassword", validSalt());
|
||||
|
||||
const originalKey = await db.crypto().generateRandomKey();
|
||||
const wrapped = await km.wrapKey(originalKey, masterKey!);
|
||||
|
||||
await expect(km.unwrapKey(wrapped, wrongKey)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
test("wrapped key is a Cipher with base64 format", async () => {
|
||||
await databaseTest().then(async (db) => {
|
||||
await loginFakeUser(db);
|
||||
const km = new KeyManager(db);
|
||||
|
||||
const masterKey = await db.user.getMasterKey();
|
||||
const originalKey = await db.crypto().generateRandomKey();
|
||||
const wrapped = await km.wrapKey(originalKey, masterKey!);
|
||||
|
||||
expect(wrapped).toHaveProperty("format", "base64");
|
||||
expect(wrapped).toHaveProperty("alg");
|
||||
expect(typeof (wrapped as any).cipher).toBe("string");
|
||||
expect(typeof (wrapped as any).iv).toBe("string");
|
||||
expect(typeof (wrapped as any).salt).toBe("string");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("rewrapKey", () => {
|
||||
test("symmetric: rewrap succeeds with new key", async () => {
|
||||
await databaseTest().then(async (db) => {
|
||||
const km = new KeyManager(db);
|
||||
|
||||
const oldKey = await db.storage().generateCryptoKey("oldpassword", validSalt());
|
||||
const newKey = await db.storage().generateCryptoKey("newpassword", validSalt());
|
||||
|
||||
const originalKey = await db.crypto().generateRandomKey();
|
||||
const wrapped = await km.wrapKey(originalKey, oldKey);
|
||||
|
||||
const rewrapped = await km.rewrapKey(wrapped, oldKey, newKey);
|
||||
|
||||
const unwrapped = await km.unwrapKey(rewrapped, newKey);
|
||||
expect(unwrapped).toEqual(originalKey);
|
||||
});
|
||||
});
|
||||
|
||||
test("symmetric: rewrap fails when unwrap with wrong old key", async () => {
|
||||
await databaseTest().then(async (db) => {
|
||||
const km = new KeyManager(db);
|
||||
|
||||
const correctOldKey = await db.storage().generateCryptoKey("correct", validSalt());
|
||||
const wrongOldKey = await db.storage().generateCryptoKey("wrong", validSalt());
|
||||
const newKey = await db.storage().generateCryptoKey("newpassword", validSalt());
|
||||
|
||||
const originalKey = await db.crypto().generateRandomKey();
|
||||
const wrapped = await km.wrapKey(originalKey, correctOldKey);
|
||||
|
||||
await expect(km.rewrapKey(wrapped, wrongOldKey, newKey)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
test("rewrap produces different ciphertext than original", async () => {
|
||||
await databaseTest().then(async (db) => {
|
||||
const km = new KeyManager(db);
|
||||
|
||||
const key1 = await db.storage().generateCryptoKey("password1", validSalt());
|
||||
const key2 = await db.storage().generateCryptoKey("password2", validSalt());
|
||||
|
||||
const originalKey = await db.crypto().generateRandomKey();
|
||||
const wrapped = await km.wrapKey(originalKey, key1);
|
||||
const rewrapped = await km.rewrapKey(wrapped, key1, key2);
|
||||
|
||||
expect((rewrapped as any).cipher).not.toBe((wrapped as any).cipher);
|
||||
expect((rewrapped as any).iv).not.toBe((wrapped as any).iv);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("get() caching", () => {
|
||||
test("get() populates cache and returns cached value", async () => {
|
||||
await databaseTest().then(async (db) => {
|
||||
await loginFakeUser(db);
|
||||
const km = new KeyManager(db);
|
||||
|
||||
const key1 = await km.get("attachmentsKey", { useCache: false, refetchUser: false });
|
||||
expect(key1).toBeDefined();
|
||||
|
||||
const key2 = await km.get("attachmentsKey", { useCache: true, refetchUser: false });
|
||||
expect(key2).toBeDefined();
|
||||
expect(key2).toEqual(key1);
|
||||
});
|
||||
});
|
||||
|
||||
test("clearCache() forces fresh fetch on next get()", async () => {
|
||||
await databaseTest().then(async (db) => {
|
||||
await loginFakeUser(db);
|
||||
const km = new KeyManager(db);
|
||||
|
||||
await km.get("attachmentsKey", { useCache: true, refetchUser: false });
|
||||
km.clearCache();
|
||||
|
||||
const key = await km.get("attachmentsKey", { useCache: false, refetchUser: false });
|
||||
expect(key).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("get() edge cases", () => {
|
||||
test("get() returns undefined when user has no key for that ID", async () => {
|
||||
await databaseTest().then(async (db) => {
|
||||
await loginFakeUser(db);
|
||||
const km = new KeyManager(db);
|
||||
|
||||
const key = await km.get("dataEncryptionKey", { useCache: false, refetchUser: false });
|
||||
expect(key).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
test("get() returns undefined when no user exists", async () => {
|
||||
await databaseTest().then(async (db) => {
|
||||
const km = new KeyManager(db);
|
||||
const key = await km.get("attachmentsKey", { useCache: false, refetchUser: false });
|
||||
expect(key).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
test("get() returns all key types when they exist", async () => {
|
||||
await databaseTest().then(async (db) => {
|
||||
await loginFakeUser(db);
|
||||
const km = new KeyManager(db);
|
||||
|
||||
const masterKey = await db.user.getMasterKey();
|
||||
const randomKey = await db.crypto().generateRandomKey();
|
||||
|
||||
const user = await db.user.getUser();
|
||||
await db.user.setUser({
|
||||
...user,
|
||||
attachmentsKey: await km.wrapKey(randomKey, masterKey!),
|
||||
monographPasswordsKey: await km.wrapKey(randomKey, masterKey!),
|
||||
dataEncryptionKey: await km.wrapKey(randomKey, masterKey!)
|
||||
});
|
||||
|
||||
const attachmentsKey = await km.get("attachmentsKey", { useCache: false, refetchUser: false });
|
||||
const monographKey = await km.get("monographPasswordsKey", { useCache: false, refetchUser: false });
|
||||
const dek = await km.get("dataEncryptionKey", { useCache: false, refetchUser: false });
|
||||
|
||||
expect(attachmentsKey).toBeDefined();
|
||||
expect(monographKey).toBeDefined();
|
||||
expect(dek).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("wrapKey/unwrapKey with password-derived key", () => {
|
||||
test("wrap with password key, derive same password, unwrap succeeds", async () => {
|
||||
await databaseTest().then(async (db) => {
|
||||
const km = new KeyManager(db);
|
||||
const salt = validSalt();
|
||||
const passwordKey = await db.storage().generateCryptoKey("mypassword", salt);
|
||||
|
||||
const originalKey = await db.crypto().generateRandomKey();
|
||||
const wrapped = await km.wrapKey(originalKey, passwordKey);
|
||||
|
||||
const samePasswordKey = await db.storage().generateCryptoKey("mypassword", salt);
|
||||
const unwrapped = await km.unwrapKey(wrapped, samePasswordKey);
|
||||
expect(unwrapped).toEqual(originalKey);
|
||||
});
|
||||
});
|
||||
|
||||
test("wrap with different password fails to unwrap", async () => {
|
||||
await databaseTest().then(async (db) => {
|
||||
const km = new KeyManager(db);
|
||||
|
||||
const key1 = await db.storage().generateCryptoKey("password1", validSalt());
|
||||
const key2 = await db.storage().generateCryptoKey("password2", validSalt());
|
||||
|
||||
const originalKey = await db.crypto().generateRandomKey();
|
||||
const wrapped = await km.wrapKey(originalKey, key1);
|
||||
|
||||
await expect(km.unwrapKey(wrapped, key2)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Data integrity", () => {
|
||||
test("wrap/unwrap preserves large symmetric key", async () => {
|
||||
await databaseTest().then(async (db) => {
|
||||
const km = new KeyManager(db);
|
||||
await loginFakeUser(db);
|
||||
|
||||
const masterKey = await db.user.getMasterKey();
|
||||
const largeKey = await db.storage().generateCryptoKey("x".repeat(1000), validSalt());
|
||||
|
||||
const wrapped = await km.wrapKey(largeKey, masterKey!);
|
||||
const unwrapped = await km.unwrapKey(wrapped, masterKey!);
|
||||
expect(unwrapped).toEqual(largeKey);
|
||||
});
|
||||
});
|
||||
|
||||
test("multiple wrap/unwrap cycles with different keys", async () => {
|
||||
await databaseTest().then(async (db) => {
|
||||
const km = new KeyManager(db);
|
||||
|
||||
let currentKey = await db.storage().generateCryptoKey("initial", validSalt());
|
||||
const originalData = await db.crypto().generateRandomKey();
|
||||
|
||||
const key2 = await db.storage().generateCryptoKey("second", validSalt());
|
||||
const key3 = await db.storage().generateCryptoKey("third", validSalt());
|
||||
|
||||
let wrapped = await km.wrapKey(originalData, currentKey);
|
||||
wrapped = await km.rewrapKey(wrapped, currentKey, key2);
|
||||
wrapped = await km.rewrapKey(wrapped, key2, key3);
|
||||
wrapped = await km.rewrapKey(wrapped, key3, currentKey);
|
||||
|
||||
const unwrapped = await km.unwrapKey(wrapped, currentKey);
|
||||
expect(unwrapped).toEqual(originalData);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
423
packages/core/__tests__/password-change.test.ts
Normal file
423
packages/core/__tests__/password-change.test.ts
Normal file
@@ -0,0 +1,423 @@
|
||||
/*
|
||||
This file is part of the Notesnook project (https://notesnook.com/)
|
||||
|
||||
Copyright (C) 2023 Streetwriters (Private) Limited
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { test, expect, describe, vi } from "vitest";
|
||||
import { databaseTest } from "./utils/index.ts";
|
||||
import { KeyManager } from "../src/api/key-manager.js";
|
||||
import { randomBytes } from "../src/utils/random.js";
|
||||
|
||||
const FULL_USER: any = {
|
||||
id: "user-123",
|
||||
email: "test@example.com",
|
||||
isEmailConfirmed: true,
|
||||
salt: "",
|
||||
mfa: { isEnabled: false, primaryMethod: "app", remainingValidCodes: 0 },
|
||||
subscription: { appId: 0, cancelURL: null, expiry: 0, productId: null, provider: "none", start: 0, plan: "free", status: "trial", updateURL: null, googlePurchaseToken: null }
|
||||
};
|
||||
|
||||
vi.mock("../src/utils/http.js", () => ({
|
||||
default: {
|
||||
get: vi.fn().mockResolvedValue(undefined),
|
||||
post: vi.fn().mockResolvedValue(undefined),
|
||||
patch: { json: vi.fn().mockResolvedValue(undefined) },
|
||||
delete: vi.fn().mockResolvedValue(undefined)
|
||||
}
|
||||
}));
|
||||
|
||||
async function setupLoggedInUser(db: any, password: string = "oldpassword") {
|
||||
const salt = randomBytes(16).toString("base64");
|
||||
const user = { ...FULL_USER, salt };
|
||||
await db.user.setUser(user);
|
||||
await db.storage().deriveCryptoKey({ password, salt });
|
||||
|
||||
await db.kv().write("token", {
|
||||
access_token: "fake-token",
|
||||
t: Date.now(),
|
||||
expires_in: 3600,
|
||||
scope: "notesnook.sync offline_access IdentityServerApi",
|
||||
refresh_token: "fake-refresh"
|
||||
});
|
||||
|
||||
const km = new KeyManager(db);
|
||||
const masterKey = await db.user.getMasterKey();
|
||||
const randomKey = await db.crypto().generateRandomKey();
|
||||
|
||||
const attachmentsKey = await km.wrapKey(randomKey, masterKey!);
|
||||
const monographPasswordsKey = await km.wrapKey(randomKey, masterKey!);
|
||||
const dek = await km.wrapKey(randomKey, masterKey!);
|
||||
const legacyDEK = await km.wrapKey(randomKey, masterKey!);
|
||||
|
||||
await db.user.setUser({
|
||||
...user,
|
||||
attachmentsKey,
|
||||
monographPasswordsKey,
|
||||
dataEncryptionKey: dek,
|
||||
legacyDataEncryptionKey: legacyDEK
|
||||
});
|
||||
|
||||
return { password, salt, masterKey, randomKey };
|
||||
}
|
||||
|
||||
// ─── _updatePassword (change) ──────────────────────────────────────
|
||||
|
||||
describe("UserManager._updatePassword (change)", () => {
|
||||
test("successful password change rewraps all keys and derives new master key", async () => {
|
||||
await databaseTest().then(async (db) => {
|
||||
await setupLoggedInUser(db, "oldpassword");
|
||||
const result = await db.user.changePassword("oldpassword", "newpassword");
|
||||
expect(result).toBe(true);
|
||||
const newMasterKey = await db.user.getMasterKey();
|
||||
expect(newMasterKey).toBeDefined();
|
||||
expect(newMasterKey!.key).not.toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
test("wrong old password throws 'Incorrect old password'", async () => {
|
||||
await databaseTest().then(async (db) => {
|
||||
await setupLoggedInUser(db, "oldpassword");
|
||||
await expect(
|
||||
db.user.changePassword("wrongpassword", "newpassword")
|
||||
).rejects.toThrow("Incorrect old password");
|
||||
});
|
||||
});
|
||||
|
||||
test("new master key decrypts dataEncryptionKey and old master key throws (regression)", async () => {
|
||||
await databaseTest().then(async (db) => {
|
||||
const { salt } = await setupLoggedInUser(db, "oldpassword");
|
||||
|
||||
// Simulate a full password change with old -> new passwords.
|
||||
const result = await db.user.changePassword("oldpassword", "newpassword");
|
||||
expect(result).toBe(true);
|
||||
|
||||
const user = await db.user.getUser();
|
||||
|
||||
// The stored master key must now be the one derived from the NEW password.
|
||||
const newMasterKey = await db.user.getMasterKey();
|
||||
expect(newMasterKey).toBeDefined();
|
||||
expect(newMasterKey!.key).toBe(
|
||||
(await db.storage().generateCryptoKey("newpassword", salt)).key
|
||||
);
|
||||
|
||||
// (a) The NEW master key must decrypt the rewrapped dataEncryptionKey.
|
||||
await expect(
|
||||
db.storage().decrypt(newMasterKey!, user!.dataEncryptionKey!)
|
||||
).resolves.toBeDefined();
|
||||
|
||||
// (b) The OLD master key must NOT be able to decrypt it anymore.
|
||||
// We derive it without persisting it to the key store.
|
||||
const oldMasterKey = await db.storage().generateCryptoKey(
|
||||
"oldpassword",
|
||||
salt
|
||||
);
|
||||
await expect(
|
||||
db.storage().decrypt(oldMasterKey, user!.dataEncryptionKey!)
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
test("empty new password throws 'New password is required'", async () => {
|
||||
await databaseTest().then(async (db) => {
|
||||
await setupLoggedInUser(db, "oldpassword");
|
||||
await expect(
|
||||
db.user.changePassword("oldpassword", "")
|
||||
).rejects.toThrow("New password is required");
|
||||
});
|
||||
});
|
||||
|
||||
test("no logged in user throws", async () => {
|
||||
await databaseTest().then(async (db) => {
|
||||
await expect(
|
||||
db.user.changePassword("oldpassword", "newpassword")
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
test("keys are rewrapped: old master key can no longer unwrap", async () => {
|
||||
await databaseTest().then(async (db) => {
|
||||
const { masterKey: oldMasterKey } = await setupLoggedInUser(db, "oldpassword");
|
||||
await db.user.changePassword("oldpassword", "newpassword");
|
||||
|
||||
const user = await db.user.getUser();
|
||||
const km = new KeyManager(db);
|
||||
|
||||
if (user.attachmentsKey) {
|
||||
await expect(
|
||||
km.unwrapKey(user.attachmentsKey, oldMasterKey!)
|
||||
).rejects.toThrow();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("new master key can unwrap all rewrapped keys", async () => {
|
||||
await databaseTest().then(async (db) => {
|
||||
await setupLoggedInUser(db, "oldpassword");
|
||||
await db.user.changePassword("oldpassword", "newpassword");
|
||||
|
||||
const newMasterKey = await db.user.getMasterKey();
|
||||
const user = await db.user.getUser();
|
||||
const km = new KeyManager(db);
|
||||
|
||||
if (user.attachmentsKey) {
|
||||
const key = await km.unwrapKey(user.attachmentsKey, newMasterKey!);
|
||||
expect(key).toBeDefined();
|
||||
}
|
||||
if (user.monographPasswordsKey) {
|
||||
const key = await km.unwrapKey(user.monographPasswordsKey, newMasterKey!);
|
||||
expect(key).toBeDefined();
|
||||
}
|
||||
if (user.dataEncryptionKey) {
|
||||
const key = await km.unwrapKey(user.dataEncryptionKey, newMasterKey!);
|
||||
expect(key).toBeDefined();
|
||||
}
|
||||
if (user.legacyDataEncryptionKey) {
|
||||
const key = await km.unwrapKey(user.legacyDataEncryptionKey, newMasterKey!);
|
||||
expect(key).toBeDefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("rewrapped user keys are stored with new ciphertext", async () => {
|
||||
await databaseTest().then(async (db) => {
|
||||
await setupLoggedInUser(db, "oldpassword");
|
||||
const km = new KeyManager(db);
|
||||
|
||||
// Fetch the attachmentsKey BEFORE password change
|
||||
const oldKey = await km.get("attachmentsKey", {
|
||||
useCache: false,
|
||||
refetchUser: false
|
||||
});
|
||||
expect(oldKey).toBeDefined();
|
||||
const oldCipher = (oldKey as any).cipher;
|
||||
|
||||
// Change password — keys are rewrapped and user is updated
|
||||
await db.user.changePassword("oldpassword", "newpassword");
|
||||
|
||||
// The new user object in KV should have a rewrapped attachmentsKey
|
||||
// (with new ciphertext)
|
||||
const newKey = await km.get("attachmentsKey", {
|
||||
useCache: false,
|
||||
refetchUser: false
|
||||
});
|
||||
expect(newKey).toBeDefined();
|
||||
// The re-fetched cipher MUST differ from the old one because the key
|
||||
// was re-wrapped with a new master key (different ciphertext).
|
||||
expect((newKey as any).cipher).not.toBe(oldCipher);
|
||||
|
||||
// Verify the new key can be unwrapped with the new master key
|
||||
const newMasterKey = await db.user.getMasterKey();
|
||||
const unwrapped = await km.unwrapKey(newKey, newMasterKey!);
|
||||
expect(unwrapped).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── _updatePassword (reset) ───────────────────────────────────────
|
||||
|
||||
describe("UserManager._updatePassword (reset)", () => {
|
||||
test("successful reset with explicit encryption key", async () => {
|
||||
await databaseTest().then(async (db) => {
|
||||
const { password, salt } = await setupLoggedInUser(db, "oldpassword");
|
||||
const result = await db.user.resetPassword({
|
||||
newPassword: "newpassword",
|
||||
encryptionKey: { password, salt }
|
||||
});
|
||||
expect(result).toBe(true);
|
||||
const newMasterKey = await db.user.getMasterKey();
|
||||
expect(newMasterKey).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
test("reset without encryption key throws", async () => {
|
||||
await databaseTest().then(async (db) => {
|
||||
await setupLoggedInUser(db, "oldpassword");
|
||||
await expect(
|
||||
db.user.resetPassword({ newPassword: "newpassword" } as any)
|
||||
).rejects.toThrow("Encryption key is required.");
|
||||
});
|
||||
});
|
||||
|
||||
test("empty new password throws 'New password is required'", async () => {
|
||||
await databaseTest().then(async (db) => {
|
||||
const { password, salt } = await setupLoggedInUser(db, "oldpassword");
|
||||
await expect(
|
||||
db.user.resetPassword({ newPassword: "", encryptionKey: { password, salt } })
|
||||
).rejects.toThrow("New password is required");
|
||||
});
|
||||
});
|
||||
|
||||
test("reset with wrong encryption key throws during verification", async () => {
|
||||
await databaseTest().then(async (db) => {
|
||||
await setupLoggedInUser(db, "oldpassword");
|
||||
await expect(
|
||||
db.user.resetPassword({
|
||||
newPassword: "newpassword",
|
||||
encryptionKey: { password: "wrongpassword", salt: "wrongsalt" }
|
||||
})
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Key migration scenarios ───────────────────────────────────────
|
||||
|
||||
describe("Key migration during password change", () => {
|
||||
test("legacy user (no DEK, no legacy DEK) gets new DEK and legacy DEK created", async () => {
|
||||
await databaseTest().then(async (db) => {
|
||||
const password = "oldpassword";
|
||||
const salt = randomBytes(16).toString("base64");
|
||||
await db.user.setUser({ ...FULL_USER, salt });
|
||||
await db.storage().deriveCryptoKey({ password, salt });
|
||||
|
||||
await db.kv().write("token", {
|
||||
access_token: "fake-token", t: Date.now(), expires_in: 3600,
|
||||
scope: "notesnook.sync offline_access IdentityServerApi", refresh_token: "fake-refresh"
|
||||
});
|
||||
|
||||
const km = new KeyManager(db);
|
||||
const masterKey = await db.user.getMasterKey();
|
||||
const randomKey = await db.crypto().generateRandomKey();
|
||||
const attachmentsKey = await km.wrapKey(randomKey, masterKey!);
|
||||
|
||||
await db.user.setUser({ ...FULL_USER, salt, attachmentsKey });
|
||||
|
||||
const result = await db.user.changePassword("oldpassword", "newpassword");
|
||||
expect(result).toBe(true);
|
||||
|
||||
const user = await db.user.getUser();
|
||||
expect(user.dataEncryptionKey).toBeDefined();
|
||||
expect(user.legacyDataEncryptionKey).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
test("user with both DEK and legacy DEK: both are rewrapped", async () => {
|
||||
await databaseTest().then(async (db) => {
|
||||
await setupLoggedInUser(db, "oldpassword");
|
||||
const userBefore = await db.user.getUser();
|
||||
const originalDEKCipher = userBefore.dataEncryptionKey?.cipher;
|
||||
const originalLegacyDEKCipher = userBefore.legacyDataEncryptionKey?.cipher;
|
||||
|
||||
await db.user.changePassword("oldpassword", "newpassword");
|
||||
|
||||
const userAfter = await db.user.getUser();
|
||||
expect(userAfter.dataEncryptionKey?.cipher).not.toBe(originalDEKCipher);
|
||||
expect(userAfter.legacyDataEncryptionKey?.cipher).not.toBe(originalLegacyDEKCipher);
|
||||
});
|
||||
});
|
||||
|
||||
test("user with only DEK (no legacy): password change succeeds verification", async () => {
|
||||
await databaseTest().then(async (db) => {
|
||||
const password = "oldpassword";
|
||||
const salt = randomBytes(16).toString("base64");
|
||||
await db.user.setUser({ ...FULL_USER, salt });
|
||||
await db.storage().deriveCryptoKey({ password, salt });
|
||||
|
||||
await db.kv().write("token", {
|
||||
access_token: "fake-token", t: Date.now(), expires_in: 3600,
|
||||
scope: "notesnook.sync offline_access IdentityServerApi", refresh_token: "fake-refresh"
|
||||
});
|
||||
|
||||
const km = new KeyManager(db);
|
||||
const masterKey = await db.user.getMasterKey();
|
||||
const randomKey = await db.crypto().generateRandomKey();
|
||||
const dek = await km.wrapKey(randomKey, masterKey!);
|
||||
|
||||
await db.user.setUser({ ...FULL_USER, salt, dataEncryptionKey: dek });
|
||||
|
||||
// A single DEK is now a valid verifier, so the change should succeed
|
||||
// and the DEK should be rewrapped with the new password.
|
||||
const result = await db.user.changePassword("oldpassword", "newpassword");
|
||||
expect(result).toBe(true);
|
||||
|
||||
const userAfter = await db.user.getUser();
|
||||
expect(userAfter.dataEncryptionKey).toBeDefined();
|
||||
expect(userAfter.dataEncryptionKey?.cipher).not.toBe(dek.cipher);
|
||||
});
|
||||
});
|
||||
|
||||
test("inbox keys are rewrapped during password change", async () => {
|
||||
await databaseTest().then(async (db) => {
|
||||
await setupLoggedInUser(db, "oldpassword");
|
||||
|
||||
const km = new KeyManager(db);
|
||||
const masterKey = await db.user.getMasterKey();
|
||||
// Use a fake keypair since generatePGPKeyPair depends on unavailable sodium API
|
||||
const fakeKeyPair = { publicKey: "fake-public-key", privateKey: "fake-private-key" };
|
||||
const wrappedInboxKeys = await km.wrapKey(fakeKeyPair as any, masterKey!);
|
||||
|
||||
const user = await db.user.getUser();
|
||||
await db.user.setUser({ ...user, inboxKeys: wrappedInboxKeys });
|
||||
|
||||
await db.user.changePassword("oldpassword", "newpassword");
|
||||
|
||||
const userAfter = await db.user.getUser();
|
||||
const newMasterKey = await db.user.getMasterKey();
|
||||
expect(userAfter.inboxKeys).toBeDefined();
|
||||
const unwrappedKeypair = await km.unwrapKey(userAfter.inboxKeys, newMasterKey!);
|
||||
expect((unwrappedKeypair as any).publicKey).toBe(fakeKeyPair.publicKey);
|
||||
expect((unwrappedKeypair as any).privateKey).toBe(fakeKeyPair.privateKey);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── getDataEncryptionKeys ─────────────────────────────────────────
|
||||
|
||||
describe("UserManager.getDataEncryptionKeys", () => {
|
||||
test("returns LEGACY version when no DEK exists", async () => {
|
||||
await databaseTest().then(async (db) => {
|
||||
const salt = randomBytes(16).toString("base64");
|
||||
await db.user.setUser({ ...FULL_USER, salt });
|
||||
await db.storage().deriveCryptoKey({ password: "password", salt });
|
||||
|
||||
const keys = await db.user.getDataEncryptionKeys();
|
||||
expect(keys).toBeDefined();
|
||||
expect(keys!.length).toBe(1);
|
||||
expect(keys![0].version).toBe(0); // KEY_VERSION.LEGACY
|
||||
});
|
||||
});
|
||||
|
||||
test("returns DEK version when DEK exists", async () => {
|
||||
await databaseTest().then(async (db) => {
|
||||
await setupLoggedInUser(db);
|
||||
const keys = await db.user.getDataEncryptionKeys();
|
||||
expect(keys).toBeDefined();
|
||||
expect(keys!.length).toBeGreaterThanOrEqual(1);
|
||||
const dekVersion = keys!.find((k: any) => k.version === 1);
|
||||
expect(dekVersion).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
test("returns both LEGACY and DEK when both exist", async () => {
|
||||
await databaseTest().then(async (db) => {
|
||||
await setupLoggedInUser(db);
|
||||
const keys = await db.user.getDataEncryptionKeys();
|
||||
expect(keys).toBeDefined();
|
||||
expect(keys!.length).toBe(2);
|
||||
const versions = keys!.map((k: any) => k.version);
|
||||
expect(versions).toContain(0);
|
||||
expect(versions).toContain(1);
|
||||
});
|
||||
});
|
||||
|
||||
test("returns undefined when no master key exists", async () => {
|
||||
await databaseTest().then(async (db) => {
|
||||
const keys = await db.user.getDataEncryptionKeys();
|
||||
expect(keys).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -24,7 +24,7 @@ import TokenManager from "./token-manager.js";
|
||||
import { EV, EVENTS } from "../common.js";
|
||||
import { HealthCheck } from "./healthcheck.js";
|
||||
import Database from "./index.js";
|
||||
import { SerializedKeyPair, SerializedKey } from "@notesnook/crypto";
|
||||
import { SerializedKeyPair, SerializedKey, Cipher } from "@notesnook/crypto";
|
||||
import { logger } from "../logger.js";
|
||||
import { KEY_VERSION, KeyVersion } from "./sync/types.js";
|
||||
import {
|
||||
@@ -394,9 +394,13 @@ class UserManager {
|
||||
);
|
||||
}
|
||||
|
||||
resetPassword(newPassword: string) {
|
||||
resetPassword(options: {
|
||||
newPassword: string;
|
||||
encryptionKey: SerializedKey;
|
||||
}) {
|
||||
return this._updatePassword("reset", {
|
||||
new_password: newPassword
|
||||
new_password: options.newPassword,
|
||||
encryptionKey: options.encryptionKey
|
||||
});
|
||||
}
|
||||
|
||||
@@ -622,6 +626,47 @@ class UserManager {
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchEncryptionVerifier(): Promise<
|
||||
Cipher<"base64"> | undefined
|
||||
> {
|
||||
const token = await this.tokenManager.getAccessToken();
|
||||
return http.get(`${constants.API_HOST}/users/verifier`, token);
|
||||
}
|
||||
|
||||
async verifyEncryptionKey(key: SerializedKey) {
|
||||
const user = await this.getUser();
|
||||
if (!user) throw new Error("User not found.");
|
||||
|
||||
const verifiers = [
|
||||
user.attachmentsKey,
|
||||
user.monographPasswordsKey,
|
||||
user.legacyDataEncryptionKey,
|
||||
user.dataEncryptionKey,
|
||||
user.inboxKeys?.private
|
||||
].filter((v): v is Cipher<"base64"> => !!v);
|
||||
|
||||
if (verifiers.length === 0) {
|
||||
const verifier = await this.fetchEncryptionVerifier();
|
||||
if (verifier) verifiers.push(verifier);
|
||||
else
|
||||
throw new Error(
|
||||
"Encryption key cannot be verified: no encryption verifier found."
|
||||
);
|
||||
}
|
||||
|
||||
for (const verifier of verifiers) {
|
||||
const decryptedData = await this.db
|
||||
.storage()
|
||||
.decrypt(key, verifier)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
if (!decryptedData)
|
||||
throw new Error(
|
||||
"Your data cannot be decrypted using the provided encryption key."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async _updatePassword(
|
||||
type: "change" | "reset",
|
||||
data: {
|
||||
@@ -630,15 +675,77 @@ class UserManager {
|
||||
encryptionKey?: SerializedKey;
|
||||
}
|
||||
) {
|
||||
const { new_password, old_password } = data;
|
||||
if (!new_password) throw new Error("New password is required.");
|
||||
|
||||
const token = await this.tokenManager.getAccessToken();
|
||||
const user = await this.getUser();
|
||||
if (!token || !user) throw new Error("You are not logged in.");
|
||||
|
||||
const { email, salt } = user;
|
||||
|
||||
const { new_password, old_password } = data;
|
||||
if (old_password && !(await this.verifyPassword(old_password)))
|
||||
throw new Error("Incorrect old password.");
|
||||
if (old_password && !data.encryptionKey)
|
||||
data.encryptionKey = await this.getMasterKey();
|
||||
|
||||
if (!data.encryptionKey) throw new Error("Encryption key is required.");
|
||||
|
||||
// we must be 100% sure that the provided encryption key is valid before
|
||||
// proceeding
|
||||
await this.verifyEncryptionKey(data.encryptionKey);
|
||||
|
||||
const updateUserPayload: Partial<User> = {};
|
||||
const newMasterKey = await this.db
|
||||
.storage()
|
||||
.generateCryptoKey(new_password, salt);
|
||||
|
||||
if (user.attachmentsKey) {
|
||||
updateUserPayload.attachmentsKey = await this.keyManager.rewrapKey(
|
||||
user.attachmentsKey,
|
||||
data.encryptionKey,
|
||||
newMasterKey
|
||||
);
|
||||
}
|
||||
if (user.monographPasswordsKey) {
|
||||
updateUserPayload.monographPasswordsKey = await this.keyManager.rewrapKey(
|
||||
user.monographPasswordsKey,
|
||||
data.encryptionKey,
|
||||
newMasterKey
|
||||
);
|
||||
}
|
||||
if (user.inboxKeys) {
|
||||
updateUserPayload.inboxKeys = await this.keyManager.rewrapKey(
|
||||
user.inboxKeys,
|
||||
data.encryptionKey,
|
||||
newMasterKey
|
||||
);
|
||||
}
|
||||
|
||||
if (user.legacyDataEncryptionKey)
|
||||
updateUserPayload.legacyDataEncryptionKey =
|
||||
await this.keyManager.rewrapKey(
|
||||
user.legacyDataEncryptionKey,
|
||||
data.encryptionKey,
|
||||
newMasterKey
|
||||
);
|
||||
if (user.dataEncryptionKey)
|
||||
updateUserPayload.dataEncryptionKey = await this.keyManager.rewrapKey(
|
||||
user.dataEncryptionKey,
|
||||
data.encryptionKey,
|
||||
newMasterKey
|
||||
);
|
||||
|
||||
if (!user.legacyDataEncryptionKey && !user.dataEncryptionKey) {
|
||||
updateUserPayload.dataEncryptionKey = await this.keyManager.wrapKey(
|
||||
await this.db.crypto().generateRandomKey(),
|
||||
newMasterKey
|
||||
);
|
||||
updateUserPayload.legacyDataEncryptionKey = await this.keyManager.wrapKey(
|
||||
data.encryptionKey,
|
||||
newMasterKey
|
||||
);
|
||||
}
|
||||
|
||||
const oldPassword = old_password
|
||||
? // we don't lowercase email here to allow user accounts with
|
||||
@@ -649,61 +756,6 @@ class UserManager {
|
||||
})
|
||||
: null;
|
||||
|
||||
if (!new_password) throw new Error("New password is required.");
|
||||
|
||||
data.encryptionKey = data.encryptionKey || (await this.getMasterKey());
|
||||
|
||||
const updateUserPayload: Partial<User> = {};
|
||||
if (data.encryptionKey) {
|
||||
const newMasterKey = await this.db
|
||||
.storage()
|
||||
.generateCryptoKey(new_password, salt);
|
||||
if (user.attachmentsKey) {
|
||||
updateUserPayload.attachmentsKey = await this.keyManager.rewrapKey(
|
||||
user.attachmentsKey,
|
||||
data.encryptionKey,
|
||||
newMasterKey
|
||||
);
|
||||
}
|
||||
if (user.monographPasswordsKey) {
|
||||
updateUserPayload.monographPasswordsKey =
|
||||
await this.keyManager.rewrapKey(
|
||||
user.monographPasswordsKey,
|
||||
data.encryptionKey,
|
||||
newMasterKey
|
||||
);
|
||||
}
|
||||
if (user.inboxKeys) {
|
||||
updateUserPayload.inboxKeys = await this.keyManager.rewrapKey(
|
||||
user.inboxKeys,
|
||||
data.encryptionKey,
|
||||
newMasterKey
|
||||
);
|
||||
}
|
||||
|
||||
if (user.legacyDataEncryptionKey)
|
||||
updateUserPayload.legacyDataEncryptionKey =
|
||||
await this.keyManager.rewrapKey(
|
||||
user.legacyDataEncryptionKey,
|
||||
data.encryptionKey,
|
||||
newMasterKey
|
||||
);
|
||||
if (user.dataEncryptionKey)
|
||||
updateUserPayload.dataEncryptionKey = await this.keyManager.rewrapKey(
|
||||
user.dataEncryptionKey,
|
||||
data.encryptionKey,
|
||||
newMasterKey
|
||||
);
|
||||
else {
|
||||
updateUserPayload.dataEncryptionKey = await this.keyManager.wrapKey(
|
||||
await this.db.crypto().generateRandomKey(),
|
||||
newMasterKey
|
||||
);
|
||||
updateUserPayload.legacyDataEncryptionKey =
|
||||
await this.keyManager.wrapKey(data.encryptionKey, newMasterKey);
|
||||
}
|
||||
}
|
||||
|
||||
await http.patch.json(
|
||||
`${constants.API_HOST}/users/password/${type}`,
|
||||
{
|
||||
|
||||
276
packages/crypto/__tests__/crypto.test.ts
Normal file
276
packages/crypto/__tests__/crypto.test.ts
Normal file
@@ -0,0 +1,276 @@
|
||||
/*
|
||||
This file is part of the Notesnook project (https://notesnook.com/)
|
||||
|
||||
Copyright (C) 2023 Streetwriters (Private) Limited
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { test, expect, describe } from "vitest";
|
||||
import { NNCrypto } from "../src/index.js";
|
||||
import { randomBytes } from "crypto";
|
||||
|
||||
const crypto = new NNCrypto();
|
||||
|
||||
function validSalt() {
|
||||
return randomBytes(16).toString("base64");
|
||||
}
|
||||
|
||||
// ─── Key Derivation ────────────────────────────────────────────────
|
||||
|
||||
describe("KeyUtils.deriveKey", () => {
|
||||
test("same password + salt produces same key", async () => {
|
||||
const salt = validSalt();
|
||||
const key1 = await crypto.deriveKey("password123", salt);
|
||||
const key2 = await crypto.deriveKey("password123", salt);
|
||||
expect(key1.key).toEqual(key2.key);
|
||||
expect(key1.salt).toBe(key2.salt);
|
||||
});
|
||||
|
||||
test("different passwords produce different keys", async () => {
|
||||
const salt = validSalt();
|
||||
const key1 = await crypto.deriveKey("password1", salt);
|
||||
const key2 = await crypto.deriveKey("password2", salt);
|
||||
expect(key1.key).not.toEqual(key2.key);
|
||||
});
|
||||
|
||||
test("different salts produce different keys", async () => {
|
||||
const salt1 = validSalt();
|
||||
const salt2 = validSalt();
|
||||
const key1 = await crypto.deriveKey("password", salt1);
|
||||
const key2 = await crypto.deriveKey("password", salt2);
|
||||
expect(key1.key).not.toEqual(key2.key);
|
||||
});
|
||||
|
||||
test("without salt generates a random salt", async () => {
|
||||
const key1 = await crypto.deriveKey("password");
|
||||
const key2 = await crypto.deriveKey("password");
|
||||
expect(key1.salt).not.toBe(key2.salt);
|
||||
expect(key1.key).not.toEqual(key2.key);
|
||||
});
|
||||
|
||||
test("exportKey returns SerializedKey with base64 key and salt", async () => {
|
||||
const salt = validSalt();
|
||||
const exported = await crypto.exportKey("password", salt);
|
||||
expect(exported.key).toBeDefined();
|
||||
expect(exported.salt).toBe(salt);
|
||||
expect(typeof exported.key).toBe("string");
|
||||
expect(exported.key!.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("exportKey without salt generates one", async () => {
|
||||
const exported = await crypto.exportKey("password");
|
||||
expect(exported.key).toBeDefined();
|
||||
expect(exported.salt).toBeDefined();
|
||||
expect(typeof exported.salt).toBe("string");
|
||||
});
|
||||
|
||||
test("deriveKey produces 32-byte key", async () => {
|
||||
const salt = validSalt();
|
||||
const key = await crypto.deriveKey("password", salt);
|
||||
// libsodium crypto_aead_xchacha20poly1305_ietf_KEYBYTES = 32
|
||||
expect(key.key.length).toBe(32);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Symmetric Encryption Round-Trip ───────────────────────────────
|
||||
|
||||
describe("Encryption/Decryption round-trip", () => {
|
||||
test("text encrypt + decrypt round-trip", async () => {
|
||||
const key = await crypto.exportKey("testpassword", validSalt());
|
||||
const plaintext = "Hello, Notesnook!";
|
||||
const cipher = await crypto.encrypt(key, plaintext, "text", "base64");
|
||||
|
||||
expect(cipher.cipher).toBeDefined();
|
||||
expect(cipher.iv).toBeDefined();
|
||||
expect(cipher.salt).toBeDefined();
|
||||
expect(cipher.format).toBe("base64");
|
||||
expect(cipher.alg).toContain("xcha");
|
||||
expect(cipher.length).toBe(plaintext.length);
|
||||
|
||||
const decrypted = await crypto.decrypt(key, cipher, "text");
|
||||
expect(decrypted).toBe(plaintext);
|
||||
});
|
||||
|
||||
test("base64 input encrypt + decrypt round-trip", async () => {
|
||||
const key = await crypto.exportKey("testpassword", validSalt());
|
||||
const plaintext = "SGVsbG8gV29ybGQ=";
|
||||
const cipher = await crypto.encrypt(key, plaintext, "base64", "base64");
|
||||
const decrypted = await crypto.decrypt(key, cipher, "base64");
|
||||
expect(decrypted).toBe(plaintext);
|
||||
});
|
||||
|
||||
test("empty string encrypt + decrypt round-trip", async () => {
|
||||
const key = await crypto.exportKey("testpassword", validSalt());
|
||||
const plaintext = "";
|
||||
const cipher = await crypto.encrypt(key, plaintext, "text", "base64");
|
||||
const decrypted = await crypto.decrypt(key, cipher, "text");
|
||||
expect(decrypted).toBe(plaintext);
|
||||
});
|
||||
|
||||
test("unicode string encrypt + decrypt round-trip", async () => {
|
||||
const key = await crypto.exportKey("testpassword", validSalt());
|
||||
const plaintext = "Hello \u{1F30D}! Testing \u{00E9}mojis and \u{65E5}\u{672C}\u{8A9E}";
|
||||
const cipher = await crypto.encrypt(key, plaintext, "text", "base64");
|
||||
const decrypted = await crypto.decrypt(key, cipher, "text");
|
||||
expect(decrypted).toBe(plaintext);
|
||||
});
|
||||
|
||||
test("long text encrypt + decrypt round-trip", async () => {
|
||||
const key = await crypto.exportKey("testpassword", validSalt());
|
||||
const plaintext = "A".repeat(100000);
|
||||
const cipher = await crypto.encrypt(key, plaintext, "text", "base64");
|
||||
const decrypted = await crypto.decrypt(key, cipher, "text");
|
||||
expect(decrypted).toBe(plaintext);
|
||||
});
|
||||
|
||||
test("decrypt with wrong key throws", async () => {
|
||||
const salt = validSalt();
|
||||
const key1 = await crypto.exportKey("password1", salt);
|
||||
const key2 = await crypto.exportKey("password2", salt);
|
||||
const cipher = await crypto.encrypt(key1, "secret", "text", "base64");
|
||||
|
||||
await expect(crypto.decrypt(key2, cipher, "text")).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("each encryption produces unique ciphertext (random nonce)", async () => {
|
||||
const key = await crypto.exportKey("testpassword", validSalt());
|
||||
const cipher1 = await crypto.encrypt(key, "same data", "text", "base64");
|
||||
const cipher2 = await crypto.encrypt(key, "same data", "text", "base64");
|
||||
expect(cipher1.cipher).not.toBe(cipher2.cipher);
|
||||
expect(cipher1.iv).not.toBe(cipher2.iv);
|
||||
});
|
||||
|
||||
test("cipher with salt in key but no salt on key uses cipher salt", async () => {
|
||||
const key = await crypto.exportKey("testpassword", validSalt());
|
||||
const cipher = await crypto.encrypt(key, "data", "text", "base64");
|
||||
|
||||
// key without salt - should use cipher's salt
|
||||
const keyWithoutSalt = { key: key.key };
|
||||
const decrypted = await crypto.decrypt(keyWithoutSalt, cipher, "text");
|
||||
expect(decrypted).toBe("data");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Multi-Encryption ──────────────────────────────────────────────
|
||||
|
||||
describe("encryptMulti / decryptMulti round-trip", () => {
|
||||
test("multiple items encrypt + decrypt round-trip", async () => {
|
||||
const key = await crypto.exportKey("testpassword", validSalt());
|
||||
const items = ["item1", "item2", "item3", "hello world"];
|
||||
const ciphers = await crypto.encryptMulti(key, items, "text", "base64");
|
||||
|
||||
expect(ciphers.length).toBe(items.length);
|
||||
|
||||
const decrypted = await crypto.decryptMulti(key, ciphers, "text");
|
||||
expect(decrypted).toEqual(items);
|
||||
});
|
||||
|
||||
test("empty array encrypt + decrypt round-trip", async () => {
|
||||
const key = await crypto.exportKey("testpassword", validSalt());
|
||||
const ciphers = await crypto.encryptMulti(key, [], "text", "base64");
|
||||
expect(ciphers.length).toBe(0);
|
||||
|
||||
const decrypted = await crypto.decryptMulti(key, ciphers, "text");
|
||||
expect(decrypted.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Password Hashing ──────────────────────────────────────────────
|
||||
|
||||
describe("Password.hash", () => {
|
||||
test("same password + salt produces same hash", async () => {
|
||||
const hash1 = await crypto.hash("mypassword", "salt@email.com");
|
||||
const hash2 = await crypto.hash("mypassword", "salt@email.com");
|
||||
expect(hash1).toBe(hash2);
|
||||
});
|
||||
|
||||
test("different passwords produce different hashes", async () => {
|
||||
const hash1 = await crypto.hash("password1", "salt@email.com");
|
||||
const hash2 = await crypto.hash("password2", "salt@email.com");
|
||||
expect(hash1).not.toBe(hash2);
|
||||
});
|
||||
|
||||
test("different salts produce different hashes", async () => {
|
||||
const hash1 = await crypto.hash("password", "email1@example.com");
|
||||
const hash2 = await crypto.hash("password", "email2@example.com");
|
||||
expect(hash1).not.toBe(hash2);
|
||||
});
|
||||
|
||||
test("hash is a non-empty string", async () => {
|
||||
const hash = await crypto.hash("password", "email@example.com");
|
||||
expect(typeof hash).toBe("string");
|
||||
expect(hash.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("empty password produces a hash", async () => {
|
||||
const hash = await crypto.hash("", "email@example.com");
|
||||
expect(typeof hash).toBe("string");
|
||||
expect(hash.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Streaming Encryption ──────────────────────────────────────────
|
||||
|
||||
describe("Streaming Encryption/Decryption", () => {
|
||||
test("stream encrypt + decrypt round-trip", async () => {
|
||||
const key = await crypto.exportKey("testpassword", validSalt());
|
||||
|
||||
const { iv, stream: encryptStream } =
|
||||
await crypto.createEncryptionStream(key);
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const chunks: Uint8Array[] = [];
|
||||
|
||||
const writer = encryptStream.writable.getWriter();
|
||||
const reader = encryptStream.readable.getReader();
|
||||
|
||||
writer.write({ data: encoder.encode("chunk1-"), final: false });
|
||||
writer.write({ data: encoder.encode("chunk2-"), final: false });
|
||||
writer.write({ data: encoder.encode("chunk3-final"), final: true });
|
||||
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
chunks.push(value);
|
||||
}
|
||||
|
||||
const decryptStream = await crypto.createDecryptionStream(key, iv);
|
||||
const decryptWriter = decryptStream.writable.getWriter();
|
||||
const decryptReader = decryptStream.readable.getReader();
|
||||
|
||||
for (const chunk of chunks) {
|
||||
decryptWriter.write(chunk);
|
||||
}
|
||||
|
||||
const decryptedChunks: Uint8Array[] = [];
|
||||
while (true) {
|
||||
const { value, done } = await decryptReader.read();
|
||||
if (done) break;
|
||||
decryptedChunks.push(value);
|
||||
}
|
||||
|
||||
const decrypted = new Uint8Array(
|
||||
decryptedChunks.reduce((acc, chunk) => acc + chunk.length, 0)
|
||||
);
|
||||
let offset = 0;
|
||||
for (const chunk of decryptedChunks) {
|
||||
decrypted.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
expect(decoder.decode(decrypted)).toBe("chunk1-chunk2-chunk3-final");
|
||||
});
|
||||
});
|
||||
1454
packages/crypto/package-lock.json
generated
1454
packages/crypto/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -20,12 +20,15 @@
|
||||
"scripts": {
|
||||
"build": "tsdown src/index.ts",
|
||||
"prepublishOnly": "npm run build",
|
||||
"watch": "npm run build -- --watch"
|
||||
"watch": "npm run build -- --watch",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@notesnook/sodium": "file:../sodium"
|
||||
},
|
||||
"devDependencies": {},
|
||||
"devDependencies": {
|
||||
"vitest": "2.1.8"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/streetwriters/notesnook.git",
|
||||
|
||||
26
packages/crypto/vitest.config.ts
Normal file
26
packages/crypto/vitest.config.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
This file is part of the Notesnook project (https://notesnook.com/)
|
||||
|
||||
Copyright (C) 2023 Streetwriters (Private) Limited
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["__tests__/**/*.test.ts"]
|
||||
}
|
||||
});
|
||||
File diff suppressed because one or more lines are too long
@@ -1,5 +1,5 @@
|
||||
diff --git a/node_modules/prosemirror-view/dist/index.cjs b/node_modules/prosemirror-view/dist/index.cjs
|
||||
index a615cb7..e07b6e0 100644
|
||||
index a615cb7..c1a6cbb 100644
|
||||
--- a/node_modules/prosemirror-view/dist/index.cjs
|
||||
+++ b/node_modules/prosemirror-view/dist/index.cjs
|
||||
@@ -1005,8 +1005,8 @@ var ViewDesc = function () {
|
||||
@@ -12,64 +12,7 @@ index a615cb7..e07b6e0 100644
|
||||
if (anchor != head) domSel.extend(headDOM.node, headDOM.offset);
|
||||
domSelExtended = true;
|
||||
} catch (_) {}
|
||||
@@ -2935,6 +2935,8 @@ var InputState = _createClass(function InputState() {
|
||||
this.lastSelectionOrigin = null;
|
||||
this.lastSelectionTime = 0;
|
||||
this.lastIOSEnter = 0;
|
||||
+ this.lastAndroidEnter = 0;
|
||||
+ this.androidEnterFallbackTimeout = -1;
|
||||
this.lastIOSEnterFallbackTimeout = -1;
|
||||
this.lastFocus = 0;
|
||||
this.lastTouch = 0;
|
||||
@@ -2978,6 +2980,7 @@ function destroyInput(view) {
|
||||
for (var type in view.input.eventHandlers) view.dom.removeEventListener(type, view.input.eventHandlers[type]);
|
||||
clearTimeout(view.input.composingTimeout);
|
||||
clearTimeout(view.input.lastIOSEnterFallbackTimeout);
|
||||
+ clearTimeout(view.input.androidEnterFallbackTimeout);
|
||||
}
|
||||
function ensureListeners(view) {
|
||||
view.someProp("handleDOMEvents", function (currentHandlers) {
|
||||
@@ -3003,11 +3006,30 @@ function _dispatchEvent(view, event) {
|
||||
}
|
||||
editHandlers.keydown = function (view, _event) {
|
||||
var event = _event;
|
||||
+ if (android && event.keyCode == 13) {
|
||||
+ var enterNow = Date.now();
|
||||
+ view.input.lastAndroidEnter = enterNow;
|
||||
+ clearTimeout(view.input.androidEnterFallbackTimeout);
|
||||
+ view.input.androidEnterFallbackTimeout = setTimeout(function () {
|
||||
+ if (view.input.lastAndroidEnter == enterNow) {
|
||||
+ view.input.lastAndroidEnter = 0;
|
||||
+ view.domObserver.forceFlush();
|
||||
+ view.domObserver.flush();
|
||||
+ view.someProp("handleKeyDown", function (f) {
|
||||
+ return f(view, keyEvent(13, "Enter"));
|
||||
+ });
|
||||
+ }
|
||||
+ }, 200);
|
||||
+ }
|
||||
view.input.shiftKey = event.keyCode == 16 || event.shiftKey;
|
||||
if (inOrNearComposition(view)) return;
|
||||
view.input.lastKeyCode = event.keyCode;
|
||||
view.input.lastKeyCodeTime = Date.now();
|
||||
- if (android && chrome && event.keyCode == 13) return;
|
||||
+ if (android && chrome && event.keyCode == 13) {
|
||||
+ view.domObserver.forceFlush();
|
||||
+ view.domObserver.flush();
|
||||
+ if (view.state.selection.empty) return;
|
||||
+ }
|
||||
if (event.keyCode != 229) view.domObserver.forceFlush();
|
||||
if (ios && event.keyCode == 13 && !event.ctrlKey && !event.altKey && !event.metaKey) {
|
||||
var now = Date.now();
|
||||
@@ -3023,6 +3045,7 @@ editHandlers.keydown = function (view, _event) {
|
||||
} else if (view.someProp("handleKeyDown", function (f) {
|
||||
return f(view, event);
|
||||
}) || captureKeyDown(view, event)) {
|
||||
+ if (android && event.keyCode == 13) view.input.lastAndroidEnter = 0;
|
||||
event.preventDefault();
|
||||
} else {
|
||||
setSelectionOrigin(view, "key");
|
||||
@@ -3647,7 +3670,7 @@ function handleDrop(view, event, dragging) {
|
||||
@@ -3647,7 +3647,7 @@ function handleDrop(view, event, dragging) {
|
||||
});
|
||||
tr.setSelection(selectionBetween(view, $pos, tr.doc.resolve(end)));
|
||||
}
|
||||
@@ -78,45 +21,8 @@ index a615cb7..e07b6e0 100644
|
||||
view.dispatch(tr.setMeta("uiEvent", "drop"));
|
||||
}
|
||||
handlers.focus = function (view) {
|
||||
@@ -3674,6 +3697,7 @@ handlers.blur = function (view, _event) {
|
||||
};
|
||||
handlers.beforeinput = function (view, _event) {
|
||||
var event = _event;
|
||||
+ if (android && /^insert(Paragraph|LineBreak)/.test(event.inputType)) view.input.lastAndroidEnter = Date.now();
|
||||
if (android && event.inputType == "deleteContentBackward") {
|
||||
view.domObserver.flushSoon();
|
||||
var domChangeCount = view.input.domChangeCount;
|
||||
@@ -4841,6 +4865,7 @@ function readDOMChange(view, from, to, typeOver, addedNodes) {
|
||||
return f(view, keyEvent(13, "Enter"));
|
||||
})) {
|
||||
view.input.lastIOSEnter = 0;
|
||||
+ view.input.lastAndroidEnter = 0;
|
||||
return;
|
||||
}
|
||||
if (!change) {
|
||||
@@ -4881,10 +4906,11 @@ function readDOMChange(view, from, to, typeOver, addedNodes) {
|
||||
var inlineChange = $from.sameParent($to) && $from.parent.inlineContent && $fromA.end() >= change.endA;
|
||||
if ((ios && view.input.lastIOSEnter > Date.now() - 225 && (!inlineChange || addedNodes.some(function (n) {
|
||||
return n.nodeName == "DIV" || n.nodeName == "P";
|
||||
- })) || !inlineChange && $from.pos < parse.doc.content.size && (!$from.sameParent($to) || !$from.parent.inlineContent) && $from.pos < $to.pos && !/\S/.test(parse.doc.textBetween($from.pos, $to.pos, "", ""))) && view.someProp("handleKeyDown", function (f) {
|
||||
+ })) || (!android || view.input.lastAndroidEnter > Date.now() - 225) && !inlineChange && $from.pos < parse.doc.content.size && (!$from.sameParent($to) || !$from.parent.inlineContent) && $from.pos < $to.pos && !/\S/.test(parse.doc.textBetween($from.pos, $to.pos, "", ""))) && view.someProp("handleKeyDown", function (f) {
|
||||
return f(view, keyEvent(13, "Enter"));
|
||||
})) {
|
||||
view.input.lastIOSEnter = 0;
|
||||
+ view.input.lastAndroidEnter = 0;
|
||||
return;
|
||||
}
|
||||
if (view.state.selection.anchor > change.start && looksLikeBackspace(doc, change.start, change.endA, $from, $to) && view.someProp("handleKeyDown", function (f) {
|
||||
@@ -4893,6 +4919,7 @@ function readDOMChange(view, from, to, typeOver, addedNodes) {
|
||||
if (android && chrome) view.domObserver.suppressSelectionUpdates();
|
||||
return;
|
||||
}
|
||||
+ view.input.lastAndroidEnter = 0;
|
||||
if (chrome && change.endB == change.start) view.input.lastChromeDelete = Date.now();
|
||||
if (android && !inlineChange && $from.start() != $to.start() && $to.parentOffset == 0 && $from.depth == $to.depth && parse.sel && parse.sel.anchor == parse.sel.head && parse.sel.head == change.endA) {
|
||||
change.endB -= 2;
|
||||
diff --git a/node_modules/prosemirror-view/dist/index.js b/node_modules/prosemirror-view/dist/index.js
|
||||
index 61118ee..b82e477 100644
|
||||
index 61118ee..ec4c853 100644
|
||||
--- a/node_modules/prosemirror-view/dist/index.js
|
||||
+++ b/node_modules/prosemirror-view/dist/index.js
|
||||
@@ -1079,8 +1079,8 @@ class ViewDesc {
|
||||
@@ -129,68 +35,7 @@ index 61118ee..b82e477 100644
|
||||
if (anchor != head)
|
||||
domSel.extend(headDOM.node, headDOM.offset);
|
||||
domSelExtended = true;
|
||||
@@ -3098,6 +3098,8 @@ class InputState {
|
||||
this.lastSelectionTime = 0;
|
||||
this.lastIOSEnter = 0;
|
||||
this.lastIOSEnterFallbackTimeout = -1;
|
||||
+ this.lastAndroidEnter = 0;
|
||||
+ this.androidEnterFallbackTimeout = -1;
|
||||
this.lastFocus = 0;
|
||||
this.lastTouch = 0;
|
||||
this.lastChromeDelete = 0;
|
||||
@@ -3143,6 +3145,7 @@ function destroyInput(view) {
|
||||
view.dom.removeEventListener(type, view.input.eventHandlers[type]);
|
||||
clearTimeout(view.input.composingTimeout);
|
||||
clearTimeout(view.input.lastIOSEnterFallbackTimeout);
|
||||
+ clearTimeout(view.input.androidEnterFallbackTimeout);
|
||||
}
|
||||
function ensureListeners(view) {
|
||||
view.someProp("handleDOMEvents", currentHandlers => {
|
||||
@@ -3175,6 +3178,19 @@ function dispatchEvent(view, event) {
|
||||
}
|
||||
editHandlers.keydown = (view, _event) => {
|
||||
let event = _event;
|
||||
+ if (android && event.keyCode == 13) {
|
||||
+ let now = Date.now();
|
||||
+ view.input.lastAndroidEnter = now;
|
||||
+ clearTimeout(view.input.androidEnterFallbackTimeout);
|
||||
+ view.input.androidEnterFallbackTimeout = setTimeout(() => {
|
||||
+ if (view.input.lastAndroidEnter == now) {
|
||||
+ view.input.lastAndroidEnter = 0;
|
||||
+ view.domObserver.forceFlush();
|
||||
+ view.domObserver.flush();
|
||||
+ view.someProp("handleKeyDown", f => f(view, keyEvent(13, "Enter")));
|
||||
+ }
|
||||
+ }, 200);
|
||||
+ }
|
||||
view.input.shiftKey = event.keyCode == 16 || event.shiftKey;
|
||||
if (inOrNearComposition(view))
|
||||
return;
|
||||
@@ -3183,8 +3199,12 @@ editHandlers.keydown = (view, _event) => {
|
||||
// Suppress enter key events on Chrome Android, because those tend
|
||||
// to be part of a confused sequence of composition events fired,
|
||||
// and handling them eagerly tends to corrupt the input.
|
||||
- if (android && chrome && event.keyCode == 13)
|
||||
- return;
|
||||
+ if (android && chrome && event.keyCode == 13) {
|
||||
+ view.domObserver.forceFlush();
|
||||
+ view.domObserver.flush();
|
||||
+ if (view.state.selection.empty)
|
||||
+ return;
|
||||
+ }
|
||||
if (event.keyCode != 229)
|
||||
view.domObserver.forceFlush();
|
||||
// On iOS, if we preventDefault enter key presses, the virtual
|
||||
@@ -3202,6 +3222,8 @@ editHandlers.keydown = (view, _event) => {
|
||||
}, 200);
|
||||
}
|
||||
else if (view.someProp("handleKeyDown", f => f(view, event)) || captureKeyDown(view, event)) {
|
||||
+ if (android && event.keyCode == 13)
|
||||
+ view.input.lastAndroidEnter = 0;
|
||||
event.preventDefault();
|
||||
}
|
||||
else {
|
||||
@@ -3885,7 +3907,7 @@ function handleDrop(view, event, dragging) {
|
||||
@@ -3885,7 +3885,7 @@ function handleDrop(view, event, dragging) {
|
||||
tr.mapping.maps[tr.mapping.maps.length - 1].forEach((_from, _to, _newFrom, newTo) => end = newTo);
|
||||
tr.setSelection(selectionBetween(view, $pos, tr.doc.resolve(end)));
|
||||
}
|
||||
@@ -199,44 +44,3 @@ index 61118ee..b82e477 100644
|
||||
view.dispatch(tr.setMeta("uiEvent", "drop"));
|
||||
}
|
||||
handlers.focus = view => {
|
||||
@@ -3914,6 +3936,8 @@ handlers.blur = (view, _event) => {
|
||||
};
|
||||
handlers.beforeinput = (view, _event) => {
|
||||
let event = _event;
|
||||
+ if (android && /^insert(Paragraph|LineBreak)/.test(event.inputType))
|
||||
+ view.input.lastAndroidEnter = Date.now();
|
||||
// We should probably do more with beforeinput events, but support
|
||||
// is so spotty that I'm still waiting to see where they are going.
|
||||
// Very specific hack to deal with backspace sometimes failing on
|
||||
@@ -5139,6 +5163,7 @@ function readDOMChange(view, from, to, typeOver, addedNodes) {
|
||||
(!change || change.endA >= change.endB) &&
|
||||
view.someProp("handleKeyDown", f => f(view, keyEvent(13, "Enter")))) {
|
||||
view.input.lastIOSEnter = 0;
|
||||
+ view.input.lastAndroidEnter = 0;
|
||||
return;
|
||||
}
|
||||
if (!change) {
|
||||
@@ -5193,11 +5218,12 @@ function readDOMChange(view, from, to, typeOver, addedNodes) {
|
||||
// as being an iOS enter press), just dispatch an Enter key instead.
|
||||
if (((ios && view.input.lastIOSEnter > Date.now() - 225 &&
|
||||
(!inlineChange || addedNodes.some(n => n.nodeName == "DIV" || n.nodeName == "P"))) ||
|
||||
- (!inlineChange && $from.pos < parse.doc.content.size &&
|
||||
+ ((!android || view.input.lastAndroidEnter > Date.now() - 225) && !inlineChange && $from.pos < parse.doc.content.size &&
|
||||
(!$from.sameParent($to) || !$from.parent.inlineContent) &&
|
||||
$from.pos < $to.pos && !/\S/.test(parse.doc.textBetween($from.pos, $to.pos, "", "")))) &&
|
||||
view.someProp("handleKeyDown", f => f(view, keyEvent(13, "Enter")))) {
|
||||
view.input.lastIOSEnter = 0;
|
||||
+ view.input.lastAndroidEnter = 0;
|
||||
return;
|
||||
}
|
||||
// Same for backspace
|
||||
@@ -5208,6 +5234,9 @@ function readDOMChange(view, from, to, typeOver, addedNodes) {
|
||||
view.domObserver.suppressSelectionUpdates(); // #820
|
||||
return;
|
||||
}
|
||||
+ // A DOM change is about to be applied, so an Enter keypress (if any) is
|
||||
+ // accounted for -- don't let the Android Enter fallback fire on top of it.
|
||||
+ view.input.lastAndroidEnter = 0;
|
||||
// Chrome will occasionally, during composition, delete the
|
||||
// entire composition and then immediately insert it again. This is
|
||||
// used to detect that situation.
|
||||
|
||||
Reference in New Issue
Block a user