mirror of
https://github.com/streetwriters/notesnook.git
synced 2025-12-16 19:57:52 +01:00
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:
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user