From 2a20f1d1076e7b74410869a1d6df674d77c54698 Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Wed, 2 Sep 2026 14:29:08 +0000 Subject: [PATCH] feat: a .docx can be saved encrypted too, and an encrypted one opened MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Writes agile encryption with Word's own parameters (AES-256-CBC in 4096-byte segments, 100000 SHA-512 rounds, HMAC integrity) into a compound file built here — cfb.ts, MiniFAT and DIFAT included. Reads that and the older standard encryption LibreOffice writes, whose package is AES-ECB. Verified both ways against LibreOffice. --- src/lib/crypto/cfb.ts | 259 ++++++++++++++++++++++++++++ src/lib/crypto/ooxml.ts | 247 ++++++++++++++++++++++++++ src/lib/crypto/protect.ts | 11 +- tests/unit/cfb-container.test.ts | 42 +++++ tests/unit/ooxml-encryption.test.ts | 122 +++++++++++++ 5 files changed, 680 insertions(+), 1 deletion(-) create mode 100644 src/lib/crypto/cfb.ts create mode 100644 src/lib/crypto/ooxml.ts create mode 100644 tests/unit/cfb-container.test.ts create mode 100644 tests/unit/ooxml-encryption.test.ts diff --git a/src/lib/crypto/cfb.ts b/src/lib/crypto/cfb.ts new file mode 100644 index 0000000..6f17aa5 --- /dev/null +++ b/src/lib/crypto/cfb.ts @@ -0,0 +1,259 @@ +// The compound file an encrypted OOXML document is wrapped in (MS-CFB). Only what +// that wrapper needs: a flat set of streams, no storages, no timestamps. + +import { encryptionError, UNSUPPORTED_ENCRYPTION } from './errors'; + +const SIGNATURE = [0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]; +const SECTOR = 512; +const MINI_SECTOR = 64; +// A stream shorter than this lives in the mini stream, which is itself one stream. +const MINI_CUTOFF = 4096; +const PER_SECTOR = SECTOR / 4; +const DIR_PER_SECTOR = SECTOR / 128; +const HEADER_DIFAT = 109; +const DIFAT_PER_SECTOR = PER_SECTOR - 1; + +const DIFSECT = 0xfffffffc; +const FATSECT = 0xfffffffd; +const ENDOFCHAIN = 0xfffffffe; +const FREESECT = 0xffffffff; + +type Stream = [name: string, data: Uint8Array]; + +function chunks(data: Uint8Array, size: number): number { + return Math.ceil(data.length / size); +} + +function padTo(data: Uint8Array, size: number): Uint8Array { + const out = new Uint8Array(chunks(data, size) * size); + out.set(data); + return out; +} + +// CFB orders directory entries by name length first, then by upper-cased UTF-16. +function compareNames(a: string, b: string): number { + if (a.length !== b.length) return a.length - b.length; + const [x, y] = [a.toUpperCase(), b.toUpperCase()]; + return x < y ? -1 : x > y ? 1 : 0; +} + +export function writeCfb(streams: Stream[]): Uint8Array { + const big = streams.filter(([, d]) => d.length >= MINI_CUTOFF); + const small = streams.filter(([, d]) => d.length > 0 && d.length < MINI_CUTOFF); + + // The mini stream is the small streams laid end to end on mini-sector boundaries. + const miniStart = new Map(); + let miniSectors = 0; + for (const [name, data] of small) { + miniStart.set(name, miniSectors); + miniSectors += chunks(data, MINI_SECTOR); + } + const miniStream = new Uint8Array(miniSectors * MINI_SECTOR); + for (const [name, data] of small) miniStream.set(data, miniStart.get(name)! * MINI_SECTOR); + + const miniFatSectors = Math.ceil(miniSectors / PER_SECTOR) || 0; + const dirSectors = Math.ceil((streams.length + 1) / DIR_PER_SECTOR); + const miniStreamSectors = chunks(miniStream, SECTOR); + const bigSectors = big.reduce((n, [, d]) => n + chunks(d, SECTOR), 0); + const base = bigSectors + miniStreamSectors + miniFatSectors + dirSectors; + + // The FAT describes the file including itself, so its size is a fixed point. + let fatSectors = 1; + let difatSectors = 0; + for (let i = 0; i < 8; i++) { + const total = base + fatSectors + difatSectors; + const nextFat = Math.max(1, Math.ceil(total / PER_SECTOR)); + const nextDifat = nextFat > HEADER_DIFAT ? Math.ceil((nextFat - HEADER_DIFAT) / DIFAT_PER_SECTOR) : 0; + if (nextFat === fatSectors && nextDifat === difatSectors) break; + fatSectors = nextFat; + difatSectors = nextDifat; + } + + const totalSectors = base + fatSectors + difatSectors; + const fat = new Uint32Array(fatSectors * PER_SECTOR).fill(FREESECT); + const file = new Uint8Array(SECTOR * (1 + totalSectors)); + const view = new DataView(file.buffer); + + // Sectors are handed out in the order they are written: big streams, the mini + // stream, the MiniFAT, the directory, then the FAT and its DIFAT. + let next = 0; + const place = (data: Uint8Array): number => { + const start = next; + const padded = padTo(data, SECTOR); + file.set(padded, SECTOR * (1 + start)); + const count = padded.length / SECTOR; + for (let i = 0; i < count; i++) fat[start + i] = i === count - 1 ? ENDOFCHAIN : start + i + 1; + next += count; + return count ? start : ENDOFCHAIN; + }; + + const bigStart = new Map(); + for (const [name, data] of big) bigStart.set(name, place(data)); + const miniStreamStart = miniStream.length ? place(miniStream) : ENDOFCHAIN; + + const miniFat = new Uint32Array(miniFatSectors * PER_SECTOR).fill(FREESECT); + let mini = 0; + for (const [, data] of small) { + const count = chunks(data, MINI_SECTOR); + for (let i = 0; i < count; i++) miniFat[mini + i] = i === count - 1 ? ENDOFCHAIN : mini + i + 1; + mini += count; + } + const miniFatStart = miniFatSectors ? place(new Uint8Array(miniFat.buffer)) : ENDOFCHAIN; + + const directory = new Uint8Array(dirSectors * SECTOR); + const dirView = new DataView(directory.buffer); + const order = streams.map((_, i) => i + 1).sort((a, b) => compareNames(streams[a - 1][0], streams[b - 1][0])); + // A balanced tree over the sorted entries; every node black, which readers accept. + const build = (lo: number, hi: number): number => { + if (lo > hi) return FREESECT; + const mid = (lo + hi) >> 1; + const at = order[mid]; + dirView.setUint32(at * 128 + 68, build(lo, mid - 1), true); + dirView.setUint32(at * 128 + 72, build(mid + 1, hi), true); + return at; + }; + + const entry = (index: number, name: string, type: number, start: number, size: number) => { + const off = index * 128; + for (let i = 0; i < name.length; i++) dirView.setUint16(off + i * 2, name.charCodeAt(i), true); + dirView.setUint16(off + 64, (name.length + 1) * 2, true); + directory[off + 66] = type; + directory[off + 67] = 1; // black + dirView.setUint32(off + 76, FREESECT, true); // no child until the root gets one + dirView.setUint32(off + 116, start, true); + dirView.setBigUint64(off + 120, BigInt(size), true); + }; + + streams.forEach(([name, data], i) => { + const start = data.length === 0 ? ENDOFCHAIN + : data.length >= MINI_CUTOFF ? bigStart.get(name)! + : miniStart.get(name)!; + entry(i + 1, name, 2, start, data.length); + }); + // The root entry owns the mini stream, and its size is the mini stream's. + entry(0, 'Root Entry', 5, miniStreamStart, miniStream.length); + dirView.setUint32(68, FREESECT, true); + dirView.setUint32(72, FREESECT, true); + dirView.setUint32(76, build(0, order.length - 1), true); + for (let i = streams.length + 1; i < dirSectors * DIR_PER_SECTOR; i++) { + dirView.setUint32(i * 128 + 68, FREESECT, true); + dirView.setUint32(i * 128 + 72, FREESECT, true); + dirView.setUint32(i * 128 + 76, FREESECT, true); + } + const dirStart = place(directory); + + const fatStart = next; + for (let i = 0; i < fatSectors; i++) fat[fatStart + i] = FATSECT; + const difatStart = fatStart + fatSectors; + for (let i = 0; i < difatSectors; i++) fat[difatStart + i] = DIFSECT; + file.set(new Uint8Array(fat.buffer), SECTOR * (1 + fatStart)); + + // The DIFAT lists the FAT sectors: the first 109 in the header, the rest in a chain. + for (let i = 0; i < difatSectors; i++) { + const off = SECTOR * (1 + difatStart + i); + for (let j = 0; j < DIFAT_PER_SECTOR; j++) { + const fatIndex = HEADER_DIFAT + i * DIFAT_PER_SECTOR + j; + view.setUint32(off + j * 4, fatIndex < fatSectors ? fatStart + fatIndex : FREESECT, true); + } + view.setUint32(off + SECTOR - 4, i + 1 < difatSectors ? difatStart + i + 1 : ENDOFCHAIN, true); + } + + file.set(SIGNATURE); + view.setUint16(24, 0x003e, true); + view.setUint16(26, 3, true); + view.setUint16(28, 0xfffe, true); + view.setUint16(30, 9, true); + view.setUint16(32, 6, true); + view.setUint32(44, fatSectors, true); + view.setUint32(48, dirStart, true); + view.setUint32(56, MINI_CUTOFF, true); + view.setUint32(60, miniFatStart, true); + view.setUint32(64, miniFatSectors, true); + view.setUint32(68, difatSectors ? difatStart : ENDOFCHAIN, true); + view.setUint32(72, difatSectors, true); + for (let i = 0; i < HEADER_DIFAT; i++) { + view.setUint32(76 + i * 4, i < fatSectors ? fatStart + i : FREESECT, true); + } + return file; +} + +export function isCfb(bytes: Uint8Array): boolean { + return bytes.length >= 512 && SIGNATURE.every((b, i) => bytes[i] === b); +} + +export function readCfb(bytes: Uint8Array): Map { + if (!isCfb(bytes)) throw encryptionError(UNSUPPORTED_ENCRYPTION); + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const sector = 1 << view.getUint16(30, true); + const miniSector = 1 << view.getUint16(32, true); + const cutoff = view.getUint32(56, true); + const perSector = sector / 4; + const at = (index: number): number => sector * (1 + index); + const read = (index: number): Uint8Array => bytes.subarray(at(index), at(index) + sector); + + // The DIFAT names the FAT sectors: 109 in the header, the rest in their own chain. + const fatSectorIds: number[] = []; + for (let i = 0; i < 109; i++) { + const id = view.getUint32(76 + i * 4, true); + if (id < FREESECT - 3) fatSectorIds.push(id); + } + let difat = view.getUint32(68, true); + for (let guard = 0; difat < FREESECT - 3 && guard < 1024; guard++) { + const base = at(difat); + for (let j = 0; j < perSector - 1; j++) { + const id = view.getUint32(base + j * 4, true); + if (id < FREESECT - 3) fatSectorIds.push(id); + } + difat = view.getUint32(base + sector - 4, true); + } + + const fat = new Uint32Array(fatSectorIds.length * perSector); + fatSectorIds.forEach((id, i) => { + for (let j = 0; j < perSector; j++) fat[i * perSector + j] = view.getUint32(at(id) + j * 4, true); + }); + + const chain = (start: number): number[] => { + const out: number[] = []; + for (let s = start; s < FREESECT - 3 && out.length <= fat.length; s = fat[s]) out.push(s); + return out; + }; + const streamOf = (start: number, size: number): Uint8Array => { + const out = new Uint8Array(chain(start).length * sector); + chain(start).forEach((s, i) => out.set(read(s), i * sector)); + return out.subarray(0, size); + }; + + const directory = streamOf(view.getUint32(48, true), Number.MAX_SAFE_INTEGER); + const dir = new DataView(directory.buffer, directory.byteOffset, directory.byteLength); + const count = Math.floor(directory.length / 128); + + let miniStream: Uint8Array = new Uint8Array(0); + for (let i = 0; i < count; i++) { + if (directory[i * 128 + 66] === 5) { + miniStream = streamOf(dir.getUint32(i * 128 + 116, true), Number(dir.getBigUint64(i * 128 + 120, true))); + break; + } + } + const miniFat = new Uint32Array(streamOf(view.getUint32(60, true), Number.MAX_SAFE_INTEGER).buffer.slice(0)); + const miniOf = (start: number, size: number): Uint8Array => { + const out = new Uint8Array(Math.ceil(size / miniSector) * miniSector); + let s = start; + for (let i = 0; i * miniSector < size && s < FREESECT - 3; i++, s = miniFat[s]) { + out.set(miniStream.subarray(s * miniSector, (s + 1) * miniSector), i * miniSector); + } + return out.subarray(0, size); + }; + + const streams = new Map(); + for (let i = 0; i < count; i++) { + const off = i * 128; + if (directory[off + 66] !== 2) continue; + const nameLength = Math.max(0, dir.getUint16(off + 64, true) / 2 - 1); + let name = ''; + for (let j = 0; j < nameLength; j++) name += String.fromCharCode(dir.getUint16(off + j * 2, true)); + const size = Number(dir.getBigUint64(off + 120, true)); + const start = dir.getUint32(off + 116, true); + streams.set(name, size === 0 ? new Uint8Array(0) : size < cutoff ? miniOf(start, size) : streamOf(start, size)); + } + return streams; +} diff --git a/src/lib/crypto/ooxml.ts b/src/lib/crypto/ooxml.ts new file mode 100644 index 0000000..baad6d8 --- /dev/null +++ b/src/lib/crypto/ooxml.ts @@ -0,0 +1,247 @@ +// OOXML document encryption (MS-OFFCRYPTO). Written: agile encryption, what Word +// 2010 and later write. Read: that plus the older standard encryption, which is what +// LibreOffice writes for .docx. + +import { cbc, ecb } from '@noble/ciphers/aes.js'; +import { hmac } from '@noble/hashes/hmac.js'; +import { sha256, sha384, sha512 } from '@noble/hashes/sha2.js'; +import type { CHash } from '@noble/hashes/utils.js'; +import { sha1 } from '@noble/hashes/legacy.js'; +import { readCfb, writeCfb } from './cfb'; +import { encryptionError, UNSUPPORTED_ENCRYPTION, WRONG_PASSWORD } from './errors'; + +type Hash = CHash; + +const INFO = 'EncryptionInfo'; +const PACKAGE = 'EncryptedPackage'; +// Every segment of the package is encrypted under its own initialisation vector. +const SEGMENT = 4096; + +// The fixed block keys of the agile scheme. +const BLOCK_VERIFIER_INPUT = [0xfe, 0xa7, 0xd2, 0x76, 0x3b, 0x4b, 0x9e, 0x79]; +const BLOCK_VERIFIER_VALUE = [0xd7, 0xaa, 0x0f, 0x6d, 0x30, 0x61, 0x34, 0x4e]; +const BLOCK_KEY_VALUE = [0x14, 0x6e, 0x0b, 0xe7, 0xab, 0xac, 0xd0, 0xd6]; +const BLOCK_HMAC_KEY = [0x5f, 0xb2, 0xad, 0x01, 0x0c, 0xb9, 0xe1, 0xf6]; +const BLOCK_HMAC_VALUE = [0xa0, 0x67, 0x7f, 0x02, 0xb2, 0x2c, 0x84, 0x33]; + +// What we write, matching Word's own choice so a byte comparison stays meaningful. +const SPIN_COUNT = 100_000; +const KEY_BYTES = 32; +const BLOCK_SIZE = 16; +const SALT_SIZE = 16; + +const HASHES: Record = { SHA512: sha512, SHA384: sha384, SHA256: sha256, SHA1: sha1 }; + +const concat = (...parts: Uint8Array[]): Uint8Array => { + const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0)); + let at = 0; + for (const p of parts) { out.set(p, at); at += p.length; } + return out; +}; + +const le32 = (value: number): Uint8Array => { + const out = new Uint8Array(4); + new DataView(out.buffer).setUint32(0, value, true); + return out; +}; + +// Passwords go into the hash as UTF-16LE, the encoding the format was defined in. +const utf16 = (text: string): Uint8Array => { + const out = new Uint8Array(text.length * 2); + const view = new DataView(out.buffer); + for (let i = 0; i < text.length; i++) view.setUint16(i * 2, text.charCodeAt(i), true); + return out; +}; + +// A derived value is cut to size, or filled up with 0x36 where the hash is shorter. +function fit(value: Uint8Array, size: number): Uint8Array { + if (value.length >= size) return value.subarray(0, size); + const out = new Uint8Array(size).fill(0x36); + out.set(value); + return out; +} + +function blocks(data: Uint8Array, size: number): Uint8Array { + if (data.length % size === 0) return data; + const out = new Uint8Array(Math.ceil(data.length / size) * size); + out.set(data); + return out; +} + +const b64 = (data: Uint8Array): string => btoa(String.fromCharCode(...data)); +const unb64 = (text: string): Uint8Array => Uint8Array.from(atob(text), (c) => c.charCodeAt(0)); + +const encryptCbc = (key: Uint8Array, iv: Uint8Array, data: Uint8Array): Uint8Array => + cbc(key, iv, { disablePadding: true }).encrypt(blocks(data, BLOCK_SIZE)); +const decryptCbc = (key: Uint8Array, iv: Uint8Array, data: Uint8Array): Uint8Array => + cbc(key, iv, { disablePadding: true }).decrypt(blocks(data, BLOCK_SIZE)); + +// The password is stretched by a long chain of hashes, one per iteration. +function spin(hash: Hash, salt: Uint8Array, password: string, spinCount: number): Uint8Array { + let h = hash(concat(salt, utf16(password))); + for (let i = 0; i < spinCount; i++) h = hash(concat(le32(i), h)); + return h; +} + +const derive = (hash: Hash, base: Uint8Array, block: number[], size: number): Uint8Array => + fit(hash(concat(base, Uint8Array.from(block))), size); + +type Agile = { + hash: Hash; + keySalt: Uint8Array; + blockSize: number; + keyBytes: number; + hashSize: number; + spinCount: number; + passwordSalt: Uint8Array; + verifierInput: Uint8Array; + verifierValue: Uint8Array; + keyValue: Uint8Array; +}; + +function parseAgile(info: Uint8Array): Agile { + const xml = new TextDecoder().decode(info.subarray(8)); + const doc = new DOMParser().parseFromString(xml, 'application/xml'); + const keyData = doc.getElementsByTagName('keyData').item(0); + const encryptedKey = doc.getElementsByTagNameNS( + 'http://schemas.microsoft.com/office/2006/keyEncryptor/password', 'encryptedKey').item(0); + if (!keyData || !encryptedKey) throw encryptionError(UNSUPPORTED_ENCRYPTION); + const hash = HASHES[encryptedKey.getAttribute('hashAlgorithm') ?? 'SHA512']; + if (!hash || encryptedKey.getAttribute('cipherAlgorithm') !== 'AES') { + throw encryptionError(UNSUPPORTED_ENCRYPTION); + } + return { + hash, + keySalt: unb64(keyData.getAttribute('saltValue') ?? ''), + blockSize: Number(keyData.getAttribute('blockSize') ?? BLOCK_SIZE), + keyBytes: Number(encryptedKey.getAttribute('keyBits') ?? 256) / 8, + hashSize: Number(encryptedKey.getAttribute('hashSize') ?? 64), + spinCount: Number(encryptedKey.getAttribute('spinCount') ?? SPIN_COUNT), + passwordSalt: unb64(encryptedKey.getAttribute('saltValue') ?? ''), + verifierInput: unb64(encryptedKey.getAttribute('encryptedVerifierHashInput') ?? ''), + verifierValue: unb64(encryptedKey.getAttribute('encryptedVerifierHashValue') ?? ''), + keyValue: unb64(encryptedKey.getAttribute('encryptedKeyValue') ?? ''), + }; +} + +// The package key itself, once the password has been shown to be the right one. +function agileSecret(agile: Agile, password: string): Uint8Array { + const base = spin(agile.hash, agile.passwordSalt, password, agile.spinCount); + const key = (block: number[]) => derive(agile.hash, base, block, agile.keyBytes); + const input = decryptCbc(key(BLOCK_VERIFIER_INPUT), agile.passwordSalt, agile.verifierInput); + const expected = agile.hash(input.subarray(0, agile.blockSize)); + const actual = decryptCbc(key(BLOCK_VERIFIER_VALUE), agile.passwordSalt, agile.verifierValue); + if (!expected.every((b, i) => b === actual[i])) throw encryptionError(WRONG_PASSWORD); + return decryptCbc(key(BLOCK_KEY_VALUE), agile.passwordSalt, agile.keyValue).subarray(0, agile.keyBytes); +} + +function segmentIv(agile: Agile, index: number): Uint8Array { + return fit(agile.hash(concat(agile.keySalt, le32(index))), agile.blockSize); +} + +function decryptAgilePackage(agile: Agile, secret: Uint8Array, stream: Uint8Array): Uint8Array { + const size = Number(new DataView(stream.buffer, stream.byteOffset, stream.byteLength).getBigUint64(0, true)); + const body = stream.subarray(8); + const out = new Uint8Array(Math.ceil(body.length / BLOCK_SIZE) * BLOCK_SIZE); + for (let at = 0, i = 0; at < body.length; at += SEGMENT, i++) { + out.set(decryptCbc(secret, segmentIv(agile, i), body.subarray(at, at + SEGMENT)), at); + } + return out.subarray(0, size); +} + +export async function encryptOoxml(bytes: Uint8Array, password: string): Promise { + const hash = sha512; + const keySalt = crypto.getRandomValues(new Uint8Array(SALT_SIZE)); + const passwordSalt = crypto.getRandomValues(new Uint8Array(SALT_SIZE)); + const secret = crypto.getRandomValues(new Uint8Array(KEY_BYTES)); + const verifier = crypto.getRandomValues(new Uint8Array(BLOCK_SIZE)); + const hmacKey = crypto.getRandomValues(new Uint8Array(hash.outputLen)); + + const base = spin(hash, passwordSalt, password, SPIN_COUNT); + const key = (block: number[]) => derive(hash, base, block, KEY_BYTES); + const agile: Agile = { + hash, keySalt, blockSize: BLOCK_SIZE, keyBytes: KEY_BYTES, hashSize: hash.outputLen, + spinCount: SPIN_COUNT, passwordSalt, verifierInput: new Uint8Array(0), + verifierValue: new Uint8Array(0), keyValue: new Uint8Array(0), + }; + + const encryptedPackage = new Uint8Array(8 + Math.ceil(bytes.length / BLOCK_SIZE) * BLOCK_SIZE); + new DataView(encryptedPackage.buffer).setBigUint64(0, BigInt(bytes.length), true); + for (let at = 0, i = 0; at < bytes.length; at += SEGMENT, i++) { + encryptedPackage.set(encryptCbc(secret, segmentIv(agile, i), bytes.subarray(at, at + SEGMENT)), 8 + at); + } + + const hmacIv = (block: number[]) => derive(hash, keySalt, block, BLOCK_SIZE); + const encryptedHmacKey = encryptCbc(secret, hmacIv(BLOCK_HMAC_KEY), hmacKey); + const encryptedHmacValue = encryptCbc(secret, hmacIv(BLOCK_HMAC_VALUE), + hmac(hash, hmacKey, encryptedPackage)); + + const info = ` +`; + + const header = new Uint8Array(8); + const view = new DataView(header.buffer); + view.setUint16(0, 4, true); // major: agile + view.setUint16(2, 4, true); // minor + view.setUint32(4, 0x40, true); + + // ponytail: no \x06DataSpaces streams — LibreOffice writes none either and its files + // open elsewhere; add them if a reader ever refuses ours. + return writeCfb([ + [INFO, concat(header, new TextEncoder().encode(info))], + [PACKAGE, encryptedPackage], + ]); +} + +// Standard encryption, the older scheme: one AES-ECB key from an iterated SHA-1. +function decryptStandard(info: Uint8Array, stream: Uint8Array, password: string): Uint8Array { + const view = new DataView(info.buffer, info.byteOffset, info.byteLength); + const headerSize = view.getUint32(8, true); + const algId = view.getUint32(12 + 8, true); + const keyBytes = view.getUint32(12 + 16, true) / 8; + // 0x660E/0x660F/0x6610 are AES-128/192/256; anything else (RC4) we do not read. + if (algId < 0x660e || algId > 0x6610) throw encryptionError(UNSUPPORTED_ENCRYPTION); + const verifierAt = 12 + headerSize; + const saltSize = view.getUint32(verifierAt, true); + const salt = info.subarray(verifierAt + 4, verifierAt + 4 + saltSize); + const encryptedVerifier = info.subarray(verifierAt + 4 + saltSize, verifierAt + 20 + saltSize); + const hashSize = view.getUint32(verifierAt + 20 + saltSize, true); + const encryptedHash = info.subarray(verifierAt + 24 + saltSize, verifierAt + 24 + saltSize + 32); + + let h = sha1(concat(salt, utf16(password))); + for (let i = 0; i < 50_000; i++) h = sha1(concat(le32(i), h)); + h = sha1(concat(h, le32(0))); + const block = (pad: number) => { + const buf = new Uint8Array(64).fill(pad); + for (let i = 0; i < h.length; i++) buf[i] = h[i] ^ pad; + return sha1(buf); + }; + const key = concat(block(0x36), block(0x5c)).subarray(0, keyBytes); + + const cipher = ecb(key, { disablePadding: true }); + const verifier = cipher.decrypt(encryptedVerifier); + const expected = sha1(verifier); + const actual = cipher.decrypt(encryptedHash).subarray(0, Math.min(hashSize, 20)); + if (!expected.every((b, i) => b === actual[i])) throw encryptionError(WRONG_PASSWORD); + + const size = Number(new DataView(stream.buffer, stream.byteOffset, stream.byteLength).getBigUint64(0, true)); + return cipher.decrypt(blocks(stream.subarray(8), BLOCK_SIZE)).subarray(0, size); +} + +export async function decryptOoxml(bytes: Uint8Array, password: string): Promise { + const streams = readCfb(bytes); + const info = streams.get(INFO); + const stream = streams.get(PACKAGE); + if (!info || !stream) throw encryptionError(UNSUPPORTED_ENCRYPTION); + const view = new DataView(info.buffer, info.byteOffset, info.byteLength); + const major = view.getUint16(0, true); + const minor = view.getUint16(2, true); + if (major === 4 && minor === 4) { + const agile = parseAgile(info); + return decryptAgilePackage(agile, agileSecret(agile, password), stream); + } + if (minor === 2 && (major === 2 || major === 3 || major === 4)) { + return decryptStandard(info, stream, password); + } + throw encryptionError(UNSUPPORTED_ENCRYPTION); +} diff --git a/src/lib/crypto/protect.ts b/src/lib/crypto/protect.ts index d88fc5d..58a5170 100644 --- a/src/lib/crypto/protect.ts +++ b/src/lib/crypto/protect.ts @@ -48,10 +48,15 @@ function containerOf(bytes: Uint8Array): Protection { } export async function encryptPackage(bytes: Uint8Array, password: string): Promise { - if (containerOf(bytes) === 'odf') { + const container = containerOf(bytes); + if (container === 'odf') { const { encryptOdf } = await import('./odf'); return encryptOdf(bytes, password); } + if (container === 'ooxml') { + const { encryptOoxml } = await import('./ooxml'); + return encryptOoxml(bytes, password); + } throw encryptionError(UNSUPPORTED_ENCRYPTION); } @@ -60,5 +65,9 @@ export async function decryptPackage(bytes: Uint8Array, password: string): Promi const { decryptOdf } = await import('./odf'); return decryptOdf(bytes, password); } + if (isCfb(bytes)) { + const { decryptOoxml } = await import('./ooxml'); + return decryptOoxml(bytes, password); + } throw encryptionError(UNSUPPORTED_ENCRYPTION); } diff --git a/tests/unit/cfb-container.test.ts b/tests/unit/cfb-container.test.ts new file mode 100644 index 0000000..0f46551 --- /dev/null +++ b/tests/unit/cfb-container.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect } from 'vitest'; +import { readCfb, writeCfb, isCfb } from '../../src/lib/crypto/cfb'; + +const bytes = (n: number, seed = 1) => + Uint8Array.from({ length: n }, (_, i) => (i * 31 + seed) & 0xff); + +describe('compound file container', () => { + it('round-trips a mini stream and a big one in the same file', () => { + const small = bytes(224); + const large = bytes(9000, 7); + const streams = readCfb(writeCfb([['EncryptionInfo', small], ['EncryptedPackage', large]])); + expect(streams.get('EncryptionInfo')).toEqual(small); + expect(streams.get('EncryptedPackage')).toEqual(large); + }); + + it('writes a header a reader recognizes', () => { + const file = writeCfb([['EncryptionInfo', bytes(224)]]); + expect(isCfb(file)).toBe(true); + const view = new DataView(file.buffer); + expect(view.getUint16(26, true)).toBe(3); // major version + expect(view.getUint16(30, true)).toBe(9); // 512-byte sectors + expect(view.getUint32(56, true)).toBe(4096); // mini stream cutoff + expect(file.length % 512).toBe(0); + }); + + it('survives a stream past the first FAT sector', () => { + // Over 64 KiB needs more than one FAT sector's worth of chain entries. + const large = bytes(300_000, 3); + expect(readCfb(writeCfb([['EncryptedPackage', large]])).get('EncryptedPackage')).toEqual(large); + }); + + it('handles an empty stream and exact sector multiples', () => { + const streams = readCfb(writeCfb([['Empty', new Uint8Array(0)], ['Exact', bytes(4096)]])); + expect(streams.get('Empty')!.length).toBe(0); + expect(streams.get('Exact')).toEqual(bytes(4096)); + }); + + it('refuses anything that is not a compound file', () => { + expect(isCfb(new Uint8Array([0x50, 0x4b, 3, 4]))).toBe(false); + expect(() => readCfb(new Uint8Array([0x50, 0x4b, 3, 4]))).toThrow(); + }); +}); diff --git a/tests/unit/ooxml-encryption.test.ts b/tests/unit/ooxml-encryption.test.ts new file mode 100644 index 0000000..ae6773b --- /dev/null +++ b/tests/unit/ooxml-encryption.test.ts @@ -0,0 +1,122 @@ +import { describe, it, expect } from 'vitest'; +import { zipSync, unzipSync, strToU8, strFromU8 } from 'fflate'; +import { ecb } from '@noble/ciphers/aes.js'; +import { sha1 } from '@noble/hashes/legacy.js'; +import { readCfb, writeCfb } from '../../src/lib/crypto/cfb'; +import { encryptOoxml, decryptOoxml } from '../../src/lib/crypto/ooxml'; +import { isProtected, WRONG_PASSWORD, UNSUPPORTED_ENCRYPTION } from '../../src/lib/crypto/protect'; + +const PW = 'geheim123'; + +function plainDocx(text = 'Hello'): Uint8Array { + return zipSync({ + '[Content_Types].xml': [strToU8(''), { level: 6 }], + '_rels/.rels': [strToU8(''), { level: 6 }], + 'word/document.xml': [strToU8(`${text}`), { level: 6 }], + }); +} + +// The older standard encryption, which is what LibreOffice writes for .docx: one +// AES-ECB key from an iterated SHA-1, and a verifier instead of an authenticated mode. +function standardEncrypted(password: string, plain: Uint8Array): Uint8Array { + const concat = (...parts: Uint8Array[]) => { + const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0)); + let at = 0; + for (const p of parts) { out.set(p, at); at += p.length; } + return out; + }; + const le32 = (v: number) => { const b = new Uint8Array(4); new DataView(b.buffer).setUint32(0, v, true); return b; }; + const utf16 = (t: string) => { const b = new Uint8Array(t.length * 2); const d = new DataView(b.buffer); for (let i = 0; i < t.length; i++) d.setUint16(i * 2, t.charCodeAt(i), true); return b; }; + const salt = new Uint8Array(16).fill(9); + let h = sha1(concat(salt, utf16(password))); + for (let i = 0; i < 50_000; i++) h = sha1(concat(le32(i), h)); + h = sha1(concat(h, le32(0))); + const block = (pad: number) => { const b = new Uint8Array(64).fill(pad); for (let i = 0; i < h.length; i++) b[i] = h[i] ^ pad; return sha1(b); }; + const key = concat(block(0x36), block(0x5c)).subarray(0, 32); + const encrypt = (data: Uint8Array) => ecb(key, { disablePadding: true }).encrypt(data); + + const verifier = new Uint8Array(16).fill(4); + const info = new Uint8Array(8 + 4 + 32 + 4 + 16 + 16 + 4 + 32); + const view = new DataView(info.buffer); + view.setUint16(0, 3, true); view.setUint16(2, 2, true); view.setUint32(4, 0x24, true); + view.setUint32(8, 32, true); // header size + view.setUint32(12 + 8, 0x6610, true); // AES-256 + view.setUint32(12 + 16, 256, true); // key size in bits + const at = 12 + 32; + view.setUint32(at, 16, true); + info.set(salt, at + 4); + info.set(encrypt(verifier), at + 20); + view.setUint32(at + 36, 20, true); + info.set(encrypt(new Uint8Array(32).map((_, i) => sha1(verifier)[i] ?? 0)), at + 40); + + const padded = new Uint8Array(Math.ceil(plain.length / 16) * 16); + padded.set(plain); + const stream = new Uint8Array(8 + padded.length); + new DataView(stream.buffer).setBigUint64(0, BigInt(plain.length), true); + stream.set(encrypt(padded), 8); + return writeCfb([['EncryptionInfo', info], ['EncryptedPackage', stream]]); +} + +describe('OOXML standard encryption', () => { + it('reads a package encrypted the older way', async () => { + const plain = plainDocx('Standard'); + const files = unzipSync(await decryptOoxml(standardEncrypted(PW, plain), PW)); + expect(strFromU8(files['word/document.xml'])).toContain('Standard'); + }); + + it('rejects a wrong password by its verifier', async () => { + await expect(decryptOoxml(standardEncrypted(PW, plainDocx()), 'falsch')).rejects.toThrow(WRONG_PASSWORD); + }); +}); + +describe('OOXML agile encryption', () => { + it('round-trips a package', async () => { + const encrypted = await encryptOoxml(plainDocx('Round trip'), PW); + const files = unzipSync(await decryptOoxml(encrypted, PW)); + expect(strFromU8(files['word/document.xml'])).toContain('Round trip'); + }); + + it('writes the two streams a reader looks for', async () => { + const streams = readCfb(await encryptOoxml(plainDocx(), PW)); + expect([...streams.keys()].sort()).toEqual(['EncryptedPackage', 'EncryptionInfo']); + const info = streams.get('EncryptionInfo')!; + const view = new DataView(info.buffer, info.byteOffset, info.byteLength); + expect(view.getUint16(0, true)).toBe(4); // agile + expect(view.getUint16(2, true)).toBe(4); + const xml = new TextDecoder().decode(info.subarray(8)); + expect(xml).toContain('spinCount="100000"'); + expect(xml).toContain('hashAlgorithm="SHA512"'); + expect(xml).toContain('cipherChaining="ChainingModeCBC"'); + expect(xml).toContain('keyBits="256"'); + expect(xml).toContain(' { + const encrypted = await encryptOoxml(plainDocx(), PW); + await expect(decryptOoxml(encrypted, 'falsch')).rejects.toThrow(WRONG_PASSWORD); + }); + + it('survives a package longer than one segment', async () => { + const big = plainDocx('x'.repeat(20_000)); + expect(await decryptOoxml(await encryptOoxml(big, PW), PW)).toEqual(big); + }); + + it('refuses an encryption version it cannot read', async () => { + const encrypted = await encryptOoxml(plainDocx(), PW); + // RC4 CryptoAPI announces itself as version 4.2 with an RC4 algorithm id. + const info = readCfb(encrypted).get('EncryptionInfo')!; + new DataView(info.buffer, info.byteOffset, info.byteLength).setUint16(2, 3, true); + const { writeCfb } = await import('../../src/lib/crypto/cfb'); + const broken = writeCfb([['EncryptionInfo', info], ['EncryptedPackage', readCfb(encrypted).get('EncryptedPackage')!]]); + await expect(decryptOoxml(broken, PW)).rejects.toThrow(UNSUPPORTED_ENCRYPTION); + }); + + it('is seen as protected', async () => { + expect(isProtected(await encryptOoxml(plainDocx(), PW))).toBe('ooxml'); + expect(isProtected(plainDocx())).toBe(null); + }); +});