Compare commits

..

8 Commits

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

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

View File

@@ -214,7 +214,7 @@ async function deleteItemsFromTrash(ids: string[]) {
await TaskManager.startTask({
type: "status",
id: "restoreItems",
id: "deleteItems",
title: strings.inProgressActions.permanentlyDeleting.item(ids.length),
action: async (report) => {
report({

View File

@@ -50,6 +50,7 @@ class ThemeStore extends BaseStore<ThemeStore> {
setTheme = (theme: ThemeDefinition) => {
changeDesktopTheme(theme, this.get().followSystemTheme);
Config.set("colorScheme", theme.colorScheme);
Config.set(`theme:${theme.colorScheme}`, theme);
this.set({
[getKey(theme)]: theme,

View File

@@ -30,7 +30,6 @@ import { ErrorText } from "../components/error-text";
import { EVENTS, User } from "@notesnook/core";
import { RecoveryKeyDialog } from "../dialogs/recovery-key-dialog";
import { strings } from "@notesnook/intl";
import { useKeyStore } from "../interfaces/key-store";
type RecoveryMethodType = "key" | "reset";
type RecoveryMethodsFormData = Record<string, unknown>;
@@ -105,8 +104,8 @@ function useAuthenticateUser({
code,
userId
}: {
code: string;
userId: string;
code?: string;
userId?: string;
}) {
const [isAuthenticating, setIsAuthenticating] = useState(true);
const [user, setUser] = useState<User>();
@@ -114,6 +113,10 @@ function useAuthenticateUser({
async function authenticateUser() {
setIsAuthenticating(true);
try {
if (!code || !userId) {
throw new Error("Missing code or userId in query params.");
}
const accessToken = await db.tokenManager.getAccessToken();
if (!accessToken) {
await db.tokenManager.getAccessTokenFromAuthorizationCode(
@@ -181,7 +184,7 @@ function Recovery(props: RecoveryProps) {
}}
variant={"body"}
>
{strings.authenticatedAs(user?.email)}
{user?.email ? strings.authenticatedAs(user.email) : ""}
</Text>
<Button
sx={{
@@ -334,16 +337,25 @@ function RecoveryKeyMethod(props: BaseRecoveryComponentProps<"method:key">) {
subtitle: strings.keyRecoveryProgressDesc()
}}
onSubmit={async (form) => {
const user = await db.user.getUser();
if (!user) throw new Error(strings.notLoggedIn());
const recoveryKey = form.recoveryKey;
if (recoveryKey.length < 40) {
if (
!(await db
.storage()
.encrypt({ key: recoveryKey, salt: user.salt }, "test")
.then(() => true)
.catch(() => false))
) {
throw new Error(strings.invalidRecoveryKey());
}
setProgress(0);
await db.user.verifyEncryptionKey({
key: recoveryKey,
salt: user.salt
});
const user = await db.user.getUser();
if (!user) throw new Error(strings.notLoggedIn());
await useKeyStore.getState().setValue("userEncryptionKey", recoveryKey);
navigate("new", form);
}}
>
@@ -365,7 +377,10 @@ function RecoveryKeyMethod(props: BaseRecoveryComponentProps<"method:key">) {
>
{strings.back()}
</Button>
<SubmitButton text={strings.startAccountRecovery()} sx={{ flex: 1, mt: 0 }} />
<SubmitButton
text={strings.startAccountRecovery()}
sx={{ flex: 1, mt: 0 }}
/>
</Flex>
<Button
@@ -405,29 +420,28 @@ function NewPassword(props: BaseRecoveryComponentProps<"new">) {
subtitle: strings.resetPasswordWait()
}}
onSubmit={async (form) => {
try {
setProgress(0);
setProgress(0);
const user = await db.user.getUser();
if (!user) throw new Error(strings.notLoggedIn());
if (form.password !== form.confirmPassword)
throw new Error("Passwords do not match.");
if (!formData?.recoveryKey)
throw new Error("Recovery key is required to reset password.");
if (formData?.userResetRequired && !(await db.user.resetUser()))
throw new Error("Failed to reset user.");
if (form.password !== form.confirmPassword)
throw new Error("Passwords do not match.");
if (!(await db.user.resetPassword(form.password)))
throw new Error("Could not reset account password.");
if (formData?.userResetRequired && !(await db.user.resetUser()))
throw new Error("Failed to reset user.");
navigate("final");
} catch (e) {
if ((e as Error).message === "invalid input") {
console.error(e);
throw new Error(
"Password reset failed because of invalid recovery key"
);
}
if (
!(await db.user.resetPassword({
encryptionKey: { key: formData?.recoveryKey, salt: user.salt },
newPassword: form.password
}))
)
throw new Error("Could not reset account password.");
throw e;
}
navigate("final");
}}
>
{(form?: NewPasswordFormData) => (

View File

@@ -27,7 +27,7 @@ import { IStorage } from "../src/interfaces.js";
import { randomBytes } from "crypto";
export class NodeStorageInterface implements IStorage {
storage = {};
storage: Record<string, unknown> = {};
crypto = new NNCrypto();
async removeMulti(keys: string[]): Promise<void> {
@@ -49,7 +49,7 @@ export class NodeStorageInterface implements IStorage {
async readMulti<T>(keys: string[]): Promise<[string, T][]> {
const result: [string, T][] = [];
keys.forEach((key) => {
result.push([key, this.storage[key]]);
result.push([key, this.storage[key] as T]);
});
return result;
}
@@ -58,7 +58,7 @@ export class NodeStorageInterface implements IStorage {
key: string,
isArray?: boolean | undefined
): Promise<T | undefined> {
return this.storage[key];
return this.storage[key] as T | undefined;
}
async remove(key: string): Promise<void> {
@@ -98,7 +98,11 @@ export class NodeStorageInterface implements IStorage {
await this.write(`userEncryptionKey`, keyData.key);
}
async hash(password: string, email: string): Promise<string> {
async hash(
password: string,
email: string,
options?: { usesFallback?: boolean }
): Promise<string> {
const APP_SALT = "oVzKtazBo7d8sb7TBvY9jw";
return await this.crypto.hash(password, `${APP_SALT}${email}`);
}
@@ -113,19 +117,34 @@ export class NodeStorageInterface implements IStorage {
password: string,
salt?: string | undefined
): Promise<SerializedKey> {
return { password, salt: salt || randomBytes(16).toString("base64") };
const finalSalt = salt || randomBytes(16).toString("base64");
return await this.crypto.exportKey(password, finalSalt);
}
generateCryptoKeyPair(): Promise<SerializedKeyPair> {
throw new Error("Method not implemented.");
async generatePGPKeyPair(): Promise<SerializedKeyPair> {
return await this.crypto.exportKeyPair();
}
generateCryptoKeyFallback(
async generateCryptoKeyFallback(
password: string,
salt?: string
): Promise<SerializedKey> {
return this.generateCryptoKey(password, salt);
}
async deriveCryptoKeyFallback(_credentials: SerializedKey): Promise<void> {}
async decryptPGPMessage(
_privateKeyArmored: string,
_encryptedMessage: string
): Promise<string> {
throw new Error("Method not implemented.");
}
deriveCryptoKeyFallback(credentials: SerializedKey): Promise<void> {
throw new Error("Method not implemented.");
async validatePGPKeyPair(_keys: SerializedKeyPair): Promise<{
isValid: boolean;
message: string;
}> {
return { isValid: true, message: "ok" };
}
}

View File

@@ -0,0 +1,103 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { test, expect, describe } from "vitest";
import { databaseTest } from "./utils/index.ts";
import { randomBytes } from "../src/utils/random.js";
function validSalt() {
return randomBytes(16).toString("base64");
}
describe("Storage encrypt/decrypt via database", () => {
test("encrypt + decrypt round-trip through storage", async () => {
await databaseTest().then(async (db) => {
const key = await db.crypto().generateRandomKey();
const plaintext = "test data for storage";
const cipher = await db.storage().encrypt(key, plaintext);
const decrypted = await db.storage().decrypt(key, cipher);
expect(decrypted).toBe(plaintext);
});
});
test("deriveCryptoKey stores key, getCryptoKey retrieves it", async () => {
await databaseTest().then(async (db) => {
const salt = validSalt();
await db.storage().deriveCryptoKey({ password: "mypassword", salt });
const storedKey = await db.storage().getCryptoKey();
expect(storedKey).toBeDefined();
expect(typeof storedKey).toBe("string");
expect(storedKey!.length).toBeGreaterThan(0);
});
});
test("generateCryptoKey returns SerializedKey", async () => {
await databaseTest().then(async (db) => {
const salt = validSalt();
const generated = await db.storage().generateCryptoKey("password", salt);
expect(generated.salt).toBe(salt);
expect(generated.key).toBeDefined();
});
});
test("generateCryptoKey without salt generates one", async () => {
await databaseTest().then(async (db) => {
const generated = await db.storage().generateCryptoKey("password");
expect(generated.salt).toBeDefined();
});
});
test("encrypt with derived key works after deriveCryptoKey", async () => {
await databaseTest().then(async (db) => {
const salt = validSalt();
const password = "secure-password";
await db.storage().deriveCryptoKey({ password, salt });
const keyStr = await db.storage().getCryptoKey();
const key = { key: keyStr!, salt };
const plaintext = "encrypted with derived key";
const cipher = await db.storage().encrypt(key, plaintext);
const decrypted = await db.storage().decrypt(key, cipher);
expect(decrypted).toBe(plaintext);
});
});
test("deriveCryptoKey with empty password or salt is a no-op", async () => {
await databaseTest().then(async (db) => {
await db.storage().deriveCryptoKey({ password: "", salt: "salt" });
const key = await db.storage().getCryptoKey();
expect(key).toBeUndefined();
await db.storage().deriveCryptoKey({ password: "pass", salt: "" });
const key2 = await db.storage().getCryptoKey();
expect(key2).toBeUndefined();
});
});
test("encryptMulti/decryptMulti round-trip through storage", async () => {
await databaseTest().then(async (db) => {
const key = await db.crypto().generateRandomKey();
const items = ["hello", "world", "test123"];
const ciphers = await db.storage().encryptMulti(key, items);
const decrypted = await db.storage().decryptMulti(key, ciphers);
expect(decrypted).toEqual(items);
});
});
});

View File

@@ -0,0 +1,407 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { test, expect, describe, vi } from "vitest";
import { databaseTest, loginFakeUser } from "./utils/index.ts";
import { KeyManager } from "../src/api/key-manager.js";
import { randomBytes } from "../src/utils/random.js";
// ─── verifyPassword ────────────────────────────────────────────────
describe("UserManager.verifyPassword", () => {
test("correct password returns true", async () => {
await databaseTest().then(async (db) => {
await loginFakeUser(db);
const result = await db.user.verifyPassword("password");
expect(result).toBe(true);
});
});
test("incorrect password returns false", async () => {
await databaseTest().then(async (db) => {
await loginFakeUser(db);
const result = await db.user.verifyPassword("wrongpassword");
expect(result).toBe(false);
});
});
test("no user returns false", async () => {
await databaseTest().then(async (db) => {
const result = await db.user.verifyPassword("anything");
expect(result).toBe(false);
});
});
test("no master key returns false", async () => {
await databaseTest().then(async (db) => {
const userSalt = randomBytes(16).toString("base64");
await db.user.setUser({
id: "user-123",
email: "test@example.com",
isEmailConfirmed: true,
salt: userSalt,
mfa: { isEnabled: false, primaryMethod: "app", remainingValidCodes: 0 },
subscription: { appId: 0, cancelURL: null, expiry: 0, productId: null, provider: "none", start: 0, plan: "free", status: "trial", updateURL: null, googlePurchaseToken: null }
} as any);
const result = await db.user.verifyPassword("anything");
expect(result).toBe(false);
});
});
});
// ─── verifyEncryptionKey ───────────────────────────────────────────
describe("UserManager.verifyEncryptionKey", () => {
describe("user with both legacyDataEncryptionKey and dataEncryptionKey", () => {
test("valid key does not throw", async () => {
await databaseTest().then(async (db) => {
const password = "mypassword";
const salt = randomBytes(16).toString("base64");
// Set up user with crypto key derived from our password
await db.user.setUser({
id: "user-123", email: "test@example.com", isEmailConfirmed: true,
salt, mfa: { isEnabled: false, primaryMethod: "app", remainingValidCodes: 0 },
subscription: { appId: 0, cancelURL: null, expiry: 0, productId: null, provider: "none", start: 0, plan: "free", status: "trial", updateURL: null, googlePurchaseToken: null }
} as any);
await db.storage().deriveCryptoKey({ password, salt });
const masterKey = await db.user.getMasterKey();
const km = new KeyManager(db);
const randomKey = await db.crypto().generateRandomKey();
const legacyDEK = await km.wrapKey(randomKey, masterKey!);
const dek = await km.wrapKey(randomKey, masterKey!);
const user = await db.user.getUser();
await db.user.setUser({ ...user, legacyDataEncryptionKey: legacyDEK, dataEncryptionKey: dek });
await expect(
db.user.verifyEncryptionKey({ password, salt })
).resolves.toBeUndefined();
});
});
test("invalid key throws", async () => {
await databaseTest().then(async (db) => {
const password = "correct";
const salt = randomBytes(16).toString("base64");
await db.user.setUser({
id: "user-123", email: "test@example.com", isEmailConfirmed: true,
salt, mfa: { isEnabled: false, primaryMethod: "app", remainingValidCodes: 0 },
subscription: { appId: 0, cancelURL: null, expiry: 0, productId: null, provider: "none", start: 0, plan: "free", status: "trial", updateURL: null, googlePurchaseToken: null }
} as any);
await db.storage().deriveCryptoKey({ password, salt });
const masterKey = await db.user.getMasterKey();
const km = new KeyManager(db);
const randomKey = await db.crypto().generateRandomKey();
const legacyDEK = await km.wrapKey(randomKey, masterKey!);
const dek = await km.wrapKey(randomKey, masterKey!);
const user = await db.user.getUser();
await db.user.setUser({ ...user, legacyDataEncryptionKey: legacyDEK, dataEncryptionKey: dek });
await expect(
db.user.verifyEncryptionKey({ password: "wrongpassword", salt })
).rejects.toThrow();
});
});
});
describe("user with neither legacyDataEncryptionKey nor dataEncryptionKey", () => {
test("valid key using attachmentsKey verifier does not throw", async () => {
await databaseTest().then(async (db) => {
const password = "mypassword";
const salt = randomBytes(16).toString("base64");
await db.user.setUser({
id: "user-123", email: "test@example.com", isEmailConfirmed: true,
salt, mfa: { isEnabled: false, primaryMethod: "app", remainingValidCodes: 0 },
subscription: { appId: 0, cancelURL: null, expiry: 0, productId: null, provider: "none", start: 0, plan: "free", status: "trial", updateURL: null, googlePurchaseToken: null }
} as any);
await db.storage().deriveCryptoKey({ password, salt });
const masterKey = await db.user.getMasterKey();
const km = new KeyManager(db);
const key = await db.crypto().generateRandomKey();
const attachmentsKey = await km.wrapKey(key, masterKey!);
const user = await db.user.getUser();
await db.user.setUser({ ...user, attachmentsKey });
await expect(
db.user.verifyEncryptionKey({ password, salt })
).resolves.toBeUndefined();
});
});
test("invalid key using attachmentsKey verifier throws", async () => {
await databaseTest().then(async (db) => {
const password = "correct";
const salt = randomBytes(16).toString("base64");
await db.user.setUser({
id: "user-123", email: "test@example.com", isEmailConfirmed: true,
salt, mfa: { isEnabled: false, primaryMethod: "app", remainingValidCodes: 0 },
subscription: { appId: 0, cancelURL: null, expiry: 0, productId: null, provider: "none", start: 0, plan: "free", status: "trial", updateURL: null, googlePurchaseToken: null }
} as any);
await db.storage().deriveCryptoKey({ password, salt });
const masterKey = await db.user.getMasterKey();
const km = new KeyManager(db);
const key = await db.crypto().generateRandomKey();
const attachmentsKey = await km.wrapKey(key, masterKey!);
const user = await db.user.getUser();
await db.user.setUser({ ...user, attachmentsKey });
await expect(
db.user.verifyEncryptionKey({ password: "wrongpassword", salt })
).rejects.toThrow(
"Your data cannot be decrypted using the provided encryption key."
);
});
});
test("uses monographPasswordsKey when no attachmentsKey exists", async () => {
await databaseTest().then(async (db) => {
const password = "mypassword";
const salt = randomBytes(16).toString("base64");
await db.user.setUser({
id: "user-123", email: "test@example.com", isEmailConfirmed: true,
salt, mfa: { isEnabled: false, primaryMethod: "app", remainingValidCodes: 0 },
subscription: { appId: 0, cancelURL: null, expiry: 0, productId: null, provider: "none", start: 0, plan: "free", status: "trial", updateURL: null, googlePurchaseToken: null }
} as any);
await db.storage().deriveCryptoKey({ password, salt });
const masterKey = await db.user.getMasterKey();
const km = new KeyManager(db);
const key = await db.crypto().generateRandomKey();
const monographKey = await km.wrapKey(key, masterKey!);
const user = await db.user.getUser();
// Ensure no attachmentsKey, only monographPasswordsKey
await db.user.setUser({ ...user, attachmentsKey: undefined, monographPasswordsKey: monographKey });
await expect(
db.user.verifyEncryptionKey({ password, salt })
).resolves.toBeUndefined();
});
});
test("fetches verifier from server when no local keys exist", async () => {
const http = (await import("../src/utils/http.js")).default;
const mockGet = vi.spyOn(http, "get").mockResolvedValue(undefined);
await databaseTest().then(async (db) => {
const password = "mypassword";
const salt = randomBytes(16).toString("base64");
await db.user.setUser({
id: "user-123", email: "test@example.com", isEmailConfirmed: true,
salt, mfa: { isEnabled: false, primaryMethod: "app", remainingValidCodes: 0 },
subscription: { appId: 0, cancelURL: null, expiry: 0, productId: null, provider: "none", start: 0, plan: "free", status: "trial", updateURL: null, googlePurchaseToken: null }
} as any);
await db.storage().deriveCryptoKey({ password, salt });
// Set a token so getAccessToken returns something
await db.kv().write("token", {
access_token: "fake-token",
t: Date.now(),
expires_in: 3600,
scope: "notesnook.sync IdentityServerApi",
refresh_token: "fake-refresh"
});
// No attachmentsKey, no monographPasswordsKey
// Server returns undefined — should throw
await expect(
db.user.verifyEncryptionKey({ password, salt })
).rejects.toThrow(
"Encryption key cannot be verified: no encryption verifier found."
);
});
mockGet.mockRestore();
});
test("uses server-provided verifier cipher when available", async () => {
const http = (await import("../src/utils/http.js")).default;
await databaseTest().then(async (db) => {
const password = "mypassword";
const salt = randomBytes(16).toString("base64");
await db.user.setUser({
id: "user-123", email: "test@example.com", isEmailConfirmed: true,
salt, mfa: { isEnabled: false, primaryMethod: "app", remainingValidCodes: 0 },
subscription: { appId: 0, cancelURL: null, expiry: 0, productId: null, provider: "none", start: 0, plan: "free", status: "trial", updateURL: null, googlePurchaseToken: null }
} as any);
await db.storage().deriveCryptoKey({ password, salt });
await db.kv().write("token", {
access_token: "fake-token",
t: Date.now(),
expires_in: 3600,
scope: "notesnook.sync IdentityServerApi",
refresh_token: "fake-refresh"
});
// Create a verifier cipher that the correct key can decrypt
const verifierCipher = await db.storage().encrypt(
{ password, salt },
"test-data"
);
const mockGet = vi.spyOn(http, "get").mockResolvedValueOnce(verifierCipher);
await expect(
db.user.verifyEncryptionKey({ password, salt })
).resolves.toBeUndefined();
expect(mockGet).toHaveBeenCalledWith(
expect.stringContaining("/users/verifier"),
"fake-token"
);
mockGet.mockRestore();
});
});
test("server verifier rejects wrong key", async () => {
const http = (await import("../src/utils/http.js")).default;
await databaseTest().then(async (db) => {
const salt = randomBytes(16).toString("base64");
await db.user.setUser({
id: "user-123", email: "test@example.com", isEmailConfirmed: true,
salt, mfa: { isEnabled: false, primaryMethod: "app", remainingValidCodes: 0 },
subscription: { appId: 0, cancelURL: null, expiry: 0, productId: null, provider: "none", start: 0, plan: "free", status: "trial", updateURL: null, googlePurchaseToken: null }
} as any);
await db.storage().deriveCryptoKey({ password: "correct-password", salt });
await db.kv().write("token", {
access_token: "fake-token",
t: Date.now(),
expires_in: 3600,
scope: "notesnook.sync IdentityServerApi",
refresh_token: "fake-refresh"
});
// Verifier encrypted with correct password
const verifierCipher = await db.storage().encrypt(
{ password: "correct-password", salt },
"test-data"
);
const mockGet = vi.spyOn(http, "get").mockResolvedValueOnce(verifierCipher);
// Wrong key should fail verification
await expect(
db.user.verifyEncryptionKey({ password: "wrong-password", salt })
).rejects.toThrow(
"Your data cannot be decrypted using the provided encryption key."
);
mockGet.mockRestore();
});
});
});
describe("user with only one of legacy DEK or DEK", () => {
test("succeeds when only dataEncryptionKey exists", async () => {
await databaseTest().then(async (db) => {
const password = "password";
const salt = randomBytes(16).toString("base64");
await db.user.setUser({
id: "user-123", email: "test@example.com", isEmailConfirmed: true,
salt, mfa: { isEnabled: false, primaryMethod: "app", remainingValidCodes: 0 },
subscription: { appId: 0, cancelURL: null, expiry: 0, productId: null, provider: "none", start: 0, plan: "free", status: "trial", updateURL: null, googlePurchaseToken: null }
} as any);
await db.storage().deriveCryptoKey({ password, salt });
const masterKey = await db.user.getMasterKey();
const km = new KeyManager(db);
const randomKey = await db.crypto().generateRandomKey();
const dek = await km.wrapKey(randomKey, masterKey!);
const user = await db.user.getUser();
await db.user.setUser({ ...user, dataEncryptionKey: dek });
await expect(
db.user.verifyEncryptionKey({ password, salt })
).resolves.toBeUndefined();
});
});
test("succeeds when only legacyDataEncryptionKey exists", async () => {
await databaseTest().then(async (db) => {
const password = "password";
const salt = randomBytes(16).toString("base64");
await db.user.setUser({
id: "user-123", email: "test@example.com", isEmailConfirmed: true,
salt, mfa: { isEnabled: false, primaryMethod: "app", remainingValidCodes: 0 },
subscription: { appId: 0, cancelURL: null, expiry: 0, productId: null, provider: "none", start: 0, plan: "free", status: "trial", updateURL: null, googlePurchaseToken: null }
} as any);
await db.storage().deriveCryptoKey({ password, salt });
const masterKey = await db.user.getMasterKey();
const km = new KeyManager(db);
const randomKey = await db.crypto().generateRandomKey();
const legacyDEK = await km.wrapKey(randomKey, masterKey!);
const user = await db.user.getUser();
await db.user.setUser({ ...user, legacyDataEncryptionKey: legacyDEK });
await expect(
db.user.verifyEncryptionKey({ password, salt })
).resolves.toBeUndefined();
});
});
test("invalid key throws even when only a single DEK exists", async () => {
await databaseTest().then(async (db) => {
const password = "correct";
const salt = randomBytes(16).toString("base64");
await db.user.setUser({
id: "user-123", email: "test@example.com", isEmailConfirmed: true,
salt, mfa: { isEnabled: false, primaryMethod: "app", remainingValidCodes: 0 },
subscription: { appId: 0, cancelURL: null, expiry: 0, productId: null, provider: "none", start: 0, plan: "free", status: "trial", updateURL: null, googlePurchaseToken: null }
} as any);
await db.storage().deriveCryptoKey({ password, salt });
const masterKey = await db.user.getMasterKey();
const km = new KeyManager(db);
const randomKey = await db.crypto().generateRandomKey();
const dek = await km.wrapKey(randomKey, masterKey!);
const user = await db.user.getUser();
await db.user.setUser({ ...user, dataEncryptionKey: dek });
await expect(
db.user.verifyEncryptionKey({ password: "wrongpassword", salt })
).rejects.toThrow(
"Your data cannot be decrypted using the provided encryption key."
);
});
});
});
describe("no user", () => {
test("throws when no user exists", async () => {
await databaseTest().then(async (db) => {
await expect(
db.user.verifyEncryptionKey({ password: "test", salt: "salt" })
).rejects.toThrow("User not found.");
});
});
});
});

View File

@@ -0,0 +1,274 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { test, expect, describe } from "vitest";
import { databaseTest, loginFakeUser } from "./utils/index.ts";
import { KeyManager } from "../src/api/key-manager.js";
import { randomBytes } from "../src/utils/random.js";
function validSalt() {
return randomBytes(16).toString("base64");
}
describe("KeyManager", () => {
describe("wrapKey + unwrapKey (symmetric key)", () => {
test("round-trip: wrap then unwrap returns original key", async () => {
await databaseTest().then(async (db) => {
await loginFakeUser(db);
const km = new KeyManager(db);
const masterKey = await db.user.getMasterKey();
expect(masterKey).toBeDefined();
const originalKey = await db.crypto().generateRandomKey();
const wrapped = await km.wrapKey(originalKey, masterKey!);
expect(wrapped).toBeDefined();
expect(wrapped).toHaveProperty("cipher");
const unwrapped = await km.unwrapKey(wrapped, masterKey!);
expect(unwrapped).toEqual(originalKey);
});
});
test("unwrap with wrong key throws", async () => {
await databaseTest().then(async (db) => {
await loginFakeUser(db);
const km = new KeyManager(db);
const masterKey = await db.user.getMasterKey();
const wrongKey = await db.storage().generateCryptoKey("wrongpassword", validSalt());
const originalKey = await db.crypto().generateRandomKey();
const wrapped = await km.wrapKey(originalKey, masterKey!);
await expect(km.unwrapKey(wrapped, wrongKey)).rejects.toThrow();
});
});
test("wrapped key is a Cipher with base64 format", async () => {
await databaseTest().then(async (db) => {
await loginFakeUser(db);
const km = new KeyManager(db);
const masterKey = await db.user.getMasterKey();
const originalKey = await db.crypto().generateRandomKey();
const wrapped = await km.wrapKey(originalKey, masterKey!);
expect(wrapped).toHaveProperty("format", "base64");
expect(wrapped).toHaveProperty("alg");
expect(typeof (wrapped as any).cipher).toBe("string");
expect(typeof (wrapped as any).iv).toBe("string");
expect(typeof (wrapped as any).salt).toBe("string");
});
});
});
describe("rewrapKey", () => {
test("symmetric: rewrap succeeds with new key", async () => {
await databaseTest().then(async (db) => {
const km = new KeyManager(db);
const oldKey = await db.storage().generateCryptoKey("oldpassword", validSalt());
const newKey = await db.storage().generateCryptoKey("newpassword", validSalt());
const originalKey = await db.crypto().generateRandomKey();
const wrapped = await km.wrapKey(originalKey, oldKey);
const rewrapped = await km.rewrapKey(wrapped, oldKey, newKey);
const unwrapped = await km.unwrapKey(rewrapped, newKey);
expect(unwrapped).toEqual(originalKey);
});
});
test("symmetric: rewrap fails when unwrap with wrong old key", async () => {
await databaseTest().then(async (db) => {
const km = new KeyManager(db);
const correctOldKey = await db.storage().generateCryptoKey("correct", validSalt());
const wrongOldKey = await db.storage().generateCryptoKey("wrong", validSalt());
const newKey = await db.storage().generateCryptoKey("newpassword", validSalt());
const originalKey = await db.crypto().generateRandomKey();
const wrapped = await km.wrapKey(originalKey, correctOldKey);
await expect(km.rewrapKey(wrapped, wrongOldKey, newKey)).rejects.toThrow();
});
});
test("rewrap produces different ciphertext than original", async () => {
await databaseTest().then(async (db) => {
const km = new KeyManager(db);
const key1 = await db.storage().generateCryptoKey("password1", validSalt());
const key2 = await db.storage().generateCryptoKey("password2", validSalt());
const originalKey = await db.crypto().generateRandomKey();
const wrapped = await km.wrapKey(originalKey, key1);
const rewrapped = await km.rewrapKey(wrapped, key1, key2);
expect((rewrapped as any).cipher).not.toBe((wrapped as any).cipher);
expect((rewrapped as any).iv).not.toBe((wrapped as any).iv);
});
});
});
describe("get() caching", () => {
test("get() populates cache and returns cached value", async () => {
await databaseTest().then(async (db) => {
await loginFakeUser(db);
const km = new KeyManager(db);
const key1 = await km.get("attachmentsKey", { useCache: false, refetchUser: false });
expect(key1).toBeDefined();
const key2 = await km.get("attachmentsKey", { useCache: true, refetchUser: false });
expect(key2).toBeDefined();
expect(key2).toEqual(key1);
});
});
test("clearCache() forces fresh fetch on next get()", async () => {
await databaseTest().then(async (db) => {
await loginFakeUser(db);
const km = new KeyManager(db);
await km.get("attachmentsKey", { useCache: true, refetchUser: false });
km.clearCache();
const key = await km.get("attachmentsKey", { useCache: false, refetchUser: false });
expect(key).toBeDefined();
});
});
});
describe("get() edge cases", () => {
test("get() returns undefined when user has no key for that ID", async () => {
await databaseTest().then(async (db) => {
await loginFakeUser(db);
const km = new KeyManager(db);
const key = await km.get("dataEncryptionKey", { useCache: false, refetchUser: false });
expect(key).toBeUndefined();
});
});
test("get() returns undefined when no user exists", async () => {
await databaseTest().then(async (db) => {
const km = new KeyManager(db);
const key = await km.get("attachmentsKey", { useCache: false, refetchUser: false });
expect(key).toBeUndefined();
});
});
test("get() returns all key types when they exist", async () => {
await databaseTest().then(async (db) => {
await loginFakeUser(db);
const km = new KeyManager(db);
const masterKey = await db.user.getMasterKey();
const randomKey = await db.crypto().generateRandomKey();
const user = await db.user.getUser();
await db.user.setUser({
...user,
attachmentsKey: await km.wrapKey(randomKey, masterKey!),
monographPasswordsKey: await km.wrapKey(randomKey, masterKey!),
dataEncryptionKey: await km.wrapKey(randomKey, masterKey!)
});
const attachmentsKey = await km.get("attachmentsKey", { useCache: false, refetchUser: false });
const monographKey = await km.get("monographPasswordsKey", { useCache: false, refetchUser: false });
const dek = await km.get("dataEncryptionKey", { useCache: false, refetchUser: false });
expect(attachmentsKey).toBeDefined();
expect(monographKey).toBeDefined();
expect(dek).toBeDefined();
});
});
});
describe("wrapKey/unwrapKey with password-derived key", () => {
test("wrap with password key, derive same password, unwrap succeeds", async () => {
await databaseTest().then(async (db) => {
const km = new KeyManager(db);
const salt = validSalt();
const passwordKey = await db.storage().generateCryptoKey("mypassword", salt);
const originalKey = await db.crypto().generateRandomKey();
const wrapped = await km.wrapKey(originalKey, passwordKey);
const samePasswordKey = await db.storage().generateCryptoKey("mypassword", salt);
const unwrapped = await km.unwrapKey(wrapped, samePasswordKey);
expect(unwrapped).toEqual(originalKey);
});
});
test("wrap with different password fails to unwrap", async () => {
await databaseTest().then(async (db) => {
const km = new KeyManager(db);
const key1 = await db.storage().generateCryptoKey("password1", validSalt());
const key2 = await db.storage().generateCryptoKey("password2", validSalt());
const originalKey = await db.crypto().generateRandomKey();
const wrapped = await km.wrapKey(originalKey, key1);
await expect(km.unwrapKey(wrapped, key2)).rejects.toThrow();
});
});
});
describe("Data integrity", () => {
test("wrap/unwrap preserves large symmetric key", async () => {
await databaseTest().then(async (db) => {
const km = new KeyManager(db);
await loginFakeUser(db);
const masterKey = await db.user.getMasterKey();
const largeKey = await db.storage().generateCryptoKey("x".repeat(1000), validSalt());
const wrapped = await km.wrapKey(largeKey, masterKey!);
const unwrapped = await km.unwrapKey(wrapped, masterKey!);
expect(unwrapped).toEqual(largeKey);
});
});
test("multiple wrap/unwrap cycles with different keys", async () => {
await databaseTest().then(async (db) => {
const km = new KeyManager(db);
let currentKey = await db.storage().generateCryptoKey("initial", validSalt());
const originalData = await db.crypto().generateRandomKey();
const key2 = await db.storage().generateCryptoKey("second", validSalt());
const key3 = await db.storage().generateCryptoKey("third", validSalt());
let wrapped = await km.wrapKey(originalData, currentKey);
wrapped = await km.rewrapKey(wrapped, currentKey, key2);
wrapped = await km.rewrapKey(wrapped, key2, key3);
wrapped = await km.rewrapKey(wrapped, key3, currentKey);
const unwrapped = await km.unwrapKey(wrapped, currentKey);
expect(unwrapped).toEqual(originalData);
});
});
});
});

View File

@@ -0,0 +1,423 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { test, expect, describe, vi } from "vitest";
import { databaseTest } from "./utils/index.ts";
import { KeyManager } from "../src/api/key-manager.js";
import { randomBytes } from "../src/utils/random.js";
const FULL_USER: any = {
id: "user-123",
email: "test@example.com",
isEmailConfirmed: true,
salt: "",
mfa: { isEnabled: false, primaryMethod: "app", remainingValidCodes: 0 },
subscription: { appId: 0, cancelURL: null, expiry: 0, productId: null, provider: "none", start: 0, plan: "free", status: "trial", updateURL: null, googlePurchaseToken: null }
};
vi.mock("../src/utils/http.js", () => ({
default: {
get: vi.fn().mockResolvedValue(undefined),
post: vi.fn().mockResolvedValue(undefined),
patch: { json: vi.fn().mockResolvedValue(undefined) },
delete: vi.fn().mockResolvedValue(undefined)
}
}));
async function setupLoggedInUser(db: any, password: string = "oldpassword") {
const salt = randomBytes(16).toString("base64");
const user = { ...FULL_USER, salt };
await db.user.setUser(user);
await db.storage().deriveCryptoKey({ password, salt });
await db.kv().write("token", {
access_token: "fake-token",
t: Date.now(),
expires_in: 3600,
scope: "notesnook.sync offline_access IdentityServerApi",
refresh_token: "fake-refresh"
});
const km = new KeyManager(db);
const masterKey = await db.user.getMasterKey();
const randomKey = await db.crypto().generateRandomKey();
const attachmentsKey = await km.wrapKey(randomKey, masterKey!);
const monographPasswordsKey = await km.wrapKey(randomKey, masterKey!);
const dek = await km.wrapKey(randomKey, masterKey!);
const legacyDEK = await km.wrapKey(randomKey, masterKey!);
await db.user.setUser({
...user,
attachmentsKey,
monographPasswordsKey,
dataEncryptionKey: dek,
legacyDataEncryptionKey: legacyDEK
});
return { password, salt, masterKey, randomKey };
}
// ─── _updatePassword (change) ──────────────────────────────────────
describe("UserManager._updatePassword (change)", () => {
test("successful password change rewraps all keys and derives new master key", async () => {
await databaseTest().then(async (db) => {
await setupLoggedInUser(db, "oldpassword");
const result = await db.user.changePassword("oldpassword", "newpassword");
expect(result).toBe(true);
const newMasterKey = await db.user.getMasterKey();
expect(newMasterKey).toBeDefined();
expect(newMasterKey!.key).not.toBe("");
});
});
test("wrong old password throws 'Incorrect old password'", async () => {
await databaseTest().then(async (db) => {
await setupLoggedInUser(db, "oldpassword");
await expect(
db.user.changePassword("wrongpassword", "newpassword")
).rejects.toThrow("Incorrect old password");
});
});
test("new master key decrypts dataEncryptionKey and old master key throws (regression)", async () => {
await databaseTest().then(async (db) => {
const { salt } = await setupLoggedInUser(db, "oldpassword");
// Simulate a full password change with old -> new passwords.
const result = await db.user.changePassword("oldpassword", "newpassword");
expect(result).toBe(true);
const user = await db.user.getUser();
// The stored master key must now be the one derived from the NEW password.
const newMasterKey = await db.user.getMasterKey();
expect(newMasterKey).toBeDefined();
expect(newMasterKey!.key).toBe(
(await db.storage().generateCryptoKey("newpassword", salt)).key
);
// (a) The NEW master key must decrypt the rewrapped dataEncryptionKey.
await expect(
db.storage().decrypt(newMasterKey!, user!.dataEncryptionKey!)
).resolves.toBeDefined();
// (b) The OLD master key must NOT be able to decrypt it anymore.
// We derive it without persisting it to the key store.
const oldMasterKey = await db.storage().generateCryptoKey(
"oldpassword",
salt
);
await expect(
db.storage().decrypt(oldMasterKey, user!.dataEncryptionKey!)
).rejects.toThrow();
});
});
test("empty new password throws 'New password is required'", async () => {
await databaseTest().then(async (db) => {
await setupLoggedInUser(db, "oldpassword");
await expect(
db.user.changePassword("oldpassword", "")
).rejects.toThrow("New password is required");
});
});
test("no logged in user throws", async () => {
await databaseTest().then(async (db) => {
await expect(
db.user.changePassword("oldpassword", "newpassword")
).rejects.toThrow();
});
});
test("keys are rewrapped: old master key can no longer unwrap", async () => {
await databaseTest().then(async (db) => {
const { masterKey: oldMasterKey } = await setupLoggedInUser(db, "oldpassword");
await db.user.changePassword("oldpassword", "newpassword");
const user = await db.user.getUser();
const km = new KeyManager(db);
if (user.attachmentsKey) {
await expect(
km.unwrapKey(user.attachmentsKey, oldMasterKey!)
).rejects.toThrow();
}
});
});
test("new master key can unwrap all rewrapped keys", async () => {
await databaseTest().then(async (db) => {
await setupLoggedInUser(db, "oldpassword");
await db.user.changePassword("oldpassword", "newpassword");
const newMasterKey = await db.user.getMasterKey();
const user = await db.user.getUser();
const km = new KeyManager(db);
if (user.attachmentsKey) {
const key = await km.unwrapKey(user.attachmentsKey, newMasterKey!);
expect(key).toBeDefined();
}
if (user.monographPasswordsKey) {
const key = await km.unwrapKey(user.monographPasswordsKey, newMasterKey!);
expect(key).toBeDefined();
}
if (user.dataEncryptionKey) {
const key = await km.unwrapKey(user.dataEncryptionKey, newMasterKey!);
expect(key).toBeDefined();
}
if (user.legacyDataEncryptionKey) {
const key = await km.unwrapKey(user.legacyDataEncryptionKey, newMasterKey!);
expect(key).toBeDefined();
}
});
});
test("rewrapped user keys are stored with new ciphertext", async () => {
await databaseTest().then(async (db) => {
await setupLoggedInUser(db, "oldpassword");
const km = new KeyManager(db);
// Fetch the attachmentsKey BEFORE password change
const oldKey = await km.get("attachmentsKey", {
useCache: false,
refetchUser: false
});
expect(oldKey).toBeDefined();
const oldCipher = (oldKey as any).cipher;
// Change password — keys are rewrapped and user is updated
await db.user.changePassword("oldpassword", "newpassword");
// The new user object in KV should have a rewrapped attachmentsKey
// (with new ciphertext)
const newKey = await km.get("attachmentsKey", {
useCache: false,
refetchUser: false
});
expect(newKey).toBeDefined();
// The re-fetched cipher MUST differ from the old one because the key
// was re-wrapped with a new master key (different ciphertext).
expect((newKey as any).cipher).not.toBe(oldCipher);
// Verify the new key can be unwrapped with the new master key
const newMasterKey = await db.user.getMasterKey();
const unwrapped = await km.unwrapKey(newKey, newMasterKey!);
expect(unwrapped).toBeDefined();
});
});
});
// ─── _updatePassword (reset) ───────────────────────────────────────
describe("UserManager._updatePassword (reset)", () => {
test("successful reset with explicit encryption key", async () => {
await databaseTest().then(async (db) => {
const { password, salt } = await setupLoggedInUser(db, "oldpassword");
const result = await db.user.resetPassword({
newPassword: "newpassword",
encryptionKey: { password, salt }
});
expect(result).toBe(true);
const newMasterKey = await db.user.getMasterKey();
expect(newMasterKey).toBeDefined();
});
});
test("reset without encryption key throws", async () => {
await databaseTest().then(async (db) => {
await setupLoggedInUser(db, "oldpassword");
await expect(
db.user.resetPassword({ newPassword: "newpassword" } as any)
).rejects.toThrow("Encryption key is required.");
});
});
test("empty new password throws 'New password is required'", async () => {
await databaseTest().then(async (db) => {
const { password, salt } = await setupLoggedInUser(db, "oldpassword");
await expect(
db.user.resetPassword({ newPassword: "", encryptionKey: { password, salt } })
).rejects.toThrow("New password is required");
});
});
test("reset with wrong encryption key throws during verification", async () => {
await databaseTest().then(async (db) => {
await setupLoggedInUser(db, "oldpassword");
await expect(
db.user.resetPassword({
newPassword: "newpassword",
encryptionKey: { password: "wrongpassword", salt: "wrongsalt" }
})
).rejects.toThrow();
});
});
});
// ─── Key migration scenarios ───────────────────────────────────────
describe("Key migration during password change", () => {
test("legacy user (no DEK, no legacy DEK) gets new DEK and legacy DEK created", async () => {
await databaseTest().then(async (db) => {
const password = "oldpassword";
const salt = randomBytes(16).toString("base64");
await db.user.setUser({ ...FULL_USER, salt });
await db.storage().deriveCryptoKey({ password, salt });
await db.kv().write("token", {
access_token: "fake-token", t: Date.now(), expires_in: 3600,
scope: "notesnook.sync offline_access IdentityServerApi", refresh_token: "fake-refresh"
});
const km = new KeyManager(db);
const masterKey = await db.user.getMasterKey();
const randomKey = await db.crypto().generateRandomKey();
const attachmentsKey = await km.wrapKey(randomKey, masterKey!);
await db.user.setUser({ ...FULL_USER, salt, attachmentsKey });
const result = await db.user.changePassword("oldpassword", "newpassword");
expect(result).toBe(true);
const user = await db.user.getUser();
expect(user.dataEncryptionKey).toBeDefined();
expect(user.legacyDataEncryptionKey).toBeDefined();
});
});
test("user with both DEK and legacy DEK: both are rewrapped", async () => {
await databaseTest().then(async (db) => {
await setupLoggedInUser(db, "oldpassword");
const userBefore = await db.user.getUser();
const originalDEKCipher = userBefore.dataEncryptionKey?.cipher;
const originalLegacyDEKCipher = userBefore.legacyDataEncryptionKey?.cipher;
await db.user.changePassword("oldpassword", "newpassword");
const userAfter = await db.user.getUser();
expect(userAfter.dataEncryptionKey?.cipher).not.toBe(originalDEKCipher);
expect(userAfter.legacyDataEncryptionKey?.cipher).not.toBe(originalLegacyDEKCipher);
});
});
test("user with only DEK (no legacy): password change succeeds verification", async () => {
await databaseTest().then(async (db) => {
const password = "oldpassword";
const salt = randomBytes(16).toString("base64");
await db.user.setUser({ ...FULL_USER, salt });
await db.storage().deriveCryptoKey({ password, salt });
await db.kv().write("token", {
access_token: "fake-token", t: Date.now(), expires_in: 3600,
scope: "notesnook.sync offline_access IdentityServerApi", refresh_token: "fake-refresh"
});
const km = new KeyManager(db);
const masterKey = await db.user.getMasterKey();
const randomKey = await db.crypto().generateRandomKey();
const dek = await km.wrapKey(randomKey, masterKey!);
await db.user.setUser({ ...FULL_USER, salt, dataEncryptionKey: dek });
// A single DEK is now a valid verifier, so the change should succeed
// and the DEK should be rewrapped with the new password.
const result = await db.user.changePassword("oldpassword", "newpassword");
expect(result).toBe(true);
const userAfter = await db.user.getUser();
expect(userAfter.dataEncryptionKey).toBeDefined();
expect(userAfter.dataEncryptionKey?.cipher).not.toBe(dek.cipher);
});
});
test("inbox keys are rewrapped during password change", async () => {
await databaseTest().then(async (db) => {
await setupLoggedInUser(db, "oldpassword");
const km = new KeyManager(db);
const masterKey = await db.user.getMasterKey();
// Use a fake keypair since generatePGPKeyPair depends on unavailable sodium API
const fakeKeyPair = { publicKey: "fake-public-key", privateKey: "fake-private-key" };
const wrappedInboxKeys = await km.wrapKey(fakeKeyPair as any, masterKey!);
const user = await db.user.getUser();
await db.user.setUser({ ...user, inboxKeys: wrappedInboxKeys });
await db.user.changePassword("oldpassword", "newpassword");
const userAfter = await db.user.getUser();
const newMasterKey = await db.user.getMasterKey();
expect(userAfter.inboxKeys).toBeDefined();
const unwrappedKeypair = await km.unwrapKey(userAfter.inboxKeys, newMasterKey!);
expect((unwrappedKeypair as any).publicKey).toBe(fakeKeyPair.publicKey);
expect((unwrappedKeypair as any).privateKey).toBe(fakeKeyPair.privateKey);
});
});
});
// ─── getDataEncryptionKeys ─────────────────────────────────────────
describe("UserManager.getDataEncryptionKeys", () => {
test("returns LEGACY version when no DEK exists", async () => {
await databaseTest().then(async (db) => {
const salt = randomBytes(16).toString("base64");
await db.user.setUser({ ...FULL_USER, salt });
await db.storage().deriveCryptoKey({ password: "password", salt });
const keys = await db.user.getDataEncryptionKeys();
expect(keys).toBeDefined();
expect(keys!.length).toBe(1);
expect(keys![0].version).toBe(0); // KEY_VERSION.LEGACY
});
});
test("returns DEK version when DEK exists", async () => {
await databaseTest().then(async (db) => {
await setupLoggedInUser(db);
const keys = await db.user.getDataEncryptionKeys();
expect(keys).toBeDefined();
expect(keys!.length).toBeGreaterThanOrEqual(1);
const dekVersion = keys!.find((k: any) => k.version === 1);
expect(dekVersion).toBeDefined();
});
});
test("returns both LEGACY and DEK when both exist", async () => {
await databaseTest().then(async (db) => {
await setupLoggedInUser(db);
const keys = await db.user.getDataEncryptionKeys();
expect(keys).toBeDefined();
expect(keys!.length).toBe(2);
const versions = keys!.map((k: any) => k.version);
expect(versions).toContain(0);
expect(versions).toContain(1);
});
});
test("returns undefined when no master key exists", async () => {
await databaseTest().then(async (db) => {
const keys = await db.user.getDataEncryptionKeys();
expect(keys).toBeUndefined();
});
});
});

View File

@@ -24,7 +24,7 @@ import TokenManager from "./token-manager.js";
import { EV, EVENTS } from "../common.js";
import { HealthCheck } from "./healthcheck.js";
import Database from "./index.js";
import { SerializedKeyPair, SerializedKey } from "@notesnook/crypto";
import { SerializedKeyPair, SerializedKey, Cipher } from "@notesnook/crypto";
import { logger } from "../logger.js";
import { KEY_VERSION, KeyVersion } from "./sync/types.js";
import {
@@ -394,9 +394,13 @@ class UserManager {
);
}
resetPassword(newPassword: string) {
resetPassword(options: {
newPassword: string;
encryptionKey: SerializedKey;
}) {
return this._updatePassword("reset", {
new_password: newPassword
new_password: options.newPassword,
encryptionKey: options.encryptionKey
});
}
@@ -622,6 +626,47 @@ class UserManager {
}
}
private async fetchEncryptionVerifier(): Promise<
Cipher<"base64"> | undefined
> {
const token = await this.tokenManager.getAccessToken();
return http.get(`${constants.API_HOST}/users/verifier`, token);
}
async verifyEncryptionKey(key: SerializedKey) {
const user = await this.getUser();
if (!user) throw new Error("User not found.");
const verifiers = [
user.attachmentsKey,
user.monographPasswordsKey,
user.legacyDataEncryptionKey,
user.dataEncryptionKey,
user.inboxKeys?.private
].filter((v): v is Cipher<"base64"> => !!v);
if (verifiers.length === 0) {
const verifier = await this.fetchEncryptionVerifier();
if (verifier) verifiers.push(verifier);
else
throw new Error(
"Encryption key cannot be verified: no encryption verifier found."
);
}
for (const verifier of verifiers) {
const decryptedData = await this.db
.storage()
.decrypt(key, verifier)
.then(() => true)
.catch(() => false);
if (!decryptedData)
throw new Error(
"Your data cannot be decrypted using the provided encryption key."
);
}
}
async _updatePassword(
type: "change" | "reset",
data: {
@@ -630,15 +675,77 @@ class UserManager {
encryptionKey?: SerializedKey;
}
) {
const { new_password, old_password } = data;
if (!new_password) throw new Error("New password is required.");
const token = await this.tokenManager.getAccessToken();
const user = await this.getUser();
if (!token || !user) throw new Error("You are not logged in.");
const { email, salt } = user;
const { new_password, old_password } = data;
if (old_password && !(await this.verifyPassword(old_password)))
throw new Error("Incorrect old password.");
if (old_password && !data.encryptionKey)
data.encryptionKey = await this.getMasterKey();
if (!data.encryptionKey) throw new Error("Encryption key is required.");
// we must be 100% sure that the provided encryption key is valid before
// proceeding
await this.verifyEncryptionKey(data.encryptionKey);
const updateUserPayload: Partial<User> = {};
const newMasterKey = await this.db
.storage()
.generateCryptoKey(new_password, salt);
if (user.attachmentsKey) {
updateUserPayload.attachmentsKey = await this.keyManager.rewrapKey(
user.attachmentsKey,
data.encryptionKey,
newMasterKey
);
}
if (user.monographPasswordsKey) {
updateUserPayload.monographPasswordsKey = await this.keyManager.rewrapKey(
user.monographPasswordsKey,
data.encryptionKey,
newMasterKey
);
}
if (user.inboxKeys) {
updateUserPayload.inboxKeys = await this.keyManager.rewrapKey(
user.inboxKeys,
data.encryptionKey,
newMasterKey
);
}
if (user.legacyDataEncryptionKey)
updateUserPayload.legacyDataEncryptionKey =
await this.keyManager.rewrapKey(
user.legacyDataEncryptionKey,
data.encryptionKey,
newMasterKey
);
if (user.dataEncryptionKey)
updateUserPayload.dataEncryptionKey = await this.keyManager.rewrapKey(
user.dataEncryptionKey,
data.encryptionKey,
newMasterKey
);
if (!user.legacyDataEncryptionKey && !user.dataEncryptionKey) {
updateUserPayload.dataEncryptionKey = await this.keyManager.wrapKey(
await this.db.crypto().generateRandomKey(),
newMasterKey
);
updateUserPayload.legacyDataEncryptionKey = await this.keyManager.wrapKey(
data.encryptionKey,
newMasterKey
);
}
const oldPassword = old_password
? // we don't lowercase email here to allow user accounts with
@@ -649,61 +756,6 @@ class UserManager {
})
: null;
if (!new_password) throw new Error("New password is required.");
data.encryptionKey = data.encryptionKey || (await this.getMasterKey());
const updateUserPayload: Partial<User> = {};
if (data.encryptionKey) {
const newMasterKey = await this.db
.storage()
.generateCryptoKey(new_password, salt);
if (user.attachmentsKey) {
updateUserPayload.attachmentsKey = await this.keyManager.rewrapKey(
user.attachmentsKey,
data.encryptionKey,
newMasterKey
);
}
if (user.monographPasswordsKey) {
updateUserPayload.monographPasswordsKey =
await this.keyManager.rewrapKey(
user.monographPasswordsKey,
data.encryptionKey,
newMasterKey
);
}
if (user.inboxKeys) {
updateUserPayload.inboxKeys = await this.keyManager.rewrapKey(
user.inboxKeys,
data.encryptionKey,
newMasterKey
);
}
if (user.legacyDataEncryptionKey)
updateUserPayload.legacyDataEncryptionKey =
await this.keyManager.rewrapKey(
user.legacyDataEncryptionKey,
data.encryptionKey,
newMasterKey
);
if (user.dataEncryptionKey)
updateUserPayload.dataEncryptionKey = await this.keyManager.rewrapKey(
user.dataEncryptionKey,
data.encryptionKey,
newMasterKey
);
else {
updateUserPayload.dataEncryptionKey = await this.keyManager.wrapKey(
await this.db.crypto().generateRandomKey(),
newMasterKey
);
updateUserPayload.legacyDataEncryptionKey =
await this.keyManager.wrapKey(data.encryptionKey, newMasterKey);
}
}
await http.patch.json(
`${constants.API_HOST}/users/password/${type}`,
{

View File

@@ -0,0 +1,276 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { test, expect, describe } from "vitest";
import { NNCrypto } from "../src/index.js";
import { randomBytes } from "crypto";
const crypto = new NNCrypto();
function validSalt() {
return randomBytes(16).toString("base64");
}
// ─── Key Derivation ────────────────────────────────────────────────
describe("KeyUtils.deriveKey", () => {
test("same password + salt produces same key", async () => {
const salt = validSalt();
const key1 = await crypto.deriveKey("password123", salt);
const key2 = await crypto.deriveKey("password123", salt);
expect(key1.key).toEqual(key2.key);
expect(key1.salt).toBe(key2.salt);
});
test("different passwords produce different keys", async () => {
const salt = validSalt();
const key1 = await crypto.deriveKey("password1", salt);
const key2 = await crypto.deriveKey("password2", salt);
expect(key1.key).not.toEqual(key2.key);
});
test("different salts produce different keys", async () => {
const salt1 = validSalt();
const salt2 = validSalt();
const key1 = await crypto.deriveKey("password", salt1);
const key2 = await crypto.deriveKey("password", salt2);
expect(key1.key).not.toEqual(key2.key);
});
test("without salt generates a random salt", async () => {
const key1 = await crypto.deriveKey("password");
const key2 = await crypto.deriveKey("password");
expect(key1.salt).not.toBe(key2.salt);
expect(key1.key).not.toEqual(key2.key);
});
test("exportKey returns SerializedKey with base64 key and salt", async () => {
const salt = validSalt();
const exported = await crypto.exportKey("password", salt);
expect(exported.key).toBeDefined();
expect(exported.salt).toBe(salt);
expect(typeof exported.key).toBe("string");
expect(exported.key!.length).toBeGreaterThan(0);
});
test("exportKey without salt generates one", async () => {
const exported = await crypto.exportKey("password");
expect(exported.key).toBeDefined();
expect(exported.salt).toBeDefined();
expect(typeof exported.salt).toBe("string");
});
test("deriveKey produces 32-byte key", async () => {
const salt = validSalt();
const key = await crypto.deriveKey("password", salt);
// libsodium crypto_aead_xchacha20poly1305_ietf_KEYBYTES = 32
expect(key.key.length).toBe(32);
});
});
// ─── Symmetric Encryption Round-Trip ───────────────────────────────
describe("Encryption/Decryption round-trip", () => {
test("text encrypt + decrypt round-trip", async () => {
const key = await crypto.exportKey("testpassword", validSalt());
const plaintext = "Hello, Notesnook!";
const cipher = await crypto.encrypt(key, plaintext, "text", "base64");
expect(cipher.cipher).toBeDefined();
expect(cipher.iv).toBeDefined();
expect(cipher.salt).toBeDefined();
expect(cipher.format).toBe("base64");
expect(cipher.alg).toContain("xcha");
expect(cipher.length).toBe(plaintext.length);
const decrypted = await crypto.decrypt(key, cipher, "text");
expect(decrypted).toBe(plaintext);
});
test("base64 input encrypt + decrypt round-trip", async () => {
const key = await crypto.exportKey("testpassword", validSalt());
const plaintext = "SGVsbG8gV29ybGQ=";
const cipher = await crypto.encrypt(key, plaintext, "base64", "base64");
const decrypted = await crypto.decrypt(key, cipher, "base64");
expect(decrypted).toBe(plaintext);
});
test("empty string encrypt + decrypt round-trip", async () => {
const key = await crypto.exportKey("testpassword", validSalt());
const plaintext = "";
const cipher = await crypto.encrypt(key, plaintext, "text", "base64");
const decrypted = await crypto.decrypt(key, cipher, "text");
expect(decrypted).toBe(plaintext);
});
test("unicode string encrypt + decrypt round-trip", async () => {
const key = await crypto.exportKey("testpassword", validSalt());
const plaintext = "Hello \u{1F30D}! Testing \u{00E9}mojis and \u{65E5}\u{672C}\u{8A9E}";
const cipher = await crypto.encrypt(key, plaintext, "text", "base64");
const decrypted = await crypto.decrypt(key, cipher, "text");
expect(decrypted).toBe(plaintext);
});
test("long text encrypt + decrypt round-trip", async () => {
const key = await crypto.exportKey("testpassword", validSalt());
const plaintext = "A".repeat(100000);
const cipher = await crypto.encrypt(key, plaintext, "text", "base64");
const decrypted = await crypto.decrypt(key, cipher, "text");
expect(decrypted).toBe(plaintext);
});
test("decrypt with wrong key throws", async () => {
const salt = validSalt();
const key1 = await crypto.exportKey("password1", salt);
const key2 = await crypto.exportKey("password2", salt);
const cipher = await crypto.encrypt(key1, "secret", "text", "base64");
await expect(crypto.decrypt(key2, cipher, "text")).rejects.toThrow();
});
test("each encryption produces unique ciphertext (random nonce)", async () => {
const key = await crypto.exportKey("testpassword", validSalt());
const cipher1 = await crypto.encrypt(key, "same data", "text", "base64");
const cipher2 = await crypto.encrypt(key, "same data", "text", "base64");
expect(cipher1.cipher).not.toBe(cipher2.cipher);
expect(cipher1.iv).not.toBe(cipher2.iv);
});
test("cipher with salt in key but no salt on key uses cipher salt", async () => {
const key = await crypto.exportKey("testpassword", validSalt());
const cipher = await crypto.encrypt(key, "data", "text", "base64");
// key without salt - should use cipher's salt
const keyWithoutSalt = { key: key.key };
const decrypted = await crypto.decrypt(keyWithoutSalt, cipher, "text");
expect(decrypted).toBe("data");
});
});
// ─── Multi-Encryption ──────────────────────────────────────────────
describe("encryptMulti / decryptMulti round-trip", () => {
test("multiple items encrypt + decrypt round-trip", async () => {
const key = await crypto.exportKey("testpassword", validSalt());
const items = ["item1", "item2", "item3", "hello world"];
const ciphers = await crypto.encryptMulti(key, items, "text", "base64");
expect(ciphers.length).toBe(items.length);
const decrypted = await crypto.decryptMulti(key, ciphers, "text");
expect(decrypted).toEqual(items);
});
test("empty array encrypt + decrypt round-trip", async () => {
const key = await crypto.exportKey("testpassword", validSalt());
const ciphers = await crypto.encryptMulti(key, [], "text", "base64");
expect(ciphers.length).toBe(0);
const decrypted = await crypto.decryptMulti(key, ciphers, "text");
expect(decrypted.length).toBe(0);
});
});
// ─── Password Hashing ──────────────────────────────────────────────
describe("Password.hash", () => {
test("same password + salt produces same hash", async () => {
const hash1 = await crypto.hash("mypassword", "salt@email.com");
const hash2 = await crypto.hash("mypassword", "salt@email.com");
expect(hash1).toBe(hash2);
});
test("different passwords produce different hashes", async () => {
const hash1 = await crypto.hash("password1", "salt@email.com");
const hash2 = await crypto.hash("password2", "salt@email.com");
expect(hash1).not.toBe(hash2);
});
test("different salts produce different hashes", async () => {
const hash1 = await crypto.hash("password", "email1@example.com");
const hash2 = await crypto.hash("password", "email2@example.com");
expect(hash1).not.toBe(hash2);
});
test("hash is a non-empty string", async () => {
const hash = await crypto.hash("password", "email@example.com");
expect(typeof hash).toBe("string");
expect(hash.length).toBeGreaterThan(0);
});
test("empty password produces a hash", async () => {
const hash = await crypto.hash("", "email@example.com");
expect(typeof hash).toBe("string");
expect(hash.length).toBeGreaterThan(0);
});
});
// ─── Streaming Encryption ──────────────────────────────────────────
describe("Streaming Encryption/Decryption", () => {
test("stream encrypt + decrypt round-trip", async () => {
const key = await crypto.exportKey("testpassword", validSalt());
const { iv, stream: encryptStream } =
await crypto.createEncryptionStream(key);
const encoder = new TextEncoder();
const chunks: Uint8Array[] = [];
const writer = encryptStream.writable.getWriter();
const reader = encryptStream.readable.getReader();
writer.write({ data: encoder.encode("chunk1-"), final: false });
writer.write({ data: encoder.encode("chunk2-"), final: false });
writer.write({ data: encoder.encode("chunk3-final"), final: true });
while (true) {
const { value, done } = await reader.read();
if (done) break;
chunks.push(value);
}
const decryptStream = await crypto.createDecryptionStream(key, iv);
const decryptWriter = decryptStream.writable.getWriter();
const decryptReader = decryptStream.readable.getReader();
for (const chunk of chunks) {
decryptWriter.write(chunk);
}
const decryptedChunks: Uint8Array[] = [];
while (true) {
const { value, done } = await decryptReader.read();
if (done) break;
decryptedChunks.push(value);
}
const decrypted = new Uint8Array(
decryptedChunks.reduce((acc, chunk) => acc + chunk.length, 0)
);
let offset = 0;
for (const chunk of decryptedChunks) {
decrypted.set(chunk, offset);
offset += chunk.length;
}
const decoder = new TextDecoder();
expect(decoder.decode(decrypted)).toBe("chunk1-chunk2-chunk3-final");
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -20,12 +20,15 @@
"scripts": {
"build": "tsdown src/index.ts",
"prepublishOnly": "npm run build",
"watch": "npm run build -- --watch"
"watch": "npm run build -- --watch",
"test": "vitest run"
},
"dependencies": {
"@notesnook/sodium": "file:../sodium"
},
"devDependencies": {},
"devDependencies": {
"vitest": "2.1.8"
},
"repository": {
"type": "git",
"url": "git+https://github.com/streetwriters/notesnook.git",

View File

@@ -0,0 +1,26 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["__tests__/**/*.test.ts"]
}
});

File diff suppressed because one or more lines are too long

View File

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