feat: a .docx can be saved encrypted too, and an encrypted one opened

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.
This commit is contained in:
Steffen Becker committed 2026-09-04 19:30:04 +02:00
1 parent 5856de6c2d
commit 2a20f1d107
5 files changed
+680 -1

No files matched your search

+259
View File
@@ -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<string, number>();
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<string, number>();
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<string, Uint8Array> {
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<ArrayBuffer> => {
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<ArrayBuffer> = 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<string, Uint8Array>();
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;
}
+247
View File
@@ -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<string, Hash> = { 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<Uint8Array> {
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 = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<encryption xmlns="http://schemas.microsoft.com/office/2006/encryption" xmlns:p="http://schemas.microsoft.com/office/2006/keyEncryptor/password" xmlns:c="http://schemas.microsoft.com/office/2006/keyEncryptor/certificate"><keyData saltSize="${SALT_SIZE}" blockSize="${BLOCK_SIZE}" keyBits="${KEY_BYTES * 8}" hashSize="${hash.outputLen}" cipherAlgorithm="AES" cipherChaining="ChainingModeCBC" hashAlgorithm="SHA512" saltValue="${b64(keySalt)}"/><dataIntegrity encryptedHmacKey="${b64(encryptedHmacKey)}" encryptedHmacValue="${b64(encryptedHmacValue)}"/><keyEncryptors><keyEncryptor uri="http://schemas.microsoft.com/office/2006/keyEncryptor/password"><p:encryptedKey spinCount="${SPIN_COUNT}" saltSize="${SALT_SIZE}" blockSize="${BLOCK_SIZE}" keyBits="${KEY_BYTES * 8}" hashSize="${hash.outputLen}" cipherAlgorithm="AES" cipherChaining="ChainingModeCBC" hashAlgorithm="SHA512" saltValue="${b64(passwordSalt)}" encryptedVerifierHashInput="${b64(encryptCbc(key(BLOCK_VERIFIER_INPUT), passwordSalt, verifier))}" encryptedVerifierHashValue="${b64(encryptCbc(key(BLOCK_VERIFIER_VALUE), passwordSalt, hash(verifier)))}" encryptedKeyValue="${b64(encryptCbc(key(BLOCK_KEY_VALUE), passwordSalt, secret))}"/></keyEncryptor></keyEncryptors></encryption>`;
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<Uint8Array> {
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);
}
+10 -1
View File
@@ -48,10 +48,15 @@ function containerOf(bytes: Uint8Array): Protection {
}
export async function encryptPackage(bytes: Uint8Array, password: string): Promise<Uint8Array> {
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);
}
+42
View File
@@ -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();
});
});
+122
View File
@@ -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('<?xml version="1.0"?><Types/>'), { level: 6 }],
'_rels/.rels': [strToU8('<?xml version="1.0"?><Relationships/>'), { level: 6 }],
'word/document.xml': [strToU8(`<?xml version="1.0"?><w:document><w:body>${text}</w:body></w:document>`), { 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('<dataIntegrity');
// The stream opens with the plain package's length.
const stream = streams.get('EncryptedPackage')!;
const size = new DataView(stream.buffer, stream.byteOffset, stream.byteLength).getBigUint64(0, true);
expect(Number(size)).toBe(plainDocx().length);
});
it('rejects a wrong password', async () => {
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);
});
});