/* 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 . */ /** * MS-OFFCRYPTO encryption support for OneNote sections. * * Implements the password-based key derivation and AES-256-CBC decryption * described in [MS-OFFCRYPTO] 2.3.4.7 (ECMA-376 Agile Encryption). * * The encryption metadata (algorithm, salts, spin count, encrypted verifier * and encrypted key) is stored in an XML blob inside the * `ObjectDataEncryptionKeyV2FNDX` node of each encrypted object space. */ import { fromBase64 } from "./utils/base64.js"; // --------------------------------------------------------------------------- // Encryption metadata parsed from the XML blob // --------------------------------------------------------------------------- export interface EncryptionInfo { /** Cipher algorithm, e.g. "AES" */ cipherAlgorithm: string; /** Cipher chaining, e.g. "ChainingModeCBC" */ cipherChaining: string; /** Hash algorithm, e.g. "SHA512" */ hashAlgorithm: string; /** Key size in bits, e.g. 256 */ keyBits: number; /** Block size in bytes, e.g. 16 */ blockSize: number; /** Hash output size in bytes, e.g. 64 */ hashSize: number; /** Salt for the data key, base64-encoded */ saltValue: Uint8Array; /** Spin count for the password key derivation, e.g. 100000 */ spinCount: number; /** Salt for the password key derivation, base64-encoded */ passwordSalt: Uint8Array; /** Encrypted verifier hash input, base64-encoded */ encryptedVerifierHashInput: Uint8Array; /** Encrypted verifier hash value, base64-encoded */ encryptedVerifierHashValue: Uint8Array; /** Encrypted data encryption key, base64-encoded */ encryptedKeyValue: Uint8Array; } /** * Parse the encryption XML blob that is stored in the * `ObjectDataEncryptionKeyV2FNDX` reference. */ export function parseEncryptionXml(xml: string): EncryptionInfo { const parser = new DOMParser(); const doc = parser.parseFromString(xml, "application/xml"); const keyData = doc.querySelector("keyData"); const encryptedKey = doc.querySelector("encryptedKey"); if (!keyData || !encryptedKey) { throw new Error("Invalid encryption XML: missing keyData or encryptedKey"); } return { cipherAlgorithm: keyData.getAttribute("cipherAlgorithm") || "AES", cipherChaining: keyData.getAttribute("cipherChaining") || "ChainingModeCBC", hashAlgorithm: keyData.getAttribute("hashAlgorithm") || "SHA512", keyBits: parseInt(keyData.getAttribute("keyBits") || "256", 10), blockSize: parseInt(keyData.getAttribute("blockSize") || "16", 10), hashSize: parseInt(keyData.getAttribute("hashSize") || "64", 10), saltValue: fromBase64(keyData.getAttribute("saltValue") || ""), spinCount: parseInt(encryptedKey.getAttribute("spinCount") || "100000", 10), passwordSalt: fromBase64(encryptedKey.getAttribute("saltValue") || ""), encryptedVerifierHashInput: fromBase64( encryptedKey.getAttribute("encryptedVerifierHashInput") || "" ), encryptedVerifierHashValue: fromBase64( encryptedKey.getAttribute("encryptedVerifierHashValue") || "" ), encryptedKeyValue: fromBase64( encryptedKey.getAttribute("encryptedKeyValue") || "" ) }; } // --------------------------------------------------------------------------- // MS-OFFCRYPTO key derivation (Agile Encryption) // --------------------------------------------------------------------------- /** * Derive an intermediate key from a password using the MS-OFFCRYPTO Agile * Encryption key derivation algorithm ([MS-OFFCRYPTO] 2.3.4.7). * * Returns the derived key bytes (keyBits / 8 bytes long). */ export async function deriveKeyFromPassword( password: string, info: EncryptionInfo ): Promise { const keyBytes = info.keyBits / 8; // Step 1: H0 = SHA-512(salt + UTF16LE(password)) const passwordBytes = encodeUtf16Le(password); const h0Input = concat(info.passwordSalt, passwordBytes); let hash = await sha512(h0Input); // Step 2: iterate spinCount times // Hn = SHA-512(iterator_bytes(n) + H(n-1)) for (let i = 0; i < info.spinCount; i++) { const iteratorBytes = uint32LeBytes(i); hash = await sha512(concat(iteratorBytes, hash)); } // Step 3: Hfinal = SHA-512(iterator_bytes(0x00000000) + H(spinCount)) // + 0x00000000 block key bytes appended const blockKey = uint32LeBytes(0); const hFinal = await sha512(concat(blockKey, hash)); // Truncate to keyBytes return hFinal.slice(0, keyBytes); } /** * Verify that the given password is correct by decrypting the verifier * hash input and value. * * Returns `true` if the password is correct. */ export async function verifyPassword( password: string, info: EncryptionInfo ): Promise { try { const intermediateKey = await deriveKeyFromPassword(password, info); const key = intermediateKey.slice(0, info.keyBits / 8); // Decrypt the verifier hash input const verifierInput = await aesCbcDecrypt( info.encryptedVerifierHashInput, key ); // Compute SHA-512 of the decrypted input const verifierHash = await sha512(verifierInput); // Decrypt the verifier hash value const decryptedVerifierHash = await aesCbcDecrypt( info.encryptedVerifierHashValue, key ); // Compare: the decrypted verifier hash should be a prefix of the // computed hash (truncated to hashSize) const expected = decryptedVerifierHash; const actual = verifierHash.slice(0, expected.length); return constantTimeEqual(expected, actual); } catch { return false; } } /** * Decrypt the data encryption key using the password-derived intermediate key. */ export async function decryptDataKey( password: string, info: EncryptionInfo ): Promise { const intermediateKey = await deriveKeyFromPassword(password, info); const key = intermediateKey.slice(0, info.keyBits / 8); return aesCbcDecrypt(info.encryptedKeyValue, key); } /** * Decrypt a block of data (an encrypted object's property set) using the * data encryption key. The ciphertext is expected to have a 16-byte IV * prepended (standard AES-CBC with PKCS#7 padding). */ export async function decryptObjectData( ciphertext: Uint8Array, dataKey: Uint8Array ): Promise { return aesCbcDecrypt(ciphertext, dataKey); } // --------------------------------------------------------------------------- // Crypto helpers // --------------------------------------------------------------------------- async function sha512(data: Uint8Array): Promise { // Use Web Crypto API (available in both browsers and Node.js 18+) if (typeof globalThis.crypto !== "undefined" && globalThis.crypto.subtle) { const hashBuffer = await globalThis.crypto.subtle.digest("SHA-512", data); return new Uint8Array(hashBuffer); } // Fallback: Node.js crypto // eslint-disable-next-line @typescript-eslint/no-var-requires const { createHash } = require("crypto"); return createHash("sha512").update(data).digest(); } async function aesCbcDecrypt( ciphertext: Uint8Array, key: Uint8Array ): Promise { // First 16 bytes are the IV, rest is the ciphertext if (ciphertext.length < 16) { throw new Error("Ciphertext too short (no IV)"); } const iv = ciphertext.slice(0, 16); const data = ciphertext.slice(16); if (typeof globalThis.crypto !== "undefined" && globalThis.crypto.subtle) { const cryptoKey = await globalThis.crypto.subtle.importKey( "raw", key, { name: "AES-CBC" }, false, ["decrypt"] ); const decrypted = await globalThis.crypto.subtle.decrypt( { name: "AES-CBC", iv }, cryptoKey, data ); // Strip PKCS#7 padding const result = new Uint8Array(decrypted); return stripPkcs7Padding(result); } // Fallback: Node.js crypto // eslint-disable-next-line @typescript-eslint/no-var-requires const { createDecipheriv } = require("crypto"); const decipher = createDecipheriv("aes-256-cbc", key, iv); const decrypted = Buffer.concat([ decipher.update(Buffer.from(data)), decipher.final() ]); return stripPkcs7Padding(new Uint8Array(decrypted)); } function stripPkcs7Padding(data: Uint8Array): Uint8Array { if (data.length === 0) return data; const padLen = data[data.length - 1]; if (padLen === 0 || padLen > 16 || padLen > data.length) return data; // Verify padding for (let i = data.length - padLen; i < data.length; i++) { if (data[i] !== padLen) return data; // Invalid padding, return as-is } return data.slice(0, data.length - padLen); } function encodeUtf16Le(str: string): Uint8Array { const bytes = new Uint8Array(str.length * 2); for (let i = 0; i < str.length; i++) { const code = str.charCodeAt(i); bytes[i * 2] = code & 0xff; bytes[i * 2 + 1] = (code >> 8) & 0xff; } return bytes; } function uint32LeBytes(value: number): Uint8Array { const bytes = new Uint8Array(4); bytes[0] = value & 0xff; bytes[1] = (value >> 8) & 0xff; bytes[2] = (value >> 16) & 0xff; bytes[3] = (value >> 24) & 0xff; return bytes; } function concat(a: Uint8Array, b: Uint8Array): Uint8Array { const result = new Uint8Array(a.length + b.length); result.set(a, 0); result.set(b, a.length); return result; } function constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean { if (a.length !== b.length) return false; let diff = 0; for (let i = 0; i < a.length; i++) { diff |= a[i] ^ b[i]; } return diff === 0; }