core: handle inbox item sync & decryption (#8733)

* core: handle inbox item sync & decryption
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>

* core: minor refactors in handling inbox item sync
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>

* core: use inbox item salt
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>

* core: check inbox item version
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
This commit is contained in:
01zulfi
2025-10-13 14:02:32 +05:00
committed by GitHub
parent 712f02e61e
commit 7f558cbd41
13 changed files with 250 additions and 10 deletions

View File

@@ -25,7 +25,12 @@ import {
IKVStore
} from "./key-value";
import { NNCrypto } from "./nncrypto";
import type { Cipher, SerializedKey } from "@notesnook/crypto";
import type {
AsymmetricCipher,
Cipher,
SerializedKey,
SerializedKeyPair
} from "@notesnook/crypto";
import { isFeatureSupported } from "../utils/feature-check";
import { IKeyStore } from "./key-store";
import { User } from "@notesnook/core";
@@ -160,6 +165,14 @@ export class NNStorage implements IStorage {
return NNCrypto.decryptMulti(key, items, "text");
}
decryptAsymmetric(
keyPair: SerializedKeyPair,
cipherData: AsymmetricCipher<"base64">
): Promise<string> {
cipherData.format = "base64";
return NNCrypto.decryptAsymmetric(keyPair, cipherData, "base64");
}
/**
* @deprecated
*/

View File

@@ -29,7 +29,7 @@ import Constants from "../../utils/constants.js";
import TokenManager from "../token-manager.js";
import Collector from "./collector.js";
import { type HubConnection } from "@microsoft/signalr";
import Merger from "./merger.js";
import Merger, { handleInboxItems } from "./merger.js";
import { AutoSync } from "./auto-sync.js";
import { logger } from "../../logger.js";
import { Mutex } from "async-mutex";
@@ -49,6 +49,7 @@ import {
import {
SYNC_COLLECTIONS_MAP,
SyncableItemType,
SyncInboxItem,
SyncTransferItem
} from "./types.js";
import { DownloadableFile } from "../../database/fs.js";
@@ -233,7 +234,19 @@ class Sync {
async fetch(deviceId: string, options: SyncOptions) {
await this.checkConnection();
await this.connection?.invoke("RequestFetchV2", deviceId);
try {
await this.connection?.invoke("RequestFetchV3", deviceId);
} catch (error) {
if (
error instanceof Error &&
error.message.includes("HubException: Method does not exist")
) {
this.logger.warn(
"RequestFetchV3 failed, falling back to RequestFetchV2"
);
await this.connection?.invoke("RequestFetchV2", deviceId);
}
}
if (this.conflictedNoteIds.length > 0) {
await this.db
@@ -478,6 +491,19 @@ class Sync {
return true;
});
this.connection.on(
"SendInboxItems",
async (inboxItems: SyncInboxItem[]) => {
if (this.connection?.state !== HubConnectionState.Connected) {
return false;
}
await handleInboxItems(inboxItems, this.db);
return true;
}
);
}
private async getKey() {

View File

@@ -27,6 +27,7 @@ import {
MaybeDeletedItem,
isDeleted
} from "../../types.js";
import { ParsedInboxItem, SyncInboxItem } from "./types.js";
const THRESHOLD = process.env.NODE_ENV === "test" ? 6 * 1000 : 60 * 1000;
class Merger {
@@ -146,3 +147,81 @@ export function isContentConflicted(
return "merge";
}
}
export async function handleInboxItems(
inboxItems: SyncInboxItem[],
db: Database
) {
const inboxKeys = await db.user.getInboxKeys();
if (!inboxKeys) {
logger.error("No inbox keys found, cannot process inbox items.");
return;
}
for (const item of inboxItems) {
try {
if (await db.notes.exists(item.id)) {
logger.info("Inbox item already exists, skipping.", {
inboxItemId: item.id
});
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 parsed = JSON.parse(decryptedItem) as ParsedInboxItem;
if (parsed.type !== "note") {
continue;
}
if (parsed.version !== 1) {
continue;
}
await db.notes.add({
id: item.id,
title: parsed.title,
favorite: parsed.favorite,
pinned: parsed.pinned,
readonly: parsed.readonly,
content: {
data: parsed?.content?.data ?? "",
type: "tiptap"
}
});
if (parsed.archived !== undefined) {
await db.notes.archive(parsed.archived, item.id);
}
for (const notebookId of parsed.notebookIds || []) {
if (!(await db.notebooks.exists(notebookId))) continue;
await db.notes.addToNotebook(notebookId, item.id);
}
for (const tagId of parsed.tagIds || []) {
if (!(await db.tags.exists(tagId))) continue;
await db.relations.add(
{ type: "tag", id: tagId },
{ type: "note", id: item.id }
);
}
} catch (e) {
logger.error(e, "Failed to process inbox item.", {
inboxItem: item
});
continue;
}
}
}

View File

@@ -48,3 +48,24 @@ export type SyncTransferItem = {
items: SyncItem[];
type: SyncableItemType;
};
export type SyncInboxItem = Omit<SyncItem, "format"> & {
key: Omit<Cipher<"base64">, "format" | "salt" | "iv">;
};
export type ParsedInboxItem = {
title: string;
pinned?: boolean;
favorite?: boolean;
readonly?: boolean;
archived?: boolean;
notebookIds?: string[];
tagIds?: string[];
type: "note";
source: string;
version: 1;
content?: {
type: "html";
data: string;
};
};

View File

@@ -18,6 +18,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import {
AsymmetricCipher,
Cipher,
DataFormat,
SerializedKey,
@@ -62,6 +63,10 @@ export interface IStorage {
key: SerializedKey,
items: Cipher<"base64">[]
): Promise<string[]>;
decryptAsymmetric(
keyPair: SerializedKeyPair,
cipherData: AsymmetricCipher<"base64">
): Promise<string>;
deriveCryptoKey(credentials: SerializedKey): Promise<void>;
hash(
password: string,

View File

@@ -19,12 +19,19 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import { base64_variants, ISodium } from "@notesnook/sodium";
import KeyUtils from "./keyutils.js";
import { Cipher, Output, DataFormat, SerializedKey } from "./types.js";
import {
Cipher,
Output,
DataFormat,
SerializedKey,
SerializedKeyPair,
AsymmetricCipher
} from "./types.js";
export default class Decryption {
private static transformInput(
sodium: ISodium,
cipherData: Cipher<DataFormat>
cipherData: Cipher<DataFormat> | AsymmetricCipher<DataFormat>
): Uint8Array {
let input: Uint8Array | null = null;
if (
@@ -55,7 +62,6 @@ export default class Decryption {
): Output<TOutputFormat> {
if (!key.salt && cipherData.salt) key.salt = cipherData.salt;
const encryptionKey = KeyUtils.transform(sodium, key);
const input = this.transformInput(sodium, cipherData);
const plaintext = sodium.crypto_aead_xchacha20poly1305_ietf_decrypt(
null,
@@ -74,6 +80,28 @@ export default class Decryption {
) as Output<TOutputFormat>;
}
static decryptAsymmetric<TOutputFormat extends DataFormat>(
sodium: ISodium,
keyPair: SerializedKeyPair,
cipherData: AsymmetricCipher<DataFormat>,
outputFormat: TOutputFormat = "text" as TOutputFormat
): Output<TOutputFormat> {
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<TOutputFormat>;
}
static createStream(
sodium: ISodium,
header: string,

View File

@@ -31,7 +31,8 @@ import {
DataFormat,
SerializedKey,
SerializedKeyPair,
EncryptionKeyPair
EncryptionKeyPair,
AsymmetricCipher
} from "./types.js";
export class NNCrypto implements INNCrypto {
@@ -96,6 +97,20 @@ export class NNCrypto implements INNCrypto {
return decryptedItems;
}
async decryptAsymmetric<TOutputFormat extends DataFormat>(
keyPair: SerializedKeyPair,
cipherData: AsymmetricCipher<DataFormat>,
outputFormat: TOutputFormat = "text" as TOutputFormat
): Promise<Output<TOutputFormat>> {
await this.init();
return Decryption.decryptAsymmetric(
this.sodium,
keyPair,
cipherData,
outputFormat
);
}
async hash(password: string, salt: string): Promise<string> {
await this.init();
return Password.hash(this.sodium, password, salt);

View File

@@ -26,7 +26,8 @@ import {
Output,
Input,
EncryptionKeyPair,
SerializedKeyPair
SerializedKeyPair,
AsymmetricCipher
} from "./types.js";
export interface IStreamable {
@@ -61,6 +62,12 @@ export interface INNCrypto {
outputFormat?: TOutputFormat
): Promise<Output<TOutputFormat>[]>;
decryptAsymmetric<TOutputFormat extends DataFormat>(
keyPair: SerializedKeyPair,
cipherData: AsymmetricCipher<DataFormat>,
outputFormat?: TOutputFormat
): Promise<Output<TOutputFormat>>;
hash(password: string, salt: string): Promise<string>;
deriveKey(password: string, salt?: string): Promise<EncryptionKey>;

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 { ISodium } from "@notesnook/sodium";
import { base64_variants, ISodium } from "@notesnook/sodium";
import {
EncryptionKey,
EncryptionKeyPair,

View File

@@ -30,6 +30,11 @@ export type Cipher<TFormat extends DataFormat> = {
length: number;
};
export type AsymmetricCipher<TFormat extends DataFormat> = Omit<
Cipher<TFormat>,
"iv" | "salt"
>;
export type Output<TFormat extends DataFormat> =
TFormat extends StringOutputFormat ? string : Uint8Array;
export type Input<TFormat extends DataFormat> = Output<TFormat>;

View File

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

View File

@@ -49,7 +49,9 @@ import {
crypto_secretstream_xchacha20poly1305_TAG_MESSAGE,
crypto_box_keypair as sodium_native_crypto_box_keypair,
crypto_box_PUBLICKEYBYTES,
crypto_box_SECRETKEYBYTES
crypto_box_SECRETKEYBYTES,
crypto_box_seal_open as sodium_native_crypto_box_seal_open,
crypto_box_SEALBYTES
} from "sodium-native";
import { Buffer } from "node:buffer";
import { base64_variants, ISodium } from "./types";
@@ -377,6 +379,38 @@ function crypto_box_keypair(
}
}
function crypto_box_seal_open(
ciphertext: string | Uint8Array,
publicKey: Uint8Array,
privateKey: Uint8Array,
outputFormat?: Uint8ArrayOutputFormat | null
): Uint8Array;
function crypto_box_seal_open(
ciphertext: string | Uint8Array,
publicKey: Uint8Array,
privateKey: Uint8Array,
outputFormat: StringOutputFormat
): string;
function crypto_box_seal_open(
ciphertext: string | Uint8Array,
publicKey: Uint8Array,
privateKey: Uint8Array,
outputFormat?: StringOutputFormat | Uint8ArrayOutputFormat | null
): string | Uint8Array {
const cipher = toBuffer(ciphertext);
return wrap(
cipher.byteLength - crypto_box_SEALBYTES,
(message) =>
sodium_native_crypto_box_seal_open(
message,
cipher,
toBuffer(publicKey),
toBuffer(privateKey)
),
outputFormat
);
}
function randombytes_buf(
length: number,
outputFormat?: Uint8ArrayOutputFormat | null
@@ -594,6 +628,9 @@ export class Sodium implements ISodium {
get crypto_box_keypair() {
return crypto_box_keypair;
}
get crypto_box_seal_open() {
return crypto_box_seal_open;
}
}
export { base64_variants, type ISodium };

View File

@@ -62,4 +62,5 @@ export interface ISodium {
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;
get crypto_box_seal_open(): typeof sodium.crypto_box_seal_open;
}