global: add enable inbox setting to generate/discard inbox key pair (#8527)

Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
This commit is contained in:
01zulfi
2025-09-09 22:12:44 +05:00
committed by GitHub
parent 5e4972c1d0
commit 251145ea74
17 changed files with 357 additions and 69 deletions

View File

@@ -230,7 +230,8 @@ import {
mdiHamburger,
mdiNotePlus,
mdiNoteEditOutline,
mdiArrowUp
mdiArrowUp,
mdiInbox
} from "@mdi/js";
import { useTheme } from "@emotion/react";
import { Theme } from "@notesnook/theme";
@@ -584,3 +585,4 @@ export const ColorRemove = createIcon(mdiCloseCircleOutline);
export const ExpandSidebar = createIcon(mdiArrowCollapseRight);
export const HamburgerMenu = createIcon(mdiMenu);
export const ArrowUp = createIcon(mdiArrowUp);
export const Inbox = createIcon(mdiInbox);

View File

@@ -0,0 +1,46 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { SettingsGroup } from "./types";
import { useStore as useSettingStore } from "../../stores/setting-store";
export const InboxSettings: SettingsGroup[] = [
{
key: "inbox",
section: "inbox",
header: "Inbox",
settings: [
{
key: "toggle-inbox",
title: "Enable Inbox API",
description: "Enable/disable Inbox API",
keywords: ["inbox"],
onStateChange: (listener) =>
useSettingStore.subscribe((s) => s.isInboxEnabled, listener),
components: [
{
type: "toggle",
isToggled: () => useSettingStore.getState().isInboxEnabled,
toggle: () => useSettingStore.getState().toggleInbox()
}
]
}
]
}
];

View File

@@ -38,7 +38,8 @@ import {
Pro,
Servers,
ShieldLock,
Sync
Sync,
Inbox
} from "../../components/icons";
import NavigationItem from "../../components/navigation-menu/navigation-item";
import { FlexScrollContainer } from "../../components/scroll-container";
@@ -78,6 +79,7 @@ import { BaseDialogProps, DialogManager } from "../../common/dialog-manager";
import { ServersSettings } from "./servers-settings";
import { strings } from "@notesnook/intl";
import { mdToHtml } from "../../utils/md";
import { InboxSettings } from "./inbox-settings";
type SettingsDialogProps = BaseDialogProps<false> & {
activeSection?: SectionKeys;
@@ -106,6 +108,12 @@ const sectionGroups: SectionGroup[] = [
title: strings.sync(),
icon: Sync,
isHidden: () => !useUserStore.getState().isLoggedIn
},
{
key: "inbox",
title: "Inbox",
icon: Inbox,
isHidden: () => true // hidden until complete
}
]
},
@@ -176,7 +184,8 @@ const SettingsGroups = [
...SupportSettings,
...AboutSettings,
...SubscriptionSettings,
...ServersSettings
...ServersSettings,
...InboxSettings
];
// Thoughts:

View File

@@ -39,7 +39,8 @@ export type SectionKeys =
| "support"
| "legal"
| "developer"
| "about";
| "about"
| "inbox";
export type SectionGroupKeys =
| "account"

View File

@@ -128,6 +128,10 @@ export class NNStorage implements IStorage {
return await NNCrypto.exportKey(password, salt);
}
async generateCryptoKeyPair() {
return await NNCrypto.exportKeyPair();
}
async hash(password: string, email: string): Promise<string> {
return await NNCrypto.hash(password, `${APP_SALT}${email}`);
}

View File

