use std::io::{Cursor, Read, Write};
use base64::Engine;
use flamberge_crypto::aes;
use flamberge_keys::{kobo::derive_userkeys, KeyStore};
use rusqlite::{params, Connection};
use zip::write::SimpleFileOptions;
use zip::{CompressionMethod, ZipArchive, ZipWriter};
use super::*;
const VOLUME_ID: &str = "vol-1";
const XHTML_PATH: &str = "OEBPS/text.xhtml";
const XHTML_PLAIN: &[u8] = b"<?xml version=\"1.0\"?><html><body>hi</body></html>";
const PLAIN_PATH: &str = "OEBPS/notes.txt";
const PLAIN_CONTENT: &[u8] = b"this file is not encrypted";
fn pkcs7_pad(data: &[u8]) -> Vec<u8> {
let pad = 16 - (data.len() % 16);
let mut out = data.to_vec();
out.extend(std::iter::repeat_n(pad as u8, pad));
out
}
fn candidate_keys() -> Vec<[u8; 16]> {
let macs = vec!["00:11:22:33:44:55".to_string(), "N9999999999".to_string()];
let users = vec!["11111111-2222-3333-4444-555555555555".to_string()];
derive_userkeys(&macs, &users)
}
fn fixture(user_key: &[u8; 16]) -> (Vec<u8>, Vec<u8>) {
let page_key = [0x5au8; 16];
let wrapped = aes::ecb_encrypt(user_key, &page_key).unwrap();
let elementkey_b64 = base64::engine::general_purpose::STANDARD.encode(&wrapped);
let ciphertext = aes::ecb_encrypt(&page_key, &pkcs7_pad(XHTML_PLAIN)).unwrap();
let mut kepub = Vec::new();
{
let mut zw = ZipWriter::new(Cursor::new(&mut kepub));
let stored = SimpleFileOptions::default().compression_method(CompressionMethod::Stored);
zw.start_file("mimetype", stored).unwrap();
zw.write_all(b"application/epub+zip").unwrap();
zw.start_file(XHTML_PATH, stored).unwrap();
zw.write_all(&ciphertext).unwrap();
zw.start_file(PLAIN_PATH, SimpleFileOptions::default())
.unwrap();
zw.write_all(PLAIN_CONTENT).unwrap();
zw.finish().unwrap();
}
let dbfile = tempfile::NamedTempFile::new().unwrap();
{
let conn = Connection::open(dbfile.path()).unwrap();
conn.execute_batch(
"CREATE TABLE content_keys (volumeid TEXT, elementid TEXT, elementkey TEXT);
CREATE TABLE content (contentid TEXT, Title TEXT);",
)
.unwrap();
conn.execute(
"INSERT INTO content_keys VALUES (?1, ?2, ?3)",
params![VOLUME_ID, XHTML_PATH, elementkey_b64],
)
.unwrap();
conn.execute(
"INSERT INTO content VALUES (?1, ?2)",
params![VOLUME_ID, "Test Book"],
)
.unwrap();
}
let sqlite = std::fs::read(dbfile.path()).unwrap();
(kepub, sqlite)
}
fn member(zip: &[u8], name: &str) -> Vec<u8> {
let mut archive = ZipArchive::new(Cursor::new(zip)).unwrap();
let mut file = archive.by_name(name).unwrap();
let mut bytes = Vec::new();
file.read_to_end(&mut bytes).unwrap();
bytes
}
#[test]
fn decrypts_synthesized_kepub() {
let candidates = candidate_keys();
let (kepub, sqlite) = fixture(&candidates[0]);
let mut keys = KeyStore::new();
keys.kobo_keys = candidates;
keys.kobo_db = Some(sqlite);
let book = decrypt(&kepub, &keys).unwrap();
assert_eq!(book.extension, "epub");
assert_eq!(book.title.as_deref(), Some("Test Book"));
assert_eq!(member(&book.data, XHTML_PATH), XHTML_PLAIN);
assert_eq!(member(&book.data, PLAIN_PATH), PLAIN_CONTENT);
let mut out = ZipArchive::new(Cursor::new(&book.data)).unwrap();
let mimetype = out.by_index(0).unwrap();
assert_eq!(mimetype.name(), "mimetype");
assert_eq!(mimetype.compression(), CompressionMethod::Stored);
}
#[test]
fn honors_explicit_volume_id() {
let candidates = candidate_keys();
let (kepub, sqlite) = fixture(&candidates[0]);
let mut keys = KeyStore::new();
keys.kobo_keys = candidates;
keys.kobo_db = Some(sqlite);
keys.kobo_volumeid = Some(VOLUME_ID.to_string());
let book = decrypt(&kepub, &keys).unwrap();
assert_eq!(member(&book.data, XHTML_PATH), XHTML_PLAIN);
}
#[test]
fn no_candidate_key_fails_clearly() {
let candidates = candidate_keys();
let (kepub, sqlite) = fixture(&candidates[0]);
let mut keys = KeyStore::new();
keys.kobo_keys = Vec::new(); keys.kobo_db = Some(sqlite);
assert!(matches!(
decrypt(&kepub, &keys),
Err(SchemeError::NoKeyWorked)
));
}
#[test]
fn missing_db_is_a_clear_error() {
let (kepub, _sqlite) = fixture(&candidate_keys()[0]);
let keys = KeyStore::new();
let err = decrypt(&kepub, &keys).unwrap_err();
assert!(err.to_string().contains("SQLite DB"), "got: {err}");
}
#[test]
fn non_zip_input_falls_through() {
let keys = KeyStore::new();
assert!(matches!(
decrypt(b"not a zip", &keys),
Err(SchemeError::NotThisScheme)
));
}
#[test]
fn routes_kepub_epub_and_extensionless_via_db_signal() {
let candidates = candidate_keys();
let (kepub, sqlite) = fixture(&candidates[0]);
let mut keys = KeyStore::new();
keys.kobo_keys = candidates;
keys.kobo_db = Some(sqlite);
for ext in ["epub", ""] {
let book = crate::decrypt(&kepub, ext, &keys).unwrap();
assert_eq!(book.extension, "epub");
assert_eq!(member(&book.data, XHTML_PATH), XHTML_PLAIN);
}
}