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

@@ -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"> {