diff --git a/package-lock.json b/package-lock.json index d6688db..e2370d2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index c263625..9a148af 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/public/licenses.txt b/public/licenses.txt index fbd9acc..2109a35 100644 --- a/public/licenses.txt +++ b/public/licenses.txt @@ -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 + +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 ------------------------------------------------------------------------------ diff --git a/src/lib/crypto/errors.ts b/src/lib/crypto/errors.ts new file mode 100644 index 0000000..44c77df --- /dev/null +++ b/src/lib/crypto/errors.ts @@ -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; +} diff --git a/src/lib/crypto/odf.ts b/src/lib/crypto/odf.ts new file mode 100644 index 0000000..557786b --- /dev/null +++ b/src/lib/crypto/odf.ts @@ -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 { + 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): 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 { + const files = unzipSync(bytes); + const entries = manifestDoc(files).getElementsByTagNameNS(MANIFEST_NS, 'file-entry'); + const encryptions = new Map(); + 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 = {}; + if (files['mimetype']) out['mimetype'] = [files['mimetype'], { level: 0 }]; + const keys = new Map(); + 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(//g, '') + .replace(/]*\/>/g, '') + .replace(/ manifest:size="\d+"/g, ''); +} + +function wholesomeManifest(mediaType: string, size: number, iv: Uint8Array, salt: Uint8Array): string { + return ` + + + + + + + + +`; +} + +export async function encryptOdf(bytes: Uint8Array, password: string): Promise { + 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 }], + }); +} diff --git a/src/lib/crypto/protect.ts b/src/lib/crypto/protect.ts new file mode 100644 index 0000000..d88fc5d --- /dev/null +++ b/src/lib/crypto/protect.ts @@ -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 { + 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 { + if (isProtected(bytes) === 'odf') { + const { decryptOdf } = await import('./odf'); + return decryptOdf(bytes, password); + } + throw encryptionError(UNSUPPORTED_ENCRYPTION); +} diff --git a/tests/unit/odf-encryption.test.ts b/tests/unit/odf-encryption.test.ts new file mode 100644 index 0000000..0d883c4 --- /dev/null +++ b/tests/unit/odf-encryption.test.ts @@ -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(`${text}`), { level: 6 }], + 'META-INF/manifest.xml': [strToU8(` + + + +`), { 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 { + 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(` + + + + + + + + +`), { 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(''), { 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'); + }); +});