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:
1 parent
5856de6c2d
commit
2a20f1d107
5 files changed
+680
-1
No files matched your search
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user