@@ -27,6 +27,7 @@ import { useEditorStore } from "./editor-store";
import { setDocumentTitle } from "../utils/dom";
import { TimeFormat } from "@notesnook/core";
import { Profile, TrashCleanupInterval } from "@notesnook/core";
import { showToast } from "../utils/toast";
export const HostIds = [
"API_HOST",
@@ -89,6 +90,7 @@ class SettingStore extends BaseStore<SettingStore> {
isFlatpak = false;
isSnap = false;
proxyRules?: string;
isInboxEnabled = false;
refresh = async () => {
this.set({
@@ -105,7 +107,8 @@ class SettingStore extends BaseStore<SettingStore> {
customDns: await desktop?.integration.customDns.query(),
zoomFactor: await desktop?.integration.zoomFactor.query(),
autoUpdates: await desktop?.updater.autoUpdates.query(),
proxyRules: await desktop?.integration.proxyRules.query()
proxyRules: await desktop?.integration.proxyRules.query(),
isInboxEnabled: await db.user.hasInboxKeys()
});
};
@@ -271,6 +274,25 @@ class SettingStore extends BaseStore<SettingStore> {
this.set({ serverUrls: { ...serverUrls, ...urls } });
Config.set("serverUrls", { ...serverUrls, ...urls });
};
toggleInbox = async () => {
const { isInboxEnabled } = this.get();
const newState = !isInboxEnabled;
try {
if (newState) {
await db.user.getInboxKeys();
} else {
await db.user.discardInboxKeys();
}
this.set((state) => (state.isInboxEnabled = newState));
} catch (e) {
if (e instanceof Error) {
showToast("error", e.message);
}
}
};
}
const [useStore, store] = createStore<SettingStore>(

View File

@@ -24,7 +24,7 @@ import TokenManager from "./token-manager.js";
import { EV, EVENTS } from "../common.js";
import { HealthCheck } from "./healthcheck.js";
import Database from "./index.js";
import { SerializedKey } from "@notesnook/crypto";
import { SerializedKeyPair, SerializedKey, Cipher } from "@notesnook/crypto";
import { logger } from "../logger.js";
const ENDPOINTS = {
@@ -44,6 +44,7 @@ class UserManager {
private tokenManager: TokenManager;
private cachedAttachmentKey?: SerializedKey;
private cachedMonographPasswordsKey?: SerializedKey;
private cachedInboxKeys?: SerializedKeyPair;
constructor(private readonly db: Database) {
this.tokenManager = new TokenManager(this.db.kv);
@@ -278,6 +279,7 @@ class UserManager {
logger.error(e, "Error logging out user.", { revoke, reason });
} finally {
this.cachedAttachmentKey = undefined;
this.cachedInboxKeys = undefined;
await this.db.reset();
EV.publish(EVENTS.userLoggedOut, reason);
EV.publish(EVENTS.appRefreshRequested);
@@ -420,13 +422,26 @@ class UserManager {
return { key, salt: user.salt };
}
async getAttachmentsKey() {
if (this.cachedAttachmentKey) return this.cachedAttachmentKey;
private async getUserKey<T>(config: {
getCache: () => T | undefined;
setCache: (key: T) => void;
userProperty: keyof User;
generateKey: () => Promise<T>;
errorContext: string;
decrypt: (user: User, userEncryptionKey: SerializedKey) => Promise<T>;
encrypt: (
key: T,
userEncryptionKey: SerializedKey
) => Promise<Partial<User>>;
}): Promise<T | undefined> {
const cachedKey = config.getCache();
if (cachedKey) return cachedKey;
try {
let user = await this.getUser();
if (!user) return;
if (!user.attachmentsKey) {
if (!user[config.userProperty]) {
const token = await this.tokenManager.getAccessToken();
user = await http.get(`${constants.API_HOST}${ENDPOINTS.user}`, token);
}
@@ -435,74 +450,134 @@ class UserManager {
const userEncryptionKey = await this.getEncryptionKey();
if (!userEncryptionKey) return;
if (!user.attachmentsKey) {
const key = await this.db.crypto().generateRandomKey();
user.attachmentsKey = await this.db
.storage()
.encrypt(userEncryptionKey, JSON.stringify(key));
await this.updateUser({ attachmentsKey: user.attachmentsKey });
if (!user[config.userProperty]) {
const key = await config.generateKey();
const updatePayload = await config.encrypt(key, userEncryptionKey);
await this.updateUser(updatePayload);
return key;
}
const plainData = await this.db
.storage()
.decrypt(userEncryptionKey, user.attachmentsKey);
if (!plainData) return;
this.cachedAttachmentKey = JSON.parse(plainData) as SerializedKey;
return this.cachedAttachmentKey;
const decryptedKey = await config.decrypt(user, userEncryptionKey);
config.setCache(decryptedKey);
return decryptedKey;
} catch (e) {
logger.error(e, "Could not get attachments encryption key.");
logger.error(e, `Could not get ${config.errorContext}.`);
if (e instanceof Error)
throw new Error(
`Could not get attachments encryption key. Error: ${e.message}`
`Could not get ${config.errorContext}. Error: ${e.message}`
);
}
}
async getMonographPasswordsKey() {
if (this.cachedMonographPasswordsKey) {
return this.cachedMonographPasswordsKey;
}
try {
let user = await this.getUser();
if (!user) return;
if (!user.monographPasswordsKey) {
const token = await this.tokenManager.getAccessToken();
user = await http.get(`${constants.API_HOST}${ENDPOINTS.user}`, token);
}
if (!user) return;
const userEncryptionKey = await this.getEncryptionKey();
if (!userEncryptionKey) return;
if (!user.monographPasswordsKey) {
const key = await this.db.crypto().generateRandomKey();
user.monographPasswordsKey = await this.db
async getAttachmentsKey() {
return this.getUserKey<SerializedKey>({
getCache: () => this.cachedAttachmentKey,
setCache: (key) => {
this.cachedAttachmentKey = key;
},
userProperty: "attachmentsKey",
generateKey: () => this.db.crypto().generateRandomKey(),
errorContext: "attachments encryption key",
encrypt: async (key, userEncryptionKey) => {
const encryptedKey = await this.db
.storage()
.encrypt(userEncryptionKey, JSON.stringify(key));
await this.updateUser({
monographPasswordsKey: user.monographPasswordsKey
});
return key;
return { attachmentsKey: encryptedKey };
},
decrypt: async (user, userEncryptionKey) => {
const encryptedKey = user.attachmentsKey as Cipher<"base64">;
const plainData = await this.db
.storage()
.decrypt(userEncryptionKey, encryptedKey);
if (!plainData) throw new Error("Failed to decrypt attachments key");
return JSON.parse(plainData) as SerializedKey;
}
});
}
const plainData = await this.db
.storage()
.decrypt(userEncryptionKey, user.monographPasswordsKey);
if (!plainData) return;
this.cachedMonographPasswordsKey = JSON.parse(plainData) as SerializedKey;
return this.cachedMonographPasswordsKey;
} catch (e) {
logger.error(e, "Could not get monographs encryption key.");
if (e instanceof Error)
throw new Error(
`Could not get monographs encryption key. Error: ${e.message}`
);
}
async getMonographPasswordsKey() {
return this.getUserKey<SerializedKey>({
getCache: () => this.cachedMonographPasswordsKey,
setCache: (key) => {
this.cachedMonographPasswordsKey = key;
},
userProperty: "monographPasswordsKey",
generateKey: () => this.db.crypto().generateRandomKey(),
errorContext: "monographs encryption key",
encrypt: async (key, userEncryptionKey) => {
const encryptedKey = await this.db
.storage()
.encrypt(userEncryptionKey, JSON.stringify(key));
return { monographPasswordsKey: encryptedKey };
},
decrypt: async (user, userEncryptionKey) => {
const encryptedKey = user.monographPasswordsKey as Cipher<"base64">;
const plainData = await this.db
.storage()
.decrypt(userEncryptionKey, encryptedKey);
if (!plainData)
throw new Error("Failed to decrypt monograph passwords key");
return JSON.parse(plainData) as SerializedKey;
}
});
}
async getInboxKeys() {
return this.getUserKey<SerializedKeyPair>({
getCache: () => this.cachedInboxKeys,
setCache: (key) => {
this.cachedInboxKeys = key;
},
userProperty: "inboxKeys",
generateKey: () => this.db.crypto().generateCryptoKeyPair(),
errorContext: "inbox encryption keys",
encrypt: async (keys, userEncryptionKey) => {
const encryptedPrivateKey = await this.db
.storage()
.encrypt(userEncryptionKey, JSON.stringify(keys.privateKey));
return {
inboxKeys: {
public: keys.publicKey,
private: encryptedPrivateKey
}
};
},
decrypt: async (user, userEncryptionKey) => {
if (!user.inboxKeys) throw new Error("Inbox keys not found");
const decryptedPrivateKey = await this.db
.storage()
.decrypt(userEncryptionKey, user.inboxKeys.private);
return {
publicKey: user.inboxKeys.public,
privateKey: JSON.parse(decryptedPrivateKey)
};
}
});
}
async hasInboxKeys() {
if (this.cachedInboxKeys) return true;
let user = await this.getUser();
if (!user) return false;
return !!user.inboxKeys;
}
async discardInboxKeys() {
this.cachedInboxKeys = undefined;
const user = await this.getUser();
if (!user) return;
const token = await this.tokenManager.getAccessToken();
await http.patch.json(
`${constants.API_HOST}${ENDPOINTS.user}`,
{ inboxKeys: { public: null, private: null } },
token
);
await this.setUser({ ...user, inboxKeys: undefined });
}
async sendVerificationEmail(newEmail?: string) {
@@ -616,6 +691,16 @@ class UserManager {
.encrypt(userEncryptionKey, JSON.stringify(monographPasswordsKey));
updateUserPayload.monographPasswordsKey = user.monographPasswordsKey;
}
const inboxKeys = await this.getInboxKeys();
if (inboxKeys) {
user.inboxKeys = {
public: inboxKeys.publicKey,
private: await this.db
.storage()
.encrypt(userEncryptionKey, JSON.stringify(inboxKeys.privateKey))
};
updateUserPayload.inboxKeys = user.inboxKeys;
}
if (Object.keys(updateUserPayload).length > 0) {
await this.updateUser(updateUserPayload);
}

View File

@@ -17,7 +17,12 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { Cipher, DataFormat, SerializedKey } from "@notesnook/crypto";
import {
Cipher,
DataFormat,
SerializedKey,
SerializedKeyPair
} from "@notesnook/crypto";
import { KVStorage } from "./database/kv.js";
import { ConfigStorage } from "./database/config.js";
@@ -65,6 +70,7 @@ export interface IStorage {
): Promise<string>;
getCryptoKey(): Promise<string | undefined>;
generateCryptoKey(password: string, salt?: string): Promise<SerializedKey>;
generateCryptoKeyPair(): Promise<SerializedKeyPair>;
generateCryptoKeyFallback(
password: string,

View File

@@ -557,6 +557,7 @@ export type User = {
salt: string;
attachmentsKey?: Cipher<"base64">;
monographPasswordsKey?: Cipher<"base64">;
inboxKeys?: { public: string; private: Cipher<"base64"> };
marketingConsent?: boolean;
mfa: {
isEnabled: boolean;

View File

@@ -29,6 +29,10 @@ export class Crypto {
const password = passwordBytes.toString("base64");
return await this.storage().generateCryptoKey(password);
}
async generateCryptoKeyPair() {
return await this.storage().generateCryptoKeyPair();
}
}
export function isCipher(item: any): item is Cipher<"base64"> {

View File

@@ -29,7 +29,9 @@ import {
Input,
Output,
DataFormat,
SerializedKey
SerializedKey,
SerializedKeyPair,
EncryptionKeyPair
} from "./types.js";
export class NNCrypto implements INNCrypto {
@@ -104,11 +106,21 @@ export class NNCrypto implements INNCrypto {
return KeyUtils.deriveKey(this.sodium, password, salt);
}
async deriveKeyPair(): Promise<EncryptionKeyPair> {
await this.init();
return KeyUtils.deriveKeyPair(this.sodium);
}
async exportKey(password: string, salt?: string): Promise<SerializedKey> {
await this.init();
return KeyUtils.exportKey(this.sodium, password, salt);
}
async exportKeyPair(): Promise<SerializedKeyPair> {
await this.init();
return KeyUtils.exportKeyPair(this.sodium);
}
async createEncryptionStream(key: SerializedKey) {
await this.init();
return Encryption.createStream(this.sodium, key);

View File

@@ -24,7 +24,9 @@ import {
SerializedKey,
Chunk,
Output,
Input
Input,
EncryptionKeyPair,
SerializedKeyPair
} from "./types.js";
export interface IStreamable {
@@ -63,8 +65,12 @@ export interface INNCrypto {
deriveKey(password: string, salt?: string): Promise<EncryptionKey>;
deriveKeyPair(): Promise<EncryptionKeyPair>;
exportKey(password: string, salt?: string): Promise<SerializedKey>;
exportKeyPair(): Promise<SerializedKeyPair>;
createEncryptionStream(
key: SerializedKey
): Promise<{ iv: string; stream: TransformStream<Chunk, Uint8Array> }>;

View File

@@ -18,7 +18,12 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { ISodium } from "@notesnook/sodium";
import { EncryptionKey, SerializedKey } from "./types.js";
import {
EncryptionKey,
EncryptionKeyPair,
SerializedKey,
SerializedKeyPair
} from "./types.js";
export default class KeyUtils {
static deriveKey(
@@ -51,6 +56,14 @@ export default class KeyUtils {
};
}
static deriveKeyPair(sodium: ISodium): EncryptionKeyPair {
const keypair = sodium.crypto_box_keypair();
return {
publicKey: keypair.publicKey,
privateKey: keypair.privateKey
};
}
static exportKey(
sodium: ISodium,
password: string,
@@ -60,6 +73,14 @@ export default class KeyUtils {
return { key: sodium.to_base64(key), salt: keySalt };
}
static exportKeyPair(sodium: ISodium): SerializedKeyPair {
const { publicKey, privateKey } = this.deriveKeyPair(sodium);
return {
publicKey: sodium.to_base64(publicKey),
privateKey: sodium.to_base64(privateKey)
};
}
/**
* Takes in either a password or a serialized encryption key
* and spits out a key that can be directly used for encryption/decryption.

View File

@@ -49,3 +49,13 @@ export type Chunk = {
data: Uint8Array;
final: boolean;
};
export type EncryptionKeyPair = {
publicKey: Uint8Array;
privateKey: Uint8Array;
};
export type SerializedKeyPair = {
publicKey: string;
privateKey: string;
};

View File

@@ -118,6 +118,9 @@ export class Sodium implements ISodium {
get crypto_secretstream_xchacha20poly1305_TAG_MESSAGE() {
return sodium.crypto_secretstream_xchacha20poly1305_TAG_MESSAGE;
}
get crypto_box_keypair() {
return sodium.crypto_box_keypair;
}
}
function convertVariant(variant: base64_variants): sodium.base64_variants {

View File

@@ -46,14 +46,29 @@ import {
crypto_aead_xchacha20poly1305_ietf_KEYBYTES,
crypto_aead_xchacha20poly1305_ietf_NPUBBYTES,
crypto_secretstream_xchacha20poly1305_TAG_FINAL,
crypto_secretstream_xchacha20poly1305_TAG_MESSAGE
crypto_secretstream_xchacha20poly1305_TAG_MESSAGE,
crypto_box_keypair as sodium_native_crypto_box_keypair,
crypto_box_PUBLICKEYBYTES,
crypto_box_SECRETKEYBYTES
} from "sodium-native";
import { Buffer } from "node:buffer";
import { base64_variants, ISodium } from "./types";
export type Uint8ArrayOutputFormat = "uint8array";
export type StringOutputFormat = "text" | "hex" | "base64";
export type KeyType = "curve25519" | "ed25519" | "x25519";
export type StateAddress = { name: string };
export interface KeyPair {
keyType: KeyType;
privateKey: Uint8Array;
publicKey: Uint8Array;
}
export interface StringKeyPair {
keyType: KeyType;
privateKey: string;
publicKey: string;
}
export interface MessageTag {
message: Uint8Array;
tag: number;
@@ -329,6 +344,39 @@ function crypto_secretstream_xchacha20poly1305_pull(
return { message, tag: tag.readUInt8() } as MessageTag | StringMessageTag;
}
function crypto_box_keypair(
outputFormat?: Uint8ArrayOutputFormat | null
): KeyPair;
function crypto_box_keypair(outputFormat: StringOutputFormat): StringKeyPair;
function crypto_box_keypair(
outputFormat?: Uint8ArrayOutputFormat | null | StringOutputFormat
): KeyPair | StringKeyPair {
const publicBuffer = Buffer.alloc(crypto_box_PUBLICKEYBYTES);
const privateBuffer = Buffer.alloc(crypto_box_SECRETKEYBYTES);
sodium_native_crypto_box_keypair(publicBuffer, privateBuffer);
if (typeof outputFormat === "string") {
const transformer =
outputFormat === "base64"
? to_base64
: outputFormat === "hex"
? to_hex
: to_string;
return {
keyType: "x25519" as KeyType,
publicKey: transformer(new Uint8Array(publicBuffer)),
privateKey: transformer(new Uint8Array(privateBuffer))
};
} else {
return {
keyType: "x25519" as KeyType,
publicKey: new Uint8Array(publicBuffer),
privateKey: new Uint8Array(privateBuffer)
};
}
}
function randombytes_buf(
length: number,
outputFormat?: Uint8ArrayOutputFormat | null
@@ -391,6 +439,10 @@ function to_string(input: Uint8Array): string {
);
}
function to_hex(input: Uint8Array): string {
return Buffer.from(input, input.byteOffset, input.byteLength).toString("hex");
}
type ToBufferInput = string | Uint8Array | null | undefined;
type ToBufferResult<TInput extends ToBufferInput> = TInput extends
| undefined
@@ -539,6 +591,9 @@ export class Sodium implements ISodium {
get crypto_secretstream_xchacha20poly1305_TAG_MESSAGE() {
return crypto_secretstream_xchacha20poly1305_TAG_MESSAGE;
}
get crypto_box_keypair() {
return crypto_box_keypair;
}
}
export { base64_variants, type ISodium };

View File

@@ -17,7 +17,7 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import type sodium from "libsodium-wrappers-sumo";
import sodium from "libsodium-wrappers-sumo";
export enum base64_variants {
ORIGINAL = 1,
@@ -61,4 +61,5 @@ export interface ISodium {
get crypto_aead_xchacha20poly1305_ietf_NPUBBYTES(): typeof sodium.crypto_aead_xchacha20poly1305_ietf_NPUBBYTES;
get crypto_secretstream_xchacha20poly1305_TAG_FINAL(): typeof sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL;
get crypto_secretstream_xchacha20poly1305_TAG_MESSAGE(): typeof sodium.crypto_secretstream_xchacha20poly1305_TAG_MESSAGE;
get crypto_box_keypair(): typeof sodium.crypto_box_keypair;
}