diff --git a/apps/web/package-lock.json b/apps/web/package-lock.json
index 998c54ca2..1d4ec7cca 100644
--- a/apps/web/package-lock.json
+++ b/apps/web/package-lock.json
@@ -115,6 +115,7 @@
"happy-dom": "16.0.1",
"ip": "^2.0.1",
"lorem-ipsum": "^2.0.4",
+ "openpgp": "^6.2.2",
"otplib": "^12.0.1",
"rollup-plugin-visualizer": "^5.13.1",
"vite": "5.4.11",
@@ -507,7 +508,7 @@
},
"../desktop": {
"name": "@notesnook/desktop",
- "version": "3.3.8-beta.0",
+ "version": "3.3.8-beta.1",
"hasInstallScript": true,
"license": "GPL-3.0-or-later",
"dependencies": {
@@ -9655,6 +9656,16 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/openpgp": {
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/openpgp/-/openpgp-6.2.2.tgz",
+ "integrity": "sha512-P/dyEqQ3gfwOCo+xsqffzXjmUhGn4AZTOJ1LCcN21S23vAk+EAvMJOQTsb/C8krL6GjOSBxqGYckhik7+hneNw==",
+ "dev": true,
+ "license": "LGPL-3.0+",
+ "engines": {
+ "node": ">= 18.0.0"
+ }
+ },
"node_modules/otplib": {
"version": "12.0.1",
"resolved": "https://registry.npmjs.org/otplib/-/otplib-12.0.1.tgz",
diff --git a/apps/web/package.json b/apps/web/package.json
index 02961c421..ecc0e3c40 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -113,6 +113,7 @@
"happy-dom": "16.0.1",
"ip": "^2.0.1",
"lorem-ipsum": "^2.0.4",
+ "openpgp": "^6.2.2",
"otplib": "^12.0.1",
"rollup-plugin-visualizer": "^5.13.1",
"vite": "5.4.11",
diff --git a/apps/web/src/dialogs/inbox-pgp-keys-dialog.tsx b/apps/web/src/dialogs/inbox-pgp-keys-dialog.tsx
new file mode 100644
index 000000000..b05a413ac
--- /dev/null
+++ b/apps/web/src/dialogs/inbox-pgp-keys-dialog.tsx
@@ -0,0 +1,210 @@
+/*
+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 { useState } from "react";
+import { Button, Flex, Text } from "@theme-ui/components";
+import Dialog from "../components/dialog";
+import { BaseDialogProps, DialogManager } from "../common/dialog-manager";
+import { db } from "../common/db";
+import Field from "../components/field";
+import { showToast } from "../utils/toast";
+import { SerializedKeyPair } from "@notesnook/crypto";
+import { ConfirmDialog } from "./confirm";
+
+type InboxPGPKeysDialogProps = BaseDialogProps & {
+ keys?: SerializedKeyPair | null;
+};
+
+export const InboxPGPKeysDialog = DialogManager.register(
+ function InboxPGPKeysDialog(props: InboxPGPKeysDialogProps) {
+ const { keys: initialKeys, onClose } = props;
+ const [mode, setMode] = useState<"choose" | "edit">(
+ initialKeys ? "edit" : "choose"
+ );
+ const [publicKey, setPublicKey] = useState(initialKeys?.publicKey || "");
+ const [privateKey, setPrivateKey] = useState(initialKeys?.privateKey || "");
+ const [isLoading, setIsLoading] = useState(false);
+
+ const hasChanges =
+ publicKey !== (initialKeys?.publicKey || "") ||
+ privateKey !== (initialKeys?.privateKey || "");
+
+ async function handleAutoGenerate() {
+ try {
+ setIsLoading(true);
+ await db.user.getInboxKeys();
+ showToast("success", "Inbox keys generated");
+ onClose(true);
+ } catch (error) {
+ showToast("error", "Failed to generate inbox keys");
+ console.error(error);
+ } finally {
+ setIsLoading(false);
+ }
+ }
+
+ async function handleSave() {
+ const trimmedPublicKey = publicKey.trim();
+ const trimmedPrivateKey = privateKey.trim();
+ if (!trimmedPublicKey || !trimmedPrivateKey) {
+ showToast("error", "Both public and private keys are required");
+ return;
+ }
+
+ try {
+ setIsLoading(true);
+ const isValid = await db.storage().validatePGPKeyPair({
+ publicKey: trimmedPublicKey,
+ privateKey: trimmedPrivateKey
+ });
+ if (!isValid) {
+ showToast(
+ "error",
+ "Invalid PGP key pair. Please check your keys and try again."
+ );
+ return;
+ }
+
+ if (initialKeys) {
+ const ok = await ConfirmDialog.show({
+ title: "Change Inbox PGP Keys",
+ message:
+ "Changing Inbox PGP keys will delete all your unsynced inbox items. Are you sure?",
+ positiveButtonText: "Yes",
+ negativeButtonText: "No"
+ });
+ if (!ok) return;
+ }
+
+ await db.user.saveInboxKeys({
+ publicKey: trimmedPublicKey,
+ privateKey: trimmedPrivateKey
+ });
+ showToast("success", "Inbox keys saved");
+ onClose(true);
+ } catch (error) {
+ showToast("error", "Failed to save inbox keys");
+ console.error(error);
+ } finally {
+ setIsLoading(false);
+ }
+ }
+
+ if (mode === "choose") {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+ }
+);
diff --git a/apps/web/src/dialogs/settings/inbox-settings.ts b/apps/web/src/dialogs/settings/inbox-settings.ts
index 2743972a7..bdec99565 100644
--- a/apps/web/src/dialogs/settings/inbox-settings.ts
+++ b/apps/web/src/dialogs/settings/inbox-settings.ts
@@ -20,6 +20,10 @@ along with this program. If not, see .
import { SettingsGroup } from "./types";
import { useStore as useSettingStore } from "../../stores/setting-store";
import { InboxApiKeys } from "./components/inbox-api-keys";
+import { InboxPGPKeysDialog } from "../inbox-pgp-keys-dialog";
+import { db } from "../../common/db";
+import { showPasswordDialog } from "../password-dialog";
+import { strings } from "@notesnook/intl";
export const InboxSettings: SettingsGroup[] = [
{
@@ -42,6 +46,41 @@ export const InboxSettings: SettingsGroup[] = [
}
]
},
+ {
+ key: "show-inbox-pgp-keys",
+ title: "Inbox PGP Keys",
+ description: "View/edit your Inbox PGP keys",
+ keywords: ["inbox", "pgp", "keys"],
+ onStateChange: (listener) =>
+ useSettingStore.subscribe((s) => s.isInboxEnabled, listener),
+ isHidden: () => !useSettingStore.getState().isInboxEnabled,
+ components: [
+ {
+ type: "button",
+ title: "Show",
+ variant: "secondary",
+ action: async () => {
+ const ok = await showPasswordDialog({
+ title: "Authenticate to view/edit Inbox PGP keys",
+ inputs: {
+ password: {
+ label: strings.accountPassword(),
+ autoComplete: "current-password"
+ }
+ },
+ validate: ({ password }) => {
+ return db.user.verifyPassword(password);
+ }
+ });
+ if (!ok) return;
+
+ InboxPGPKeysDialog.show({
+ keys: await db.user.getInboxKeys()
+ });
+ }
+ }
+ ]
+ },
{
key: "inbox-api-keys",
title: "",
diff --git a/apps/web/src/interfaces/storage.ts b/apps/web/src/interfaces/storage.ts
index 9fb294c6a..a1822e187 100644
--- a/apps/web/src/interfaces/storage.ts
+++ b/apps/web/src/interfaces/storage.ts
@@ -26,7 +26,6 @@ import {
} from "./key-value";
import { NNCrypto } from "./nncrypto";
import type {
- AsymmetricCipher,
Cipher,
SerializedKey,
SerializedKeyPair
@@ -34,6 +33,7 @@ import type {
import { isFeatureSupported } from "../utils/feature-check";
import { IKeyStore } from "./key-store";
import { User } from "@notesnook/core";
+import * as openpgp from "openpgp";
type EncryptedKey = { iv: Uint8Array; cipher: BufferSource };
export type DatabasePersistence = "memory" | "db";
@@ -133,8 +133,44 @@ export class NNStorage implements IStorage {
return await NNCrypto.exportKey(password, salt);
}
- async generateCryptoKeyPair() {
- return await NNCrypto.exportKeyPair();
+ async generatePGPKeyPair(): Promise {
+ const keys = await openpgp.generateKey({
+ userIDs: [{ name: "NN", email: "NN@NN.NN" }]
+ });
+ return { publicKey: keys.publicKey, privateKey: keys.privateKey };
+ }
+
+ async validatePGPKeyPair(keys: SerializedKeyPair): Promise {
+ try {
+ const dummyData = JSON.stringify({
+ favorite: true,
+ title: "Hello world"
+ });
+
+ const publicKey = await openpgp.readKey({ armoredKey: keys.publicKey });
+ const encrypted = await openpgp.encrypt({
+ message: await openpgp.createMessage({
+ text: dummyData
+ }),
+ encryptionKeys: publicKey
+ });
+
+ const message = await openpgp.readMessage({
+ armoredMessage: encrypted
+ });
+ const privateKey = await openpgp.readPrivateKey({
+ armoredKey: keys.privateKey
+ });
+ const decrypted = await openpgp.decrypt({
+ message,
+ decryptionKeys: privateKey
+ });
+
+ return decrypted.data === dummyData;
+ } catch (e) {
+ console.error("PGP key pair validation error:", e);
+ return false;
+ }
}
async hash(password: string, email: string): Promise {
@@ -165,12 +201,21 @@ export class NNStorage implements IStorage {
return NNCrypto.decryptMulti(key, items, "text");
}
- decryptAsymmetric(
- keyPair: SerializedKeyPair,
- cipherData: AsymmetricCipher<"base64">
+ async decryptPGPMessage(
+ privateKeyArmored: string,
+ encryptedMessage: string
): Promise {
- cipherData.format = "base64";
- return NNCrypto.decryptAsymmetric(keyPair, cipherData, "base64");
+ const message = await openpgp.readMessage({
+ armoredMessage: encryptedMessage
+ });
+ const privateKey = await openpgp.readPrivateKey({
+ armoredKey: privateKeyArmored
+ });
+ const decrypted = await openpgp.decrypt({
+ message,
+ decryptionKeys: privateKey
+ });
+ return decrypted.data;
}
/**
diff --git a/apps/web/src/stores/setting-store.ts b/apps/web/src/stores/setting-store.ts
index f08cc4efb..7ef3b6175 100644
--- a/apps/web/src/stores/setting-store.ts
+++ b/apps/web/src/stores/setting-store.ts
@@ -27,6 +27,8 @@ import { TimeFormat, DayFormat, WeekFormat } from "@notesnook/core";
import { Profile, TrashCleanupInterval } from "@notesnook/core";
import { showToast } from "../utils/toast";
import { ConfirmDialog } from "../dialogs/confirm";
+import * as openpgp from "openpgp";
+import { InboxPGPKeysDialog } from "../dialogs/inbox-pgp-keys-dialog";
export const HostIds = [
"API_HOST",
@@ -294,17 +296,14 @@ class SettingStore extends BaseStore {
try {
if (isInboxEnabled) {
- const inboxTokens = await db.inboxApiKeys.get();
- if (inboxTokens && inboxTokens.length > 0) {
- const ok = await ConfirmDialog.show({
- title: "Disable Inbox API",
- message:
- "Disabling will revoke all existing API keys, they will no longer work. Are you sure?",
- positiveButtonText: "Yes",
- negativeButtonText: "No"
- });
- if (!ok) return;
- }
+ const ok = await ConfirmDialog.show({
+ title: "Disable Inbox API",
+ message:
+ "Disabling will delete all your unsynced inbox items. Additionally, disabling will revoke all existing API keys, they will no longer work. Are you sure?",
+ positiveButtonText: "Yes",
+ negativeButtonText: "No"
+ });
+ if (!ok) return;
await db.user.discardInboxKeys();
this.set({ isInboxEnabled: false });
@@ -312,8 +311,10 @@ class SettingStore extends BaseStore {
return;
}
- await db.user.getInboxKeys();
- this.set({ isInboxEnabled: true });
+ const ok = await InboxPGPKeysDialog.show({ keys: null });
+ if (ok) {
+ this.set({ isInboxEnabled: true });
+ }
} catch (e) {
if (e instanceof Error) {
showToast("error", e.message);
diff --git a/packages/core/src/api/sync/merger.ts b/packages/core/src/api/sync/merger.ts
index 59a5b6e8f..a35258d18 100644
--- a/packages/core/src/api/sync/merger.ts
+++ b/packages/core/src/api/sync/merger.ts
@@ -187,24 +187,11 @@ export async function handleInboxItems(
continue;
}
- const decryptedKey = await db.storage().decryptAsymmetric(inboxKeys, {
- alg: item.key.alg,
- cipher: item.key.cipher,
- format: "base64",
- length: item.key.length
- });
- const decryptedItem = await db.storage().decrypt(
- { key: decryptedKey },
- {
- alg: item.alg,
- iv: item.iv,
- cipher: item.cipher,
- format: "base64",
- length: item.length,
- salt: item.salt
- }
- );
+ const decryptedItem = await db
+ .storage()
+ .decryptPGPMessage(inboxKeys.privateKey, item.cipher);
const parsed = JSON.parse(decryptedItem) as ParsedInboxItem;
+
if (parsed.type !== "note") {
continue;
}
diff --git a/packages/core/src/api/sync/types.ts b/packages/core/src/api/sync/types.ts
index 994fc8451..46fe13f8a 100644
--- a/packages/core/src/api/sync/types.ts
+++ b/packages/core/src/api/sync/types.ts
@@ -50,8 +50,11 @@ export type SyncTransferItem = {
count: number;
};
-export type SyncInboxItem = Omit & {
- key: Omit, "format" | "salt" | "iv">;
+export type SyncInboxItem = {
+ id: string;
+ v: number;
+ cipher: string;
+ alg: string;
};
export type ParsedInboxItem = {
diff --git a/packages/core/src/api/user-manager.ts b/packages/core/src/api/user-manager.ts
index 53040af2a..dc6ecd513 100644
--- a/packages/core/src/api/user-manager.ts
+++ b/packages/core/src/api/user-manager.ts
@@ -533,7 +533,7 @@ class UserManager {
this.cachedInboxKeys = key;
},
userProperty: "inboxKeys",
- generateKey: () => this.db.crypto().generateCryptoKeyPair(),
+ generateKey: () => this.db.crypto().generatePGPKeyPair(),
errorContext: "inbox encryption keys",
encrypt: async (keys, userEncryptionKey) => {
const encryptedPrivateKey = await this.db
@@ -584,6 +584,23 @@ class UserManager {
await this.setUser({ ...user, inboxKeys: undefined });
}
+ async saveInboxKeys(keys: SerializedKeyPair) {
+ this.cachedInboxKeys = keys;
+
+ const userEncryptionKey = await this.getEncryptionKey();
+ if (!userEncryptionKey) return;
+
+ const updatePayload = {
+ inboxKeys: {
+ public: keys.publicKey,
+ private: await this.db
+ .storage()
+ .encrypt(userEncryptionKey, JSON.stringify(keys.privateKey))
+ }
+ };
+ await this.updateUser(updatePayload);
+ }
+
async sendVerificationEmail(newEmail?: string) {
const token = await this.tokenManager.getAccessToken();
if (!token) return;
diff --git a/packages/core/src/interfaces.ts b/packages/core/src/interfaces.ts
index eec44365f..59b4a3a77 100644
--- a/packages/core/src/interfaces.ts
+++ b/packages/core/src/interfaces.ts
@@ -18,7 +18,6 @@ along with this program. If not, see .
*/
import {
- AsymmetricCipher,
Cipher,
DataFormat,
SerializedKey,
@@ -63,10 +62,6 @@ export interface IStorage {
key: SerializedKey,
items: Cipher<"base64">[]
): Promise;
- decryptAsymmetric(
- keyPair: SerializedKeyPair,
- cipherData: AsymmetricCipher<"base64">
- ): Promise;
deriveCryptoKey(credentials: SerializedKey): Promise;
hash(
password: string,
@@ -75,7 +70,12 @@ export interface IStorage {
): Promise;
getCryptoKey(): Promise;
generateCryptoKey(password: string, salt?: string): Promise;
- generateCryptoKeyPair(): Promise;
+ generatePGPKeyPair(): Promise;
+ decryptPGPMessage(
+ privateKeyArmored: string,
+ encryptedMessage: string
+ ): Promise;
+ validatePGPKeyPair(keys: SerializedKeyPair): Promise;
generateCryptoKeyFallback(
password: string,
diff --git a/packages/core/src/utils/crypto.ts b/packages/core/src/utils/crypto.ts
index b6e65bc4a..0c4b82a63 100644
--- a/packages/core/src/utils/crypto.ts
+++ b/packages/core/src/utils/crypto.ts
@@ -30,8 +30,8 @@ export class Crypto {
return await this.storage().generateCryptoKey(password);
}
- async generateCryptoKeyPair() {
- return await this.storage().generateCryptoKeyPair();
+ async generatePGPKeyPair() {
+ return await this.storage().generatePGPKeyPair();
}
}
diff --git a/packages/crypto/src/decryption.ts b/packages/crypto/src/decryption.ts
index ee55d38cc..b3cec4115 100644
--- a/packages/crypto/src/decryption.ts
+++ b/packages/crypto/src/decryption.ts
@@ -19,19 +19,12 @@ along with this program. If not, see .
import { ISodium } from "@notesnook/sodium";
import KeyUtils, { base64_variants } from "./keyutils.js";
-import {
- Cipher,
- Output,
- DataFormat,
- SerializedKey,
- SerializedKeyPair,
- AsymmetricCipher
-} from "./types.js";
+import { Cipher, Output, DataFormat, SerializedKey } from "./types.js";
export default class Decryption {
private static transformInput(
sodium: ISodium,
- cipherData: Cipher | AsymmetricCipher
+ cipherData: Cipher
): Uint8Array {
let input: Uint8Array | null = null;
if (
@@ -80,28 +73,6 @@ export default class Decryption {
) as Output;
}
- static decryptAsymmetric(
- sodium: ISodium,
- keyPair: SerializedKeyPair,
- cipherData: AsymmetricCipher,
- outputFormat: TOutputFormat = "text" as TOutputFormat
- ): Output {
- const input = this.transformInput(sodium, cipherData);
- const plaintext = sodium.crypto_box_seal_open(
- input,
- sodium.from_base64(keyPair.publicKey),
- sodium.from_base64(keyPair.privateKey)
- );
-
- return (
- outputFormat === "base64"
- ? sodium.to_base64(plaintext, base64_variants.URLSAFE_NO_PADDING)
- : outputFormat === "text"
- ? sodium.to_string(plaintext)
- : plaintext
- ) as Output;
- }
-
static createStream(
sodium: ISodium,
header: string,
diff --git a/packages/crypto/src/index.ts b/packages/crypto/src/index.ts
index 4af8962f7..b41d7fec8 100644
--- a/packages/crypto/src/index.ts
+++ b/packages/crypto/src/index.ts
@@ -31,8 +31,7 @@ import {
DataFormat,
SerializedKey,
SerializedKeyPair,
- EncryptionKeyPair,
- AsymmetricCipher
+ EncryptionKeyPair
} from "./types.js";
export class NNCrypto implements INNCrypto {
@@ -98,20 +97,6 @@ export class NNCrypto implements INNCrypto {
return decryptedItems;
}
- async decryptAsymmetric(
- keyPair: SerializedKeyPair,
- cipherData: AsymmetricCipher,
- outputFormat: TOutputFormat = "text" as TOutputFormat
- ): Promise