diff --git a/packages/core/__mocks__/node-storage.mock.ts b/packages/core/__mocks__/node-storage.mock.ts index 0677e125d..f771faa70 100644 --- a/packages/core/__mocks__/node-storage.mock.ts +++ b/packages/core/__mocks__/node-storage.mock.ts @@ -27,7 +27,7 @@ import { IStorage } from "../src/interfaces.js"; import { randomBytes } from "crypto"; export class NodeStorageInterface implements IStorage { - storage = {}; + storage: Record = {}; crypto = new NNCrypto(); async removeMulti(keys: string[]): Promise { @@ -49,7 +49,7 @@ export class NodeStorageInterface implements IStorage { async readMulti(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 { - return this.storage[key]; + return this.storage[key] as T | undefined; } async remove(key: string): Promise { @@ -98,7 +98,11 @@ export class NodeStorageInterface implements IStorage { await this.write(`userEncryptionKey`, keyData.key); } - async hash(password: string, email: string): Promise { + async hash( + password: string, + email: string, + options?: { usesFallback?: boolean } + ): Promise { 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 { - return { password, salt: salt || randomBytes(16).toString("base64") }; + const finalSalt = salt || randomBytes(16).toString("base64"); + return await this.crypto.exportKey(password, finalSalt); } - generateCryptoKeyPair(): Promise { - throw new Error("Method not implemented."); + async generatePGPKeyPair(): Promise { + return await this.crypto.exportKeyPair(); } - generateCryptoKeyFallback( + + async generateCryptoKeyFallback( password: string, salt?: string ): Promise { + return this.generateCryptoKey(password, salt); + } + + async deriveCryptoKeyFallback(_credentials: SerializedKey): Promise {} + + async decryptPGPMessage( + _privateKeyArmored: string, + _encryptedMessage: string + ): Promise { throw new Error("Method not implemented."); } - deriveCryptoKeyFallback(credentials: SerializedKey): Promise { - throw new Error("Method not implemented."); + + async validatePGPKeyPair(_keys: SerializedKeyPair): Promise<{ + isValid: boolean; + message: string; + }> { + return { isValid: true, message: "ok" }; } } diff --git a/packages/core/__tests__/crypto-primitives.test.ts b/packages/core/__tests__/crypto-primitives.test.ts new file mode 100644 index 000000000..bd5f78c3d --- /dev/null +++ b/packages/core/__tests__/crypto-primitives.test.ts @@ -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 . +*/ + +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); + }); + }); +}); diff --git a/packages/core/__tests__/encryption-verification.test.ts b/packages/core/__tests__/encryption-verification.test.ts new file mode 100644 index 000000000..8cbc7a075 --- /dev/null +++ b/packages/core/__tests__/encryption-verification.test.ts @@ -0,0 +1,382 @@ +/* +This file is part of the Notesnook project (https://notesnook.com/) + +Copyright (C) 2023 Streetwriters (Private) Limited + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*/ + +import { test, expect, describe, vi } from "vitest"; +import { databaseTest, loginFakeUser } from "./utils/index.ts"; +import { KeyManager } from "../src/api/key-manager.js"; +import { randomBytes } from "../src/utils/random.js"; + +// ─── verifyPassword ──────────────────────────────────────────────── + +describe("UserManager.verifyPassword", () => { + test("correct password returns true", async () => { + await databaseTest().then(async (db) => { + await loginFakeUser(db); + const result = await db.user.verifyPassword("password"); + expect(result).toBe(true); + }); + }); + + test("incorrect password returns false", async () => { + await databaseTest().then(async (db) => { + await loginFakeUser(db); + const result = await db.user.verifyPassword("wrongpassword"); + expect(result).toBe(false); + }); + }); + + test("no user returns false", async () => { + await databaseTest().then(async (db) => { + const result = await db.user.verifyPassword("anything"); + expect(result).toBe(false); + }); + }); + + test("no master key returns false", async () => { + await databaseTest().then(async (db) => { + const userSalt = randomBytes(16).toString("base64"); + await db.user.setUser({ + id: "user-123", + email: "test@example.com", + isEmailConfirmed: true, + salt: userSalt, + mfa: { isEnabled: false, primaryMethod: "app", remainingValidCodes: 0 }, + subscription: { appId: 0, cancelURL: null, expiry: 0, productId: null, provider: "none", start: 0, plan: "free", status: "trial", updateURL: null, googlePurchaseToken: null } + } as any); + const result = await db.user.verifyPassword("anything"); + expect(result).toBe(false); + }); + }); +}); + +// ─── verifyEncryptionKey ─────────────────────────────────────────── + +describe("UserManager.verifyEncryptionKey", () => { + describe("user with both legacyDataEncryptionKey and dataEncryptionKey", () => { + test("valid key does not throw", async () => { + await databaseTest().then(async (db) => { + const password = "mypassword"; + const salt = randomBytes(16).toString("base64"); + // Set up user with crypto key derived from our password + await db.user.setUser({ + id: "user-123", email: "test@example.com", isEmailConfirmed: true, + salt, mfa: { isEnabled: false, primaryMethod: "app", remainingValidCodes: 0 }, + subscription: { appId: 0, cancelURL: null, expiry: 0, productId: null, provider: "none", start: 0, plan: "free", status: "trial", updateURL: null, googlePurchaseToken: null } + } as any); + await db.storage().deriveCryptoKey({ password, salt }); + const masterKey = await db.user.getMasterKey(); + const km = new KeyManager(db); + + const randomKey = await db.crypto().generateRandomKey(); + const legacyDEK = await km.wrapKey(randomKey, masterKey!); + const dek = await km.wrapKey(randomKey, masterKey!); + + const user = await db.user.getUser(); + await db.user.setUser({ ...user, legacyDataEncryptionKey: legacyDEK, dataEncryptionKey: dek }); + + await expect( + db.user.verifyEncryptionKey({ password, salt }) + ).resolves.toBeUndefined(); + }); + }); + + test("invalid key throws", async () => { + await databaseTest().then(async (db) => { + const password = "correct"; + const salt = randomBytes(16).toString("base64"); + await db.user.setUser({ + id: "user-123", email: "test@example.com", isEmailConfirmed: true, + salt, mfa: { isEnabled: false, primaryMethod: "app", remainingValidCodes: 0 }, + subscription: { appId: 0, cancelURL: null, expiry: 0, productId: null, provider: "none", start: 0, plan: "free", status: "trial", updateURL: null, googlePurchaseToken: null } + } as any); + await db.storage().deriveCryptoKey({ password, salt }); + const masterKey = await db.user.getMasterKey(); + const km = new KeyManager(db); + + const randomKey = await db.crypto().generateRandomKey(); + const legacyDEK = await km.wrapKey(randomKey, masterKey!); + const dek = await km.wrapKey(randomKey, masterKey!); + + const user = await db.user.getUser(); + await db.user.setUser({ ...user, legacyDataEncryptionKey: legacyDEK, dataEncryptionKey: dek }); + + await expect( + db.user.verifyEncryptionKey({ password: "wrongpassword", salt }) + ).rejects.toThrow(); + }); + }); + }); + + describe("user with neither legacyDataEncryptionKey nor dataEncryptionKey", () => { + test("valid key using attachmentsKey verifier does not throw", async () => { + await databaseTest().then(async (db) => { + const password = "mypassword"; + const salt = randomBytes(16).toString("base64"); + await db.user.setUser({ + id: "user-123", email: "test@example.com", isEmailConfirmed: true, + salt, mfa: { isEnabled: false, primaryMethod: "app", remainingValidCodes: 0 }, + subscription: { appId: 0, cancelURL: null, expiry: 0, productId: null, provider: "none", start: 0, plan: "free", status: "trial", updateURL: null, googlePurchaseToken: null } + } as any); + await db.storage().deriveCryptoKey({ password, salt }); + const masterKey = await db.user.getMasterKey(); + const km = new KeyManager(db); + + const key = await db.crypto().generateRandomKey(); + const attachmentsKey = await km.wrapKey(key, masterKey!); + + const user = await db.user.getUser(); + await db.user.setUser({ ...user, attachmentsKey }); + + await expect( + db.user.verifyEncryptionKey({ password, salt }) + ).resolves.toBeUndefined(); + }); + }); + + test("invalid key using attachmentsKey verifier throws", async () => { + await databaseTest().then(async (db) => { + const password = "correct"; + const salt = randomBytes(16).toString("base64"); + await db.user.setUser({ + id: "user-123", email: "test@example.com", isEmailConfirmed: true, + salt, mfa: { isEnabled: false, primaryMethod: "app", remainingValidCodes: 0 }, + subscription: { appId: 0, cancelURL: null, expiry: 0, productId: null, provider: "none", start: 0, plan: "free", status: "trial", updateURL: null, googlePurchaseToken: null } + } as any); + await db.storage().deriveCryptoKey({ password, salt }); + const masterKey = await db.user.getMasterKey(); + const km = new KeyManager(db); + + const key = await db.crypto().generateRandomKey(); + const attachmentsKey = await km.wrapKey(key, masterKey!); + + const user = await db.user.getUser(); + await db.user.setUser({ ...user, attachmentsKey }); + + await expect( + db.user.verifyEncryptionKey({ password: "wrongpassword", salt }) + ).rejects.toThrow( + "Your data cannot be decrypted using the provided encryption key." + ); + }); + }); + + test("uses monographPasswordsKey when no attachmentsKey exists", async () => { + await databaseTest().then(async (db) => { + const password = "mypassword"; + const salt = randomBytes(16).toString("base64"); + await db.user.setUser({ + id: "user-123", email: "test@example.com", isEmailConfirmed: true, + salt, mfa: { isEnabled: false, primaryMethod: "app", remainingValidCodes: 0 }, + subscription: { appId: 0, cancelURL: null, expiry: 0, productId: null, provider: "none", start: 0, plan: "free", status: "trial", updateURL: null, googlePurchaseToken: null } + } as any); + await db.storage().deriveCryptoKey({ password, salt }); + const masterKey = await db.user.getMasterKey(); + const km = new KeyManager(db); + + const key = await db.crypto().generateRandomKey(); + const monographKey = await km.wrapKey(key, masterKey!); + + const user = await db.user.getUser(); + // Ensure no attachmentsKey, only monographPasswordsKey + await db.user.setUser({ ...user, attachmentsKey: undefined, monographPasswordsKey: monographKey }); + + await expect( + db.user.verifyEncryptionKey({ password, salt }) + ).resolves.toBeUndefined(); + }); + }); + + test("fetches verifier from server when no local keys exist", async () => { + const http = (await import("../src/utils/http.js")).default; + const mockGet = vi.spyOn(http, "get").mockResolvedValue(undefined); + + await databaseTest().then(async (db) => { + const password = "mypassword"; + const salt = randomBytes(16).toString("base64"); + await db.user.setUser({ + id: "user-123", email: "test@example.com", isEmailConfirmed: true, + salt, mfa: { isEnabled: false, primaryMethod: "app", remainingValidCodes: 0 }, + subscription: { appId: 0, cancelURL: null, expiry: 0, productId: null, provider: "none", start: 0, plan: "free", status: "trial", updateURL: null, googlePurchaseToken: null } + } as any); + await db.storage().deriveCryptoKey({ password, salt }); + + // Set a token so getAccessToken returns something + await db.kv().write("token", { + access_token: "fake-token", + t: Date.now(), + expires_in: 3600, + scope: "notesnook.sync IdentityServerApi", + refresh_token: "fake-refresh" + }); + + // No attachmentsKey, no monographPasswordsKey + // Server returns undefined — should throw + await expect( + db.user.verifyEncryptionKey({ password, salt }) + ).rejects.toThrow("Failed to fetch encryption verifier."); + }); + + mockGet.mockRestore(); + }); + + test("uses server-provided verifier cipher when available", async () => { + const http = (await import("../src/utils/http.js")).default; + + await databaseTest().then(async (db) => { + const password = "mypassword"; + const salt = randomBytes(16).toString("base64"); + await db.user.setUser({ + id: "user-123", email: "test@example.com", isEmailConfirmed: true, + salt, mfa: { isEnabled: false, primaryMethod: "app", remainingValidCodes: 0 }, + subscription: { appId: 0, cancelURL: null, expiry: 0, productId: null, provider: "none", start: 0, plan: "free", status: "trial", updateURL: null, googlePurchaseToken: null } + } as any); + await db.storage().deriveCryptoKey({ password, salt }); + + await db.kv().write("token", { + access_token: "fake-token", + t: Date.now(), + expires_in: 3600, + scope: "notesnook.sync IdentityServerApi", + refresh_token: "fake-refresh" + }); + + // Create a verifier cipher that the correct key can decrypt + const verifierCipher = await db.storage().encrypt( + { password, salt }, + "test-data" + ); + const mockGet = vi.spyOn(http, "get").mockResolvedValueOnce(verifierCipher); + + await expect( + db.user.verifyEncryptionKey({ password, salt }) + ).resolves.toBeUndefined(); + + expect(mockGet).toHaveBeenCalledWith( + expect.stringContaining("/users/verifier"), + "fake-token" + ); + mockGet.mockRestore(); + }); + }); + + test("server verifier rejects wrong key", async () => { + const http = (await import("../src/utils/http.js")).default; + + await databaseTest().then(async (db) => { + const salt = randomBytes(16).toString("base64"); + await db.user.setUser({ + id: "user-123", email: "test@example.com", isEmailConfirmed: true, + salt, mfa: { isEnabled: false, primaryMethod: "app", remainingValidCodes: 0 }, + subscription: { appId: 0, cancelURL: null, expiry: 0, productId: null, provider: "none", start: 0, plan: "free", status: "trial", updateURL: null, googlePurchaseToken: null } + } as any); + await db.storage().deriveCryptoKey({ password: "correct-password", salt }); + + await db.kv().write("token", { + access_token: "fake-token", + t: Date.now(), + expires_in: 3600, + scope: "notesnook.sync IdentityServerApi", + refresh_token: "fake-refresh" + }); + + // Verifier encrypted with correct password + const verifierCipher = await db.storage().encrypt( + { password: "correct-password", salt }, + "test-data" + ); + const mockGet = vi.spyOn(http, "get").mockResolvedValueOnce(verifierCipher); + + // Wrong key should fail verification + await expect( + db.user.verifyEncryptionKey({ password: "wrong-password", salt }) + ).rejects.toThrow( + "Your data cannot be decrypted using the provided encryption key." + ); + + mockGet.mockRestore(); + }); + }); + }); + + describe("user with only one of legacy DEK or DEK", () => { + test("throws when only dataEncryptionKey exists", async () => { + await databaseTest().then(async (db) => { + const password = "password"; + const salt = randomBytes(16).toString("base64"); + await db.user.setUser({ + id: "user-123", email: "test@example.com", isEmailConfirmed: true, + salt, mfa: { isEnabled: false, primaryMethod: "app", remainingValidCodes: 0 }, + subscription: { appId: 0, cancelURL: null, expiry: 0, productId: null, provider: "none", start: 0, plan: "free", status: "trial", updateURL: null, googlePurchaseToken: null } + } as any); + await db.storage().deriveCryptoKey({ password, salt }); + const masterKey = await db.user.getMasterKey(); + const km = new KeyManager(db); + + const randomKey = await db.crypto().generateRandomKey(); + const dek = await km.wrapKey(randomKey, masterKey!); + + const user = await db.user.getUser(); + await db.user.setUser({ ...user, dataEncryptionKey: dek }); + + await expect( + db.user.verifyEncryptionKey({ password, salt }) + ).rejects.toThrow( + "Cannot verify the provided encryption key as user has only a single encryption key." + ); + }); + }); + + test("throws when only legacyDataEncryptionKey exists", async () => { + await databaseTest().then(async (db) => { + const password = "password"; + const salt = randomBytes(16).toString("base64"); + await db.user.setUser({ + id: "user-123", email: "test@example.com", isEmailConfirmed: true, + salt, mfa: { isEnabled: false, primaryMethod: "app", remainingValidCodes: 0 }, + subscription: { appId: 0, cancelURL: null, expiry: 0, productId: null, provider: "none", start: 0, plan: "free", status: "trial", updateURL: null, googlePurchaseToken: null } + } as any); + await db.storage().deriveCryptoKey({ password, salt }); + const masterKey = await db.user.getMasterKey(); + const km = new KeyManager(db); + + const randomKey = await db.crypto().generateRandomKey(); + const legacyDEK = await km.wrapKey(randomKey, masterKey!); + + const user = await db.user.getUser(); + await db.user.setUser({ ...user, legacyDataEncryptionKey: legacyDEK }); + + await expect( + db.user.verifyEncryptionKey({ password, salt }) + ).rejects.toThrow( + "Cannot verify the provided encryption key as user has only a single encryption key." + ); + }); + }); + }); + + describe("no user", () => { + test("throws when no user exists", async () => { + await databaseTest().then(async (db) => { + await expect( + db.user.verifyEncryptionKey({ password: "test", salt: "salt" }) + ).rejects.toThrow("User not found."); + }); + }); + }); +}); diff --git a/packages/core/__tests__/key-manager.test.ts b/packages/core/__tests__/key-manager.test.ts new file mode 100644 index 000000000..fdb309be3 --- /dev/null +++ b/packages/core/__tests__/key-manager.test.ts @@ -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 . +*/ + +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); + }); + }); + }); +}); diff --git a/packages/core/__tests__/password-change.test.ts b/packages/core/__tests__/password-change.test.ts new file mode 100644 index 000000000..661b18b98 --- /dev/null +++ b/packages/core/__tests__/password-change.test.ts @@ -0,0 +1,387 @@ +/* +This file is part of the Notesnook project (https://notesnook.com/) + +Copyright (C) 2023 Streetwriters (Private) Limited + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*/ + +import { test, expect, describe, vi } from "vitest"; +import { databaseTest } from "./utils/index.ts"; +import { KeyManager } from "../src/api/key-manager.js"; +import { randomBytes } from "../src/utils/random.js"; + +const FULL_USER: any = { + id: "user-123", + email: "test@example.com", + isEmailConfirmed: true, + salt: "", + mfa: { isEnabled: false, primaryMethod: "app", remainingValidCodes: 0 }, + subscription: { appId: 0, cancelURL: null, expiry: 0, productId: null, provider: "none", start: 0, plan: "free", status: "trial", updateURL: null, googlePurchaseToken: null } +}; + +vi.mock("../src/utils/http.js", () => ({ + default: { + get: vi.fn().mockResolvedValue(undefined), + post: vi.fn().mockResolvedValue(undefined), + patch: { json: vi.fn().mockResolvedValue(undefined) }, + delete: vi.fn().mockResolvedValue(undefined) + } +})); + +async function setupLoggedInUser(db: any, password: string = "oldpassword") { + const salt = randomBytes(16).toString("base64"); + const user = { ...FULL_USER, salt }; + await db.user.setUser(user); + await db.storage().deriveCryptoKey({ password, salt }); + + await db.kv().write("token", { + access_token: "fake-token", + t: Date.now(), + expires_in: 3600, + scope: "notesnook.sync offline_access IdentityServerApi", + refresh_token: "fake-refresh" + }); + + const km = new KeyManager(db); + const masterKey = await db.user.getMasterKey(); + const randomKey = await db.crypto().generateRandomKey(); + + const attachmentsKey = await km.wrapKey(randomKey, masterKey!); + const monographPasswordsKey = await km.wrapKey(randomKey, masterKey!); + const dek = await km.wrapKey(randomKey, masterKey!); + const legacyDEK = await km.wrapKey(randomKey, masterKey!); + + await db.user.setUser({ + ...user, + attachmentsKey, + monographPasswordsKey, + dataEncryptionKey: dek, + legacyDataEncryptionKey: legacyDEK + }); + + return { password, salt, masterKey, randomKey }; +} + +// ─── _updatePassword (change) ────────────────────────────────────── + +describe("UserManager._updatePassword (change)", () => { + test("successful password change rewraps all keys and derives new master key", async () => { + await databaseTest().then(async (db) => { + await setupLoggedInUser(db, "oldpassword"); + const result = await db.user.changePassword("oldpassword", "newpassword"); + expect(result).toBe(true); + const newMasterKey = await db.user.getMasterKey(); + expect(newMasterKey).toBeDefined(); + expect(newMasterKey!.key).not.toBe(""); + }); + }); + + test("wrong old password throws 'Incorrect old password'", async () => { + await databaseTest().then(async (db) => { + await setupLoggedInUser(db, "oldpassword"); + await expect( + db.user.changePassword("wrongpassword", "newpassword") + ).rejects.toThrow("Incorrect old password"); + }); + }); + + test("empty new password throws 'New password is required'", async () => { + await databaseTest().then(async (db) => { + await setupLoggedInUser(db, "oldpassword"); + await expect( + db.user.changePassword("oldpassword", "") + ).rejects.toThrow("New password is required"); + }); + }); + + test("no logged in user throws", async () => { + await databaseTest().then(async (db) => { + await expect( + db.user.changePassword("oldpassword", "newpassword") + ).rejects.toThrow(); + }); + }); + + test("keys are rewrapped: old master key can no longer unwrap", async () => { + await databaseTest().then(async (db) => { + const { masterKey: oldMasterKey } = await setupLoggedInUser(db, "oldpassword"); + await db.user.changePassword("oldpassword", "newpassword"); + + const user = await db.user.getUser(); + const km = new KeyManager(db); + + if (user.attachmentsKey) { + await expect( + km.unwrapKey(user.attachmentsKey, oldMasterKey!) + ).rejects.toThrow(); + } + }); + }); + + test("new master key can unwrap all rewrapped keys", async () => { + await databaseTest().then(async (db) => { + await setupLoggedInUser(db, "oldpassword"); + await db.user.changePassword("oldpassword", "newpassword"); + + const newMasterKey = await db.user.getMasterKey(); + const user = await db.user.getUser(); + const km = new KeyManager(db); + + if (user.attachmentsKey) { + const key = await km.unwrapKey(user.attachmentsKey, newMasterKey!); + expect(key).toBeDefined(); + } + if (user.monographPasswordsKey) { + const key = await km.unwrapKey(user.monographPasswordsKey, newMasterKey!); + expect(key).toBeDefined(); + } + if (user.dataEncryptionKey) { + const key = await km.unwrapKey(user.dataEncryptionKey, newMasterKey!); + expect(key).toBeDefined(); + } + if (user.legacyDataEncryptionKey) { + const key = await km.unwrapKey(user.legacyDataEncryptionKey, newMasterKey!); + expect(key).toBeDefined(); + } + }); + }); + + test("rewrapped user keys are stored with new ciphertext", async () => { + await databaseTest().then(async (db) => { + await setupLoggedInUser(db, "oldpassword"); + const km = new KeyManager(db); + + // Fetch the attachmentsKey BEFORE password change + const oldKey = await km.get("attachmentsKey", { + useCache: false, + refetchUser: false + }); + expect(oldKey).toBeDefined(); + const oldCipher = (oldKey as any).cipher; + + // Change password — keys are rewrapped and user is updated + await db.user.changePassword("oldpassword", "newpassword"); + + // The new user object in KV should have a rewrapped attachmentsKey + // (with new ciphertext) + const newKey = await km.get("attachmentsKey", { + useCache: false, + refetchUser: false + }); + expect(newKey).toBeDefined(); + // The re-fetched cipher MUST differ from the old one because the key + // was re-wrapped with a new master key (different ciphertext). + expect((newKey as any).cipher).not.toBe(oldCipher); + + // Verify the new key can be unwrapped with the new master key + const newMasterKey = await db.user.getMasterKey(); + const unwrapped = await km.unwrapKey(newKey, newMasterKey!); + expect(unwrapped).toBeDefined(); + }); + }); +}); + +// ─── _updatePassword (reset) ─────────────────────────────────────── + +describe("UserManager._updatePassword (reset)", () => { + test("successful reset with explicit encryption key", async () => { + await databaseTest().then(async (db) => { + const { password, salt } = await setupLoggedInUser(db, "oldpassword"); + const result = await db.user.resetPassword({ + newPassword: "newpassword", + encryptionKey: { password, salt } + }); + expect(result).toBe(true); + const newMasterKey = await db.user.getMasterKey(); + expect(newMasterKey).toBeDefined(); + }); + }); + + test("reset without encryption key throws", async () => { + await databaseTest().then(async (db) => { + await setupLoggedInUser(db, "oldpassword"); + await expect( + db.user.resetPassword({ newPassword: "newpassword" } as any) + ).rejects.toThrow("Encryption key is required."); + }); + }); + + test("empty new password throws 'New password is required'", async () => { + await databaseTest().then(async (db) => { + const { password, salt } = await setupLoggedInUser(db, "oldpassword"); + await expect( + db.user.resetPassword({ newPassword: "", encryptionKey: { password, salt } }) + ).rejects.toThrow("New password is required"); + }); + }); + + test("reset with wrong encryption key throws during verification", async () => { + await databaseTest().then(async (db) => { + await setupLoggedInUser(db, "oldpassword"); + await expect( + db.user.resetPassword({ + newPassword: "newpassword", + encryptionKey: { password: "wrongpassword", salt: "wrongsalt" } + }) + ).rejects.toThrow(); + }); + }); +}); + +// ─── Key migration scenarios ─────────────────────────────────────── + +describe("Key migration during password change", () => { + test("legacy user (no DEK, no legacy DEK) gets new DEK and legacy DEK created", async () => { + await databaseTest().then(async (db) => { + const password = "oldpassword"; + const salt = randomBytes(16).toString("base64"); + await db.user.setUser({ ...FULL_USER, salt }); + await db.storage().deriveCryptoKey({ password, salt }); + + await db.kv().write("token", { + access_token: "fake-token", t: Date.now(), expires_in: 3600, + scope: "notesnook.sync offline_access IdentityServerApi", refresh_token: "fake-refresh" + }); + + const km = new KeyManager(db); + const masterKey = await db.user.getMasterKey(); + const randomKey = await db.crypto().generateRandomKey(); + const attachmentsKey = await km.wrapKey(randomKey, masterKey!); + + await db.user.setUser({ ...FULL_USER, salt, attachmentsKey }); + + const result = await db.user.changePassword("oldpassword", "newpassword"); + expect(result).toBe(true); + + const user = await db.user.getUser(); + expect(user.dataEncryptionKey).toBeDefined(); + expect(user.legacyDataEncryptionKey).toBeDefined(); + }); + }); + + test("user with both DEK and legacy DEK: both are rewrapped", async () => { + await databaseTest().then(async (db) => { + await setupLoggedInUser(db, "oldpassword"); + const userBefore = await db.user.getUser(); + const originalDEKCipher = userBefore.dataEncryptionKey?.cipher; + const originalLegacyDEKCipher = userBefore.legacyDataEncryptionKey?.cipher; + + await db.user.changePassword("oldpassword", "newpassword"); + + const userAfter = await db.user.getUser(); + expect(userAfter.dataEncryptionKey?.cipher).not.toBe(originalDEKCipher); + expect(userAfter.legacyDataEncryptionKey?.cipher).not.toBe(originalLegacyDEKCipher); + }); + }); + + test("user with only DEK (no legacy): password change fails verification", async () => { + await databaseTest().then(async (db) => { + const password = "oldpassword"; + const salt = randomBytes(16).toString("base64"); + await db.user.setUser({ ...FULL_USER, salt }); + await db.storage().deriveCryptoKey({ password, salt }); + + await db.kv().write("token", { + access_token: "fake-token", t: Date.now(), expires_in: 3600, + scope: "notesnook.sync offline_access IdentityServerApi", refresh_token: "fake-refresh" + }); + + const km = new KeyManager(db); + const masterKey = await db.user.getMasterKey(); + const randomKey = await db.crypto().generateRandomKey(); + const dek = await km.wrapKey(randomKey, masterKey!); + + await db.user.setUser({ ...FULL_USER, salt, dataEncryptionKey: dek }); + + // verifyEncryptionKey throws when only one of legacy DEK or DEK exists + await expect( + db.user.changePassword("oldpassword", "newpassword") + ).rejects.toThrow( + "Cannot verify the provided encryption key as user has only a single encryption key." + ); + }); + }); + + test("inbox keys are rewrapped during password change", async () => { + await databaseTest().then(async (db) => { + await setupLoggedInUser(db, "oldpassword"); + + const km = new KeyManager(db); + const masterKey = await db.user.getMasterKey(); + // Use a fake keypair since generatePGPKeyPair depends on unavailable sodium API + const fakeKeyPair = { publicKey: "fake-public-key", privateKey: "fake-private-key" }; + const wrappedInboxKeys = await km.wrapKey(fakeKeyPair as any, masterKey!); + + const user = await db.user.getUser(); + await db.user.setUser({ ...user, inboxKeys: wrappedInboxKeys }); + + await db.user.changePassword("oldpassword", "newpassword"); + + const userAfter = await db.user.getUser(); + const newMasterKey = await db.user.getMasterKey(); + expect(userAfter.inboxKeys).toBeDefined(); + const unwrappedKeypair = await km.unwrapKey(userAfter.inboxKeys, newMasterKey!); + expect((unwrappedKeypair as any).publicKey).toBe(fakeKeyPair.publicKey); + expect((unwrappedKeypair as any).privateKey).toBe(fakeKeyPair.privateKey); + }); + }); +}); + +// ─── getDataEncryptionKeys ───────────────────────────────────────── + +describe("UserManager.getDataEncryptionKeys", () => { + test("returns LEGACY version when no DEK exists", async () => { + await databaseTest().then(async (db) => { + const salt = randomBytes(16).toString("base64"); + await db.user.setUser({ ...FULL_USER, salt }); + await db.storage().deriveCryptoKey({ password: "password", salt }); + + const keys = await db.user.getDataEncryptionKeys(); + expect(keys).toBeDefined(); + expect(keys!.length).toBe(1); + expect(keys![0].version).toBe(0); // KEY_VERSION.LEGACY + }); + }); + + test("returns DEK version when DEK exists", async () => { + await databaseTest().then(async (db) => { + await setupLoggedInUser(db); + const keys = await db.user.getDataEncryptionKeys(); + expect(keys).toBeDefined(); + expect(keys!.length).toBeGreaterThanOrEqual(1); + const dekVersion = keys!.find((k: any) => k.version === 1); + expect(dekVersion).toBeDefined(); + }); + }); + + test("returns both LEGACY and DEK when both exist", async () => { + await databaseTest().then(async (db) => { + await setupLoggedInUser(db); + const keys = await db.user.getDataEncryptionKeys(); + expect(keys).toBeDefined(); + expect(keys!.length).toBe(2); + const versions = keys!.map((k: any) => k.version); + expect(versions).toContain(0); + expect(versions).toContain(1); + }); + }); + + test("returns undefined when no master key exists", async () => { + await databaseTest().then(async (db) => { + const keys = await db.user.getDataEncryptionKeys(); + expect(keys).toBeUndefined(); + }); + }); +}); diff --git a/packages/crypto/__tests__/crypto.test.ts b/packages/crypto/__tests__/crypto.test.ts new file mode 100644 index 000000000..e6d407e3a --- /dev/null +++ b/packages/crypto/__tests__/crypto.test.ts @@ -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 . +*/ + +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"); + }); +}); diff --git a/packages/crypto/package-lock.json b/packages/crypto/package-lock.json index 3c74db4d9..930425367 100644 --- a/packages/crypto/package-lock.json +++ b/packages/crypto/package-lock.json @@ -11,7 +11,9 @@ "dependencies": { "@notesnook/sodium": "file:../sodium" }, - "devDependencies": {} + "devDependencies": { + "vitest": "2.1.8" + } }, "../sodium": { "name": "@notesnook/sodium", @@ -32,9 +34,1459 @@ "sodium-native": ">=4" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, "node_modules/@notesnook/sodium": { "resolved": "../sodium", "link": true + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz", + "integrity": "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.4.tgz", + "integrity": "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.4.tgz", + "integrity": "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.4.tgz", + "integrity": "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.4.tgz", + "integrity": "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.4.tgz", + "integrity": "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.4.tgz", + "integrity": "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.4.tgz", + "integrity": "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.4.tgz", + "integrity": "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.4.tgz", + "integrity": "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.4.tgz", + "integrity": "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.4.tgz", + "integrity": "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.4.tgz", + "integrity": "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.4.tgz", + "integrity": "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.4.tgz", + "integrity": "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.4.tgz", + "integrity": "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.4.tgz", + "integrity": "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.4.tgz", + "integrity": "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.4.tgz", + "integrity": "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.4.tgz", + "integrity": "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.4.tgz", + "integrity": "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.4.tgz", + "integrity": "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.4.tgz", + "integrity": "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.4.tgz", + "integrity": "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.4.tgz", + "integrity": "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/expect": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.8.tgz", + "integrity": "sha512-8ytZ/fFHq2g4PJVAtDX57mayemKgDR6X3Oa2Foro+EygiOJHUXhCqBAAKQYYajZpFoIfvBCF1j6R6IYRSIUFuw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.8", + "@vitest/utils": "2.1.8", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.8.tgz", + "integrity": "sha512-7guJ/47I6uqfttp33mgo6ga5Gr1VnL58rcqYKyShoRK9ebu8T5Rs6HN3s1NABiBeVTdWNrwUMcHH54uXZBN4zA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.8", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.8.tgz", + "integrity": "sha512-17ub8vQstRnRlIU5k50bG+QOMLHRhYPAna5tw8tYbj+jzjcspnwnwtPtiOlkuKC4+ixDPTuLZiqiWWQ2PSXHVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.8", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.8.tgz", + "integrity": "sha512-20T7xRFbmnkfcmgVEz+z3AU/3b0cEzZOt/zmnvZEctg64/QZbSDJEVm9fLnnlSi74KibmRsO9/Qabi+t0vCRPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.8", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot/node_modules/@vitest/pretty-format": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.8.tgz", + "integrity": "sha512-9HiSZ9zpqNLKlbIDRWOnAWqgcA7xu+8YxXSekhr0Ykab7PAYFkhkwoqVArPOtJhPmYeE2YHgKZlj3CP36z2AJQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.8.tgz", + "integrity": "sha512-5swjf2q95gXeYPevtW0BLk6H8+bPlMb4Vw/9Em4hFxDcaOxS+e0LOX4yqNxoHzMR2akEB2xfpnWUzkZokmgWDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.8.tgz", + "integrity": "sha512-dwSoui6djdwbfFmIgbIjX2ZhIoG7Ex/+xpxyiEgIGzjliY8xGkcpITKTlp6B4MgtGkF2ilvm97cPM96XZaAgcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.8", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils/node_modules/@vitest/pretty-format": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.8.tgz", + "integrity": "sha512-9HiSZ9zpqNLKlbIDRWOnAWqgcA7xu+8YxXSekhr0Ykab7PAYFkhkwoqVArPOtJhPmYeE2YHgKZlj3CP36z2AJQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz", + "integrity": "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.62.4", + "@rollup/rollup-android-arm64": "4.62.4", + "@rollup/rollup-darwin-arm64": "4.62.4", + "@rollup/rollup-darwin-x64": "4.62.4", + "@rollup/rollup-freebsd-arm64": "4.62.4", + "@rollup/rollup-freebsd-x64": "4.62.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.4", + "@rollup/rollup-linux-arm-musleabihf": "4.62.4", + "@rollup/rollup-linux-arm64-gnu": "4.62.4", + "@rollup/rollup-linux-arm64-musl": "4.62.4", + "@rollup/rollup-linux-loong64-gnu": "4.62.4", + "@rollup/rollup-linux-loong64-musl": "4.62.4", + "@rollup/rollup-linux-ppc64-gnu": "4.62.4", + "@rollup/rollup-linux-ppc64-musl": "4.62.4", + "@rollup/rollup-linux-riscv64-gnu": "4.62.4", + "@rollup/rollup-linux-riscv64-musl": "4.62.4", + "@rollup/rollup-linux-s390x-gnu": "4.62.4", + "@rollup/rollup-linux-x64-gnu": "4.62.4", + "@rollup/rollup-linux-x64-musl": "4.62.4", + "@rollup/rollup-openbsd-x64": "4.62.4", + "@rollup/rollup-openharmony-arm64": "4.62.4", + "@rollup/rollup-win32-arm64-msvc": "4.62.4", + "@rollup/rollup-win32-ia32-msvc": "4.62.4", + "@rollup/rollup-win32-x64-gnu": "4.62.4", + "@rollup/rollup-win32-x64-msvc": "4.62.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.8.tgz", + "integrity": "sha512-uPAwSr57kYjAUux+8E2j0q0Fxpn8M9VoyfGiRI8Kfktz9NcYMCenwY5RnZxnF1WTu3TGiYipirIzacLL3VVGFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.8.tgz", + "integrity": "sha512-1vBKTZskHw/aosXqQUlVWWlGUxSJR8YtiyZDJAFeW2kPAeX6S3Sool0mjspO+kXLuxVWlEDDowBAeqeAQefqLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.8", + "@vitest/mocker": "2.1.8", + "@vitest/pretty-format": "^2.1.8", + "@vitest/runner": "2.1.8", + "@vitest/snapshot": "2.1.8", + "@vitest/spy": "2.1.8", + "@vitest/utils": "2.1.8", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.8", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.8", + "@vitest/ui": "2.1.8", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } } } } diff --git a/packages/crypto/package.json b/packages/crypto/package.json index 95331e608..c86aafaa9 100644 --- a/packages/crypto/package.json +++ b/packages/crypto/package.json @@ -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", diff --git a/packages/crypto/vitest.config.ts b/packages/crypto/vitest.config.ts new file mode 100644 index 000000000..6de291b67 --- /dev/null +++ b/packages/crypto/vitest.config.ts @@ -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 . +*/ + +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["__tests__/**/*.test.ts"] + } +});