feat: a document can be saved encrypted with a password (ODF)

Writes LibreOffice's whole-package form: AES-256-GCM keyed by Argon2id over
SHA-256 of the password, the deflated package as one encrypted-package entry.
Reads that plus the per-entry AES-CBC/GCM form with PBKDF2 that every earlier
version writes; the nonce sits ahead of the ciphertext, which no manifest says.
Blowfish and unknown algorithms report an unsupported-encryption error.
This commit is contained in:
Steffen Becker committed 2026-09-04 19:30:03 +02:00
1 parent d273c04f9c
commit 4b163e6d10
7 files changed
+501

No files matched your search

+26
View File
@@ -9,6 +9,8 @@
"version": "0.1.0",
"license": "AGPL-3.0-only",
"dependencies": {
"@noble/ciphers": "^2.4.0",
"@noble/hashes": "^2.4.0",
"@tiptap/core": "^3.23.6",
"@tiptap/extension-bold": "^3.23.6",
"@tiptap/extension-bullet-list": "^3.23.6",
@@ -449,6 +451,30 @@
"@emnapi/runtime": "^1.7.1"
}
},
"node_modules/@noble/ciphers": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.4.0.tgz",
"integrity": "sha512-AnjFn0Jv92laAkvMrghlFZq4qQCIN/4DxFV/eooqtC2YTjB7kBeLMS2T9KJX4Dn+ZVXLOwK0lSgqDtx9gvxtiw==",
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/hashes": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz",
"integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==",
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@oxc-project/types": {
"version": "0.132.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.132.0.tgz",
+2
View File
@@ -38,6 +38,8 @@
"vitest": "^4.1.9"
},
"dependencies": {
"@noble/ciphers": "^2.4.0",
"@noble/hashes": "^2.4.0",
"@tiptap/core": "^3.23.6",
"@tiptap/extension-bold": "^3.23.6",
"@tiptap/extension-bullet-list": "^3.23.6",
+53
View File
@@ -156,6 +156,59 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
------------------------------------------------------------------------------
@noble/ciphers — MIT — Paul Miller (https://paulmillr.com)
------------------------------------------------------------------------------
The MIT License (MIT)
Copyright (c) 2022 Paul Miller (https://paulmillr.com)
Copyright (c) 2016 Thomas Pornin <pornin@bolet.org>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the “Software”), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
------------------------------------------------------------------------------
@noble/hashes — MIT — Paul Miller (https://paulmillr.com)
------------------------------------------------------------------------------
The MIT License (MIT)
Copyright (c) 2022 Paul Miller (https://paulmillr.com)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the “Software”), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
------------------------------------------------------------------------------
@sveltejs/acorn-typescript — MIT — tyrealhu and the Svelte team
------------------------------------------------------------------------------
+12
View File
@@ -0,0 +1,12 @@
// Canonical messages; the importers' error strings are matched against `en.importError`
// and localized from there (see i18n/importMessages.ts).
export const WRONG_PASSWORD = 'Wrong password.';
export const UNSUPPORTED_ENCRYPTION = 'This document uses an encryption method that is not supported.';
// `name` marks a failure of the crypto layer itself, so a save error is not mistaken
// for a lost file handle.
export function encryptionError(message: string): Error {
const err = new Error(message);
err.name = 'EncryptionError';
return err;
}
+216
View File
@@ -0,0 +1,216 @@
// ODF package encryption. Written: one AES-256-GCM `encrypted-package` entry keyed by
// Argon2id, the form LibreOffice writes by default. Read: that plus the per-entry
// AES-CBC/GCM form with PBKDF2 that every earlier version writes.
import { unzipSync, zipSync, strFromU8, strToU8, deflateSync, inflateSync } from 'fflate';
import { cbc, gcm } from '@noble/ciphers/aes.js';
import { argon2idAsync } from '@noble/hashes/argon2.js';
import { sha256 } from '@noble/hashes/sha2.js';
import { sha1 } from '@noble/hashes/legacy.js';
import { encryptionError, UNSUPPORTED_ENCRYPTION, WRONG_PASSWORD } from './errors';
const MANIFEST_NS = 'urn:oasis:names:tc:opendocument:xmlns:manifest:1.0';
const LOEXT_NS = 'urn:org:documentfoundation:names:experimental:office:xmlns:loext:1.0';
const AES_CBC = 'http://www.w3.org/2001/04/xmlenc#aes256-cbc';
const AES_GCM = 'http://www.w3.org/2009/xmlenc11#aes256-gcm';
const ARGON2ID = 'urn:org:documentfoundation:names:experimental:office:manifest:argon2id';
const SHA256_1K = `${MANIFEST_NS}#sha256-1k`;
const START_SHA1 = 'http://www.w3.org/2000/09/xmldsig#sha1';
const ODT_MIME = 'application/vnd.oasis.opendocument.text';
const MANIFEST = 'META-INF/manifest.xml';
const PACKAGE = 'encrypted-package';
// LibreOffice's parameters for a whole-package key: 3 passes over 64 MiB in 4 lanes.
const ARGON2 = { t: 3, m: 65536, p: 4 };
type Encryption = {
algorithm: string;
iv: Uint8Array;
kdf: string;
salt: Uint8Array;
iterations: number;
argon2: { t: number; m: number; p: number } | null;
keySize: number;
startKeySha1: boolean;
checksum: Uint8Array | null;
};
const b64ToBytes = (s: string): Uint8Array => Uint8Array.from(atob(s), (c) => c.charCodeAt(0));
const bytesToB64 = (b: Uint8Array): string => btoa(String.fromCharCode(...b));
function equal(a: Uint8Array, b: Uint8Array): boolean {
return a.length === b.length && a.every((v, i) => v === b[i]);
}
function child(parent: Element, name: string): Element | null {
return parent.getElementsByTagNameNS(MANIFEST_NS, name).item(0);
}
function attr(el: Element | null, name: string, ns = MANIFEST_NS): string | null {
return el?.getAttributeNS(ns, name) ?? null;
}
function parseEntry(entry: Element): Encryption | null {
const data = child(entry, 'encryption-data');
if (!data) return null;
const algorithm = child(data, 'algorithm');
const derivation = child(data, 'key-derivation');
const start = child(data, 'start-key-generation');
const iv = attr(algorithm, 'initialisation-vector');
const salt = attr(derivation, 'salt');
if (!iv || !salt) throw encryptionError(UNSUPPORTED_ENCRYPTION);
const kdf = attr(derivation, 'key-derivation-name') ?? '';
const checksum = attr(data, 'checksum');
return {
algorithm: attr(algorithm, 'algorithm-name') ?? '',
iv: b64ToBytes(iv),
kdf,
salt: b64ToBytes(salt),
iterations: Number(attr(derivation, 'iteration-count') ?? 0),
argon2: kdf === ARGON2ID
? {
t: Number(attr(derivation, 'argon2-iterations', LOEXT_NS) ?? ARGON2.t),
m: Number(attr(derivation, 'argon2-memory', LOEXT_NS) ?? ARGON2.m),
p: Number(attr(derivation, 'argon2-lanes', LOEXT_NS) ?? ARGON2.p),
}
: null,
keySize: Number(attr(derivation, 'key-size') ?? 32),
startKeySha1: attr(start, 'start-key-generation-name') === START_SHA1,
checksum: attr(data, 'checksum-type') === SHA256_1K && checksum ? b64ToBytes(checksum) : null,
};
}
async function deriveKey(password: string, enc: Encryption): Promise<Uint8Array> {
const pw = new TextEncoder().encode(password);
const startKey = enc.startKeySha1 ? sha1(pw) : sha256(pw);
if (enc.argon2) {
return argon2idAsync(startKey, enc.salt, { ...enc.argon2, dkLen: enc.keySize });
}
if (enc.kdf !== 'PBKDF2') throw encryptionError(UNSUPPORTED_ENCRYPTION);
// PBKDF2 with HMAC-SHA-1 as the PRF, as ODF specifies it.
const base = await crypto.subtle.importKey('raw', startKey, 'PBKDF2', false, ['deriveBits']);
const bits = await crypto.subtle.deriveBits(
{ name: 'PBKDF2', salt: new Uint8Array(enc.salt), iterations: enc.iterations, hash: 'SHA-1' },
base, enc.keySize * 8);
return new Uint8Array(bits);
}
// Decrypt one entry to its deflated form. GCM authenticates the password itself; the
// other forms are checked against the entry's digest below.
function decipher(data: Uint8Array, key: Uint8Array, enc: Encryption): Uint8Array {
if (enc.algorithm === AES_GCM) {
// LibreOffice repeats the nonce ahead of the ciphertext, which closes with the tag.
const body = data.length > enc.iv.length && equal(data.subarray(0, enc.iv.length), enc.iv)
? data.subarray(enc.iv.length)
: data;
try {
return gcm(key, enc.iv).decrypt(body);
} catch {
throw encryptionError(WRONG_PASSWORD);
}
}
if (enc.algorithm === AES_CBC) {
const plain = cbc(key, enc.iv, { disablePadding: true }).decrypt(data);
// W3C padding: the last byte counts the bytes that were added.
const pad = plain[plain.length - 1] ?? 0;
return pad >= 1 && pad <= 16 && pad <= plain.length ? plain.subarray(0, plain.length - pad) : plain;
}
throw encryptionError(UNSUPPORTED_ENCRYPTION);
}
// The digest covers the first 1024 bytes of the compressed, not yet encrypted stream —
// the one place a wrong password shows before the data is unusable.
function unpack(deflated: Uint8Array, enc: Encryption): Uint8Array {
if (enc.checksum && !equal(sha256(deflated.subarray(0, 1024)), enc.checksum)) {
throw encryptionError(WRONG_PASSWORD);
}
try {
return inflateSync(deflated);
} catch {
throw encryptionError(WRONG_PASSWORD);
}
}
function manifestDoc(files: Record<string, Uint8Array>): Document {
const xml = files[MANIFEST];
if (!xml) throw encryptionError(UNSUPPORTED_ENCRYPTION);
return new DOMParser().parseFromString(strFromU8(xml), 'application/xml');
}
export async function decryptOdf(bytes: Uint8Array, password: string): Promise<Uint8Array> {
const files = unzipSync(bytes);
const entries = manifestDoc(files).getElementsByTagNameNS(MANIFEST_NS, 'file-entry');
const encryptions = new Map<string, Encryption>();
for (const entry of Array.from(entries)) {
const path = attr(entry, 'full-path');
const enc = parseEntry(entry);
if (path && enc) encryptions.set(path, enc);
}
if (!encryptions.size) return bytes;
// A whole-package document carries the real one inside a single entry.
const whole = encryptions.get(PACKAGE);
if (whole && files[PACKAGE]) {
return unpack(decipher(files[PACKAGE], await deriveKey(password, whole), whole), whole);
}
// Per entry: decrypt in place, then drop the encryption from the manifest.
const out: Record<string, [Uint8Array, { level: 0 | 6 }]> = {};
if (files['mimetype']) out['mimetype'] = [files['mimetype'], { level: 0 }];
const keys = new Map<string, Uint8Array>();
for (const [path, data] of Object.entries(files)) {
if (path === 'mimetype') continue;
const enc = encryptions.get(path);
if (!enc) {
out[path] = [data, { level: 6 }];
continue;
}
const id = `${bytesToB64(enc.salt)}:${enc.iterations}`;
let key = keys.get(id);
if (!key) keys.set(id, key = await deriveKey(password, enc));
out[path] = [unpack(decipher(data, key, enc), enc), { level: 6 }];
}
out[MANIFEST] = [strToU8(strippedManifest(files[MANIFEST])), { level: 6 }];
return zipSync(out);
}
// The decrypted package needs a manifest without the encryption records; `manifest:size`
// goes with them, it describes the encrypted entry.
function strippedManifest(xml: Uint8Array): string {
return strFromU8(xml)
.replace(/<manifest:encryption-data\b[\s\S]*?<\/manifest:encryption-data>/g, '')
.replace(/<manifest:encryption-data\b[^>]*\/>/g, '')
.replace(/ manifest:size="\d+"/g, '');
}
function wholesomeManifest(mediaType: string, size: number, iv: Uint8Array, salt: Uint8Array): string {
return `<?xml version="1.0" encoding="UTF-8"?>
<manifest:manifest xmlns:manifest="${MANIFEST_NS}" manifest:version="1.4" xmlns:loext="${LOEXT_NS}">
<manifest:file-entry manifest:full-path="${PACKAGE}" manifest:media-type="${mediaType}" manifest:size="${size}">
<manifest:encryption-data>
<manifest:algorithm manifest:algorithm-name="${AES_GCM}" manifest:initialisation-vector="${bytesToB64(iv)}"/>
<manifest:start-key-generation manifest:start-key-generation-name="http://www.w3.org/2001/04/xmlenc#sha256" manifest:key-size="32"/>
<manifest:key-derivation manifest:key-derivation-name="${ARGON2ID}" loext:argon2-iterations="${ARGON2.t}" loext:argon2-memory="${ARGON2.m}" loext:argon2-lanes="${ARGON2.p}" manifest:salt="${bytesToB64(salt)}" manifest:key-size="32"/>
</manifest:encryption-data>
</manifest:file-entry>
</manifest:manifest>`;
}
export async function encryptOdf(bytes: Uint8Array, password: string): Promise<Uint8Array> {
const mimetype = unzipSync(bytes, { filter: (f) => f.name === 'mimetype' })['mimetype'];
const mediaType = mimetype ? strFromU8(mimetype) : ODT_MIME;
const salt = crypto.getRandomValues(new Uint8Array(16));
const iv = crypto.getRandomValues(new Uint8Array(12));
const key = await argon2idAsync(sha256(new TextEncoder().encode(password)), salt,
{ ...ARGON2, dkLen: 32 });
const body = gcm(key, iv).encrypt(deflateSync(bytes, { level: 6 }));
const entry = new Uint8Array(iv.length + body.length);
entry.set(iv);
entry.set(body, iv.length);
return zipSync({
mimetype: [strToU8(mediaType), { level: 0 }],
[PACKAGE]: [entry, { level: 0 }],
[MANIFEST]: [strToU8(wholesomeManifest(mediaType, bytes.length, iv, salt)), { level: 6 }],
});
}
+64
View File
@@ -0,0 +1,64 @@
// Password protection of the saved file: the one module the app and saveFile.ts see.
// The format modules and their crypto libraries load only when a password is in play.
import { unzipSync } from 'fflate';
import { encryptionError, UNSUPPORTED_ENCRYPTION, WRONG_PASSWORD } from './errors';
export { UNSUPPORTED_ENCRYPTION, WRONG_PASSWORD };
export type Protection = 'odf' | 'ooxml' | null;
// An encrypted OOXML file is not a zip at all: it is a compound file, and this is its
// signature.
const CFB_MAGIC = [0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1];
function isCfb(bytes: Uint8Array): boolean {
return bytes.length >= CFB_MAGIC.length && CFB_MAGIC.every((b, i) => bytes[i] === b);
}
export function isProtected(bytes: Uint8Array): Protection {
if (isCfb(bytes)) return 'ooxml';
return isEncryptedOdfSync(bytes) ? 'odf' : null;
}
// Kept here rather than imported from ./odf so that the check itself pulls in no crypto.
function isEncryptedOdfSync(bytes: Uint8Array): boolean {
try {
const files = unzipSync(bytes, { filter: (f) => f.name === 'META-INF/manifest.xml' });
const manifest = files['META-INF/manifest.xml'];
return manifest !== undefined && new TextDecoder().decode(manifest).includes('encryption-data');
} catch {
return false;
}
}
function isZip(bytes: Uint8Array): boolean {
return bytes[0] === 0x50 && bytes[1] === 0x4b;
}
// Which format to write is decided by the package we are handed, not by a file name.
function containerOf(bytes: Uint8Array): Protection {
if (!isZip(bytes)) return null;
try {
const files = unzipSync(bytes, { filter: (f) => f.name === 'mimetype' || f.name === '[Content_Types].xml' });
if (files['mimetype']) return 'odf';
if (files['[Content_Types].xml']) return 'ooxml';
} catch { /* not a package we can protect */ }
return null;
}
export async function encryptPackage(bytes: Uint8Array, password: string): Promise<Uint8Array> {
if (containerOf(bytes) === 'odf') {
const { encryptOdf } = await import('./odf');
return encryptOdf(bytes, password);
}
throw encryptionError(UNSUPPORTED_ENCRYPTION);
}
export async function decryptPackage(bytes: Uint8Array, password: string): Promise<Uint8Array> {
if (isProtected(bytes) === 'odf') {
const { decryptOdf } = await import('./odf');
return decryptOdf(bytes, password);
}
throw encryptionError(UNSUPPORTED_ENCRYPTION);
}
+128
View File
@@ -0,0 +1,128 @@
import { describe, it, expect } from 'vitest';
import { unzipSync, zipSync, strToU8, strFromU8, deflateSync } from 'fflate';
import { cbc } from '@noble/ciphers/aes.js';
import { sha256 } from '@noble/hashes/sha2.js';
import { encryptOdf, decryptOdf } from '../../src/lib/crypto/odf';
import { isProtected, WRONG_PASSWORD, UNSUPPORTED_ENCRYPTION } from '../../src/lib/crypto/protect';
const MIME = 'application/vnd.oasis.opendocument.text';
const PW = 'geheim123';
function plainOdt(text = 'Hello'): Uint8Array {
return zipSync({
mimetype: [strToU8(MIME), { level: 0 }],
'content.xml': [strToU8(`<?xml version="1.0"?><office:document-content>${text}</office:document-content>`), { level: 6 }],
'META-INF/manifest.xml': [strToU8(`<?xml version="1.0"?>
<manifest:manifest xmlns:manifest="urn:oasis:names:tc:opendocument:xmlns:manifest:1.0" manifest:version="1.3">
<manifest:file-entry manifest:full-path="/" manifest:media-type="${MIME}"/>
<manifest:file-entry manifest:full-path="content.xml" manifest:media-type="text/xml"/>
</manifest:manifest>`), { level: 6 }],
});
}
const b64 = (b: Uint8Array) => Buffer.from(b).toString('base64');
// The per-entry form every LibreOffice up to ODF 1.3 writes: deflate, AES-256-CBC with a
// PBKDF2 key, stored, and a digest over the first 1024 compressed bytes.
async function classicEncrypted(password: string, iterations = 1000): Promise<Uint8Array> {
const files = unzipSync(plainOdt());
const salt = new Uint8Array(16).fill(3);
const iv = new Uint8Array(16).fill(5);
const base = await crypto.subtle.importKey('raw', sha256(new TextEncoder().encode(password)), 'PBKDF2', false, ['deriveBits']);
const key = new Uint8Array(await crypto.subtle.deriveBits({ name: 'PBKDF2', salt, iterations, hash: 'SHA-1' }, base, 256));
const plain = files['content.xml'];
const deflated = deflateSync(plain, { level: 6 });
const pad = 16 - (deflated.length % 16);
const padded = new Uint8Array(deflated.length + pad);
padded.set(deflated);
padded[padded.length - 1] = pad;
const encrypted = cbc(key, iv, { disablePadding: true }).encrypt(padded);
return zipSync({
mimetype: [files['mimetype'], { level: 0 }],
'content.xml': [encrypted, { level: 0 }],
'META-INF/manifest.xml': [strToU8(`<?xml version="1.0"?>
<manifest:manifest xmlns:manifest="urn:oasis:names:tc:opendocument:xmlns:manifest:1.0" manifest:version="1.3">
<manifest:file-entry manifest:full-path="content.xml" manifest:media-type="text/xml" manifest:size="${plain.length}">
<manifest:encryption-data manifest:checksum-type="urn:oasis:names:tc:opendocument:xmlns:manifest:1.0#sha256-1k" manifest:checksum="${b64(sha256(deflated.subarray(0, 1024)))}">
<manifest:algorithm manifest:algorithm-name="http://www.w3.org/2001/04/xmlenc#aes256-cbc" manifest:initialisation-vector="${b64(iv)}"/>
<manifest:start-key-generation manifest:start-key-generation-name="http://www.w3.org/2000/09/xmldsig#sha256" manifest:key-size="32"/>
<manifest:key-derivation manifest:key-derivation-name="PBKDF2" manifest:iteration-count="${iterations}" manifest:salt="${b64(salt)}" manifest:key-size="32"/>
</manifest:encryption-data>
</manifest:file-entry>
</manifest:manifest>`), { level: 6 }],
});
}
describe('ODF package encryption', () => {
it('round-trips a package through encryptOdf/decryptOdf', async () => {
const plain = plainOdt('Round trip');
const decrypted = await decryptOdf(await encryptOdf(plain, PW), PW);
expect(strFromU8(unzipSync(decrypted)['content.xml'])).toContain('Round trip');
});
it('writes the package LibreOffice expects', async () => {
const encrypted = await encryptOdf(plainOdt(), PW);
const names = Object.keys(unzipSync(encrypted));
expect(names[0]).toBe('mimetype');
expect(names).toContain('encrypted-package');
const files = unzipSync(encrypted);
expect(strFromU8(files['mimetype'])).toBe(MIME);
const manifest = strFromU8(files['META-INF/manifest.xml']);
expect(manifest).toContain('xmlenc11#aes256-gcm');
expect(manifest).toContain('argon2id');
expect(manifest).toContain('loext:argon2-memory="65536"');
// The nonce is repeated ahead of the ciphertext, and the tag closes it.
expect(files['encrypted-package'].length).toBe(12 + 16 + (files['encrypted-package'].length - 28));
expect(manifest).toContain(`manifest:size="${plainOdt().length}"`);
});
it('keeps a template media type', async () => {
const OTT = 'application/vnd.oasis.opendocument.text-template';
const template = zipSync({ mimetype: [strToU8(OTT), { level: 0 }], 'content.xml': [strToU8('<x/>'), { level: 6 }] });
const files = unzipSync(await encryptOdf(template, PW));
expect(strFromU8(files['mimetype'])).toBe(OTT);
expect(strFromU8(files['META-INF/manifest.xml'])).toContain(OTT);
});
it('rejects a wrong password', async () => {
const encrypted = await encryptOdf(plainOdt(), PW);
await expect(decryptOdf(encrypted, 'falsch')).rejects.toThrow(WRONG_PASSWORD);
});
it('reads the per-entry form with PBKDF2 and AES-CBC', async () => {
const decrypted = await decryptOdf(await classicEncrypted(PW), PW);
const files = unzipSync(decrypted);
expect(strFromU8(files['content.xml'])).toContain('Hello');
expect(strFromU8(files['META-INF/manifest.xml'])).not.toContain('encryption-data');
expect(strFromU8(files['mimetype'])).toBe(MIME);
});
it('catches a wrong password on the per-entry form by its digest', async () => {
await expect(decryptOdf(await classicEncrypted(PW), 'falsch')).rejects.toThrow(WRONG_PASSWORD);
});
it('refuses an algorithm it cannot read', async () => {
const blowfish = strFromU8(unzipSync(await classicEncrypted(PW))['META-INF/manifest.xml'])
.replace('http://www.w3.org/2001/04/xmlenc#aes256-cbc', 'Blowfish CFB');
const files = unzipSync(await classicEncrypted(PW));
files['META-INF/manifest.xml'] = strToU8(blowfish);
const repacked = zipSync(Object.fromEntries(Object.entries(files).map(([k, v]) => [k, [v, { level: 0 }] as [Uint8Array, { level: 0 }]])));
await expect(decryptOdf(repacked, PW)).rejects.toThrow(UNSUPPORTED_ENCRYPTION);
});
});
describe('isProtected', () => {
it('recognizes an encrypted ODF package', async () => {
expect(isProtected(await encryptOdf(plainOdt(), PW))).toBe('odf');
expect(isProtected(await classicEncrypted(PW))).toBe('odf');
});
it('leaves plain and unreadable files alone', () => {
expect(isProtected(plainOdt())).toBe(null);
expect(isProtected(new Uint8Array([1, 2, 3, 4]))).toBe(null);
});
it('recognizes a compound file as an encrypted OOXML document', () => {
expect(isProtected(new Uint8Array([0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1, 0, 0]))).toBe('ooxml');
});
});