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

View File

@@ -29,7 +29,7 @@ import Constants from "../../utils/constants.js";
import TokenManager from "../token-manager.js"; import TokenManager from "../token-manager.js";
import Collector from "./collector.js"; import Collector from "./collector.js";
import { type HubConnection } from "@microsoft/signalr"; import { type HubConnection } from "@microsoft/signalr";
import Merger from "./merger.js"; import Merger, { handleInboxItems } from "./merger.js";
import { AutoSync } from "./auto-sync.js"; import { AutoSync } from "./auto-sync.js";
import { logger } from "../../logger.js"; import { logger } from "../../logger.js";
import { Mutex } from "async-mutex"; import { Mutex } from "async-mutex";
@@ -49,6 +49,7 @@ import {
import { import {
SYNC_COLLECTIONS_MAP, SYNC_COLLECTIONS_MAP,
SyncableItemType, SyncableItemType,
SyncInboxItem,
SyncTransferItem SyncTransferItem
} from "./types.js"; } from "./types.js";
import { DownloadableFile } from "../../database/fs.js"; import { DownloadableFile } from "../../database/fs.js";
@@ -233,7 +234,19 @@ class Sync {
async fetch(deviceId: string, options: SyncOptions) { async fetch(deviceId: string, options: SyncOptions) {
await this.checkConnection(); 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) { if (this.conflictedNoteIds.length > 0) {
await this.db await this.db
@@ -478,6 +491,19 @@ class Sync {
return true; 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() { private async getKey() {

View File

@@ -27,6 +27,7 @@ import {
MaybeDeletedItem, MaybeDeletedItem,
isDeleted isDeleted
} from "../../types.js"; } from "../../types.js";
import { ParsedInboxItem, SyncInboxItem } from "./types.js";
const THRESHOLD = process.env.NODE_ENV === "test" ? 6 * 1000 : 60 * 1000; const THRESHOLD = process.env.NODE_ENV === "test" ? 6 * 1000 : 60 * 1000;
class Merger { class Merger {
@@ -146,3 +147,81 @@ export function isContentConflicted(
return "merge"; 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[]; items: SyncItem[];
type: SyncableItemType; 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 { import {
AsymmetricCipher,
Cipher, Cipher,
DataFormat, DataFormat,
SerializedKey, SerializedKey,
@@ -62,6 +63,10 @@ export interface IStorage {
key: SerializedKey, key: SerializedKey,
items: Cipher<"base64">[] items: Cipher<"base64">[]
): Promise<string[]>; ): Promise<string[]>;
decryptAsymmetric(
keyPair: SerializedKeyPair,
cipherData: AsymmetricCipher<"base64">
): Promise<string>;
deriveCryptoKey(credentials: SerializedKey): Promise<void>; deriveCryptoKey(credentials: SerializedKey): Promise<void>;
hash( hash(
password: string, 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 { base64_variants, ISodium } from "@notesnook/sodium";
import KeyUtils from "./keyutils.js"; 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 { export default class Decryption {
private static transformInput( private static transformInput(
sodium: ISodium, sodium: ISodium,
cipherData: Cipher<DataFormat> cipherData: Cipher<DataFormat> | AsymmetricCipher<DataFormat>
): Uint8Array { ): Uint8Array {
let input: Uint8Array | null = null; let input: Uint8Array | null = null;
if ( if (
@@ -55,7 +62,6 @@ export default class Decryption {
): Output<TOutputFormat> { ): Output<TOutputFormat> {
if (!key.salt && cipherData.salt) key.salt = cipherData.salt; if (!key.salt && cipherData.salt) key.salt = cipherData.salt;
const encryptionKey = KeyUtils.transform(sodium, key); const encryptionKey = KeyUtils.transform(sodium, key);
const input = this.transformInput(sodium, cipherData); const input = this.transformInput(sodium, cipherData);
const plaintext = sodium.crypto_aead_xchacha20poly1305_ietf_decrypt( const plaintext = sodium.crypto_aead_xchacha20poly1305_ietf_decrypt(
null, null,
@@ -74,6 +80,28 @@ export default class Decryption {
) as Output<TOutputFormat>; ) 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( static createStream(
sodium: ISodium, sodium: ISodium,
header: string, header: string,

View File

@@ -31,7 +31,8 @@ import {
DataFormat, DataFormat,
SerializedKey, SerializedKey,
SerializedKeyPair, SerializedKeyPair,
EncryptionKeyPair EncryptionKeyPair,
AsymmetricCipher
} from "./types.js"; } from "./types.js";
export class NNCrypto implements INNCrypto { export class NNCrypto implements INNCrypto {
@@ -96,6 +97,20 @@ export class NNCrypto implements INNCrypto {
return decryptedItems; 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> { async hash(password: string, salt: string): Promise<string> {
await this.init(); await this.init();
return Password.hash(this.sodium, password, salt); return Password.hash(this.sodium, password, salt);

View File

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

View File

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

View File

@@ -121,6 +121,9 @@ export class Sodium implements ISodium {
get crypto_box_keypair() { get crypto_box_keypair() {
return sodium.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 { function convertVariant(variant: base64_variants): sodium.base64_variants {

View File

@@ -49,7 +49,9 @@ import {
crypto_secretstream_xchacha20poly1305_TAG_MESSAGE, crypto_secretstream_xchacha20poly1305_TAG_MESSAGE,
crypto_box_keypair as sodium_native_crypto_box_keypair, crypto_box_keypair as sodium_native_crypto_box_keypair,
crypto_box_PUBLICKEYBYTES, 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"; } from "sodium-native";
import { Buffer } from "node:buffer"; import { Buffer } from "node:buffer";
import { base64_variants, ISodium } from "./types"; 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( function randombytes_buf(
length: number, length: number,
outputFormat?: Uint8ArrayOutputFormat | null outputFormat?: Uint8ArrayOutputFormat | null
@@ -594,6 +628,9 @@ export class Sodium implements ISodium {
get crypto_box_keypair() { get crypto_box_keypair() {
return crypto_box_keypair; return crypto_box_keypair;
} }
get crypto_box_seal_open() {
return crypto_box_seal_open;
}
} }
export { base64_variants, type ISodium }; 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_FINAL(): typeof sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL;
get crypto_secretstream_xchacha20poly1305_TAG_MESSAGE(): typeof sodium.crypto_secretstream_xchacha20poly1305_TAG_MESSAGE; 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_keypair(): typeof sodium.crypto_box_keypair;
get crypto_box_seal_open(): typeof sodium.crypto_box_seal_open;
} }