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:
1 parent
d273c04f9c
commit
4b163e6d10
7 files changed
+501
No files matched your search
Generated
+26
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
------------------------------------------------------------------------------
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 }],
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user