Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
161 changes: 30 additions & 131 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import * as path from "node:path";
import * as fs from "node:fs";
import { commandLineMainEntry } from "readium-desktop/main/cli";
import { httpGetWithAuth } from "readium-desktop/main/network/http";
import { CRL_URL, DUMMY_CRL } from "@r2-lcp-js/parser/epub/lcp-certificate";
import { BUILD_CRL, BUILD_CRL_CACHED_AT, CRL_URL } from "@r2-lcp-js/parser/epub/lcp-certificate";
import { setLcpNativePluginPath, setCRLGetter } from "@r2-lcp-js/parser/epub/lcp";
import { initGlobalConverters_OPDS } from "@r2-opds-js/opds/init-globals";
import {
Expand All @@ -25,6 +25,7 @@ import { app } from "electron";
import { _APP_NAME, _APP_VERSION, _PACK_NAME } from "readium-desktop/preprocessor-directives";
import { FORCE_PROD_DB_IN_DEV, USER_DATA_FOLDER } from "readium-desktop/common/constant";
import { appendFileSyncWithRotation } from "readium-desktop/utils/log";
import { LcpCrlCache } from "./main/services/lcpCrlCache";

// isURL() excludes the file: and data: URL protocols; the compile-time TLD policy decides whether localhost / non-TLD hosts are accepted (note that ftp: is accepted)
// import isURL from "validator/lib/isURL";
Expand Down Expand Up @@ -71,139 +72,37 @@ initGlobalConverters_GENERIC();
const lcpNativePluginPath = path.normalize(path.join(__dirname, "external-assets", "lcp.node"));
setLcpNativePluginPath(lcpNativePluginPath);

interface ILcpCrlCache {
crlPem: string;
etag: string | undefined;
lastModified: string | undefined;
validatedAt: number;
expiresAt: number;
refreshPromise: Promise<void> | undefined;
}

const lcpCrlCache: ILcpCrlCache = {
crlPem: DUMMY_CRL,
etag: undefined,
lastModified: undefined,
validatedAt: 0,
expiresAt: 0,
refreshPromise: undefined,
};

const LCP_CRL_CACHE_FALLBACK_FRESHNESS_MS = 60 * 60 * 1000;

const getCacheControlMaxAgeMs = (cacheControl: string | undefined): number | undefined => {
if (!cacheControl) {
return undefined;
}
let maxAgeMs: number | undefined;
for (const directive of cacheControl.split(",")) {
const [rawName, rawValue] = directive.trim().split("=", 2);
const name = rawName.trim().toLowerCase();
const value = rawValue?.trim();
if (name === "no-cache" || name === "no-store") {
return 0;
}
if (name === "max-age" && value) {
const seconds = Number(value.replace(/^"|"$/g, ""));
if (Number.isFinite(seconds) && seconds >= 0) {
maxAgeMs = seconds * 1000;
}
}
}
return maxAgeMs;
};

const getLcpCrlExpiresAt = (headers: { get(name: string): string | null } | undefined, validatedAt: number): number => {
const cacheControlMaxAgeMs = getCacheControlMaxAgeMs(headers?.get("cache-control") || undefined);
return validatedAt + (cacheControlMaxAgeMs ?? LCP_CRL_CACHE_FALLBACK_FRESHNESS_MS);
};

const isLcpCrlCacheExpired = () =>
Date.now() >= lcpCrlCache.expiresAt;

const refreshLcpCrlCache = (): Promise<void> => {
debug("REFRESH LCP CRL REQUEST", lcpCrlCache);
if (typeof lcpCrlCache.refreshPromise !== "undefined") {
return lcpCrlCache.refreshPromise;
}

lcpCrlCache.refreshPromise = (async () => {
try {
const headers: Record<string, string> = {
const lcpCrlCache = new LcpCrlCache({
defaultCrlPem: BUILD_CRL,
defaultCrlCachedAt: BUILD_CRL_CACHED_AT,
fetchCrl: async () => {
debug("LCP CRL HTTP fetch");
// RFC 2585 Security Considerations: CRL retrieval does not need
// authentication, so this uses Thorium's no-auth HTTP helper.
const res = await httpGetWithAuth(false)(CRL_URL, {
headers: {
Accept: ContentType.PkixCrl,
};
if (lcpCrlCache.etag) {
headers["If-None-Match"] = lcpCrlCache.etag;
}
if (lcpCrlCache.lastModified) {
headers["If-Modified-Since"] = lcpCrlCache.lastModified;
}
// RFC 2585 Security Considerations: CRL retrieval does not need
// authentication, so this uses Thorium's no-auth HTTP helper.
const res = await httpGetWithAuth(false)(CRL_URL, {
headers,
// Reject redirects so the native LCP plugin receives bytes from the
// configured CRL endpoint only.
redirect: "error",
});
if (res.statusCode === 304) {
lcpCrlCache.lastModified = res.response.headers?.get("last-modified") || lcpCrlCache.lastModified;
const validatedAt = Date.now();
lcpCrlCache.validatedAt = validatedAt;
lcpCrlCache.expiresAt = getLcpCrlExpiresAt(res.response.headers, validatedAt);
debug("LCP CRL HTTP cache refreshed: not modified");
return;
}
const mediaType = res.contentType?.split(";")[0].trim().toLowerCase();
// RFC 5280 section 4.2.1.13 says HTTP CRL distribution point URIs point
// to a single DER encoded CRL, and HTTP servers SHOULD respond with
// Content-Type application/pkix-crl.
// https://datatracker.ietf.org/doc/html/rfc5280#section-4.2.1.13
// RFC 2585 section 4.2 registers application/pkix-crl.
// https://datatracker.ietf.org/doc/html/rfc2585#section-4.2
// RFC 2585 Security Considerations: authentication is not necessary
// to retrieve certificates and CRLs.
// https://datatracker.ietf.org/doc/html/rfc2585#page-6
if (res.statusCode === 200 && mediaType === ContentType.PkixCrl) {
const buf = await res.response.buffer();
const lcplStr = "-----BEGIN X509 CRL-----\n" + buf.toString("base64") + "\n-----END X509 CRL-----";
lcpCrlCache.crlPem = lcplStr;
lcpCrlCache.etag = res.response.headers?.get("etag") || undefined; // '"295-65b0d9de8addd"' double quote is included
lcpCrlCache.lastModified = res.response.headers?.get("last-modified") || undefined;
const validatedAt = Date.now();
lcpCrlCache.validatedAt = validatedAt;
lcpCrlCache.expiresAt = getLcpCrlExpiresAt(res.response.headers, validatedAt);
debug("LCP CRL HTTP fetch success");
debug(lcplStr);
return;
}
debug(`LCP CRL HTTP fetch fail; keeping cached CRL (${res.statusCode} ${res.contentType})`);
} catch (err) {
debug("LCP CRL HTTP fetch error; keeping cached CRL");
debug(err);
},
// Reject redirects so the native LCP plugin receives bytes from the
// configured CRL endpoint only.
redirect: "error",
});
if (res.statusCode !== 200 || !res.response?.buffer) {
throw new Error(`LCP CRL HTTP fetch failed (${res.statusCode || res.statusMessage || "unknown error"})`);
}
})().finally(() => {
lcpCrlCache.refreshPromise = undefined;
});

return lcpCrlCache.refreshPromise;
};
const initLcpCrlCacheValidatedAt = lcpCrlCache.validatedAt;
refreshLcpCrlCache().then(() => {
debug(lcpCrlCache.validatedAt > initLcpCrlCacheValidatedAt ? "INIT LCP CRL LOADED" : "INIT LCP CRL FAILED");
debug(lcpCrlCache);
}).catch((err) => {
debug("INIT LCP CRL FAILED");
debug(err);
});

setCRLGetter(async (): Promise<string> => {
const crlPem = lcpCrlCache.crlPem;
if (isLcpCrlCacheExpired()) {
void refreshLcpCrlCache();
}
return crlPem;
const der = await res.response.buffer();
debug("LCP CRL HTTP fetch success");
return der;
},
log: (message, error) => {
debug(message);
if (typeof error !== "undefined") {
debug(error);
}
},
});
lcpCrlCache.preload();
setCRLGetter((): Promise<string> => lcpCrlCache.retrieve());

app.commandLine.appendSwitch("autoplay-policy", "no-user-gesture-required");
app.commandLine.appendSwitch("enable-speech-dispatcher");
Expand Down
213 changes: 213 additions & 0 deletions src/main/services/lcpCrlCache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
// ==LICENSE-BEGIN==
// Copyright 2017 European Digital Reading Lab. All rights reserved.
// Licensed to the Readium Foundation under one or more contributor license agreements.
// Use of this source code is governed by a BSD-style license
// that can be found in the LICENSE file exposed on Github (readium) in the project repository.
// ==LICENSE-END==

import { Buffer } from "node:buffer";

const PEM_HEADER = "-----BEGIN X509 CRL-----";
const PEM_FOOTER = "-----END X509 CRL-----";

export const LCP_CRL_CACHE_EXPIRATION_MS = 7 * 24 * 60 * 60 * 1000;

interface ILcpCrlCacheEntry {
crlPem: string;
cachedAt: number;
}

interface ILcpCrlMemoryEntry {
crlPem: string;
isExpired: boolean;
}

export const encodeLcpCrlPem = (der: Uint8Array): string =>
`${PEM_HEADER}${Buffer.from(der).toString("base64")}${PEM_FOOTER}`;

/**
* Checks that the data has the outer DER structure of an X.509 CRL.
*
* CertificateList is a SEQUENCE whose first element, tbsCertList, is another
* SEQUENCE. This deliberately does not parse the full CRL; it rejects non-DER
* responses such as captive portal HTML and truncated downloads.
*/
export const isX509Crl = (data: Uint8Array): boolean => {
if (data.length < 2 || data[0] !== 0x30) {
return false;
}

let headerSize: number;
let contentLength: number;
if ((data[1] & 0x80) === 0) {
headerSize = 2;
contentLength = data[1];
} else {
const lengthSize = data[1] & 0x7F;
if (lengthSize < 1 || lengthSize > 4 || data.length < 2 + lengthSize) {
return false;
}
headerSize = 2 + lengthSize;
contentLength = data.slice(2, headerSize).reduce((length, byte) => length * 256 + byte, 0);
}

if (headerSize + contentLength !== data.length) {
return false;
}

return data.length > headerSize && data[headerSize] === 0x30;
};

interface ILcpCrlCacheOptions {
fetchCrl: () => Promise<Uint8Array>;
expirationMs?: number;
defaultCrlPem?: string;
defaultCrlCachedAt?: number;
now?: () => number;
log?: (message: string, error?: unknown) => void;
}

/**
* In-memory CRL cache following readium/swift-toolkit's refresh behavior, with
* Swift's readLocal() model represented here as readMemory(). If no memory CRL
* has been seeded, a missing CRL blocks on the network, while an expired valid
* CRL is returned immediately and refreshed in the background.
*
* Thorium seeds this cache with the build-time BUILD_CRL constant and its
* BUILD_CRL_CACHED_AT timestamp. This gives liblcp a trusted fallback before
* the first network refresh completes, while using the same freshness rule for
* the bundled CRL as for a CRL fetched during the current process.
*
* This intentionally mirrors the Swift refresh algorithm, not its persistence
* semantics. The Swift toolkit implementation stores its cache in UserDefaults,
* which may be app-container scoped on sandboxed Apple platforms. In Electron,
* the closest equivalent is app.getPath("userData"), a normal per-user app data
* directory that another same-user process can usually modify or roll back.
*
* If this cache ever becomes disk-backed, treat the disk bytes and timestamps as
* untrusted hints only. Before promoting a persisted CRL into memory, verify the
* CRL signature against a pinned LCP CA/CRL-signing certificate or public key,
* verify issuer/AuthorityKeyIdentifier, require cRLSign on the signing
* certificate, and prefer signed CRL fields such as thisUpdate, nextUpdate and
* CRLNumber over a local cachedAt value. Invalid persisted data should be
* ignored and refreshed from the network instead of being passed to liblcp.
*
* Current logical model:
* startup:
* BUILD_CRL is seeded in memory with BUILD_CRL_CACHED_AT
* preload starts a network refresh only if the memory CRL is expired
* unlock:
* fresh memory CRL -> return it
* expired memory CRL -> return it and refresh in the background
* no memory CRL seed -> wait for one network refresh before unlocking
*
* Logical model for a future disk-backed cache:
* startup:
* disk CRL present and authenticated -> load into memory, then refresh if stale
* disk CRL missing/invalid/expired -> fall back to BUILD_CRL and refresh
* unlock:
* fresh memory CRL -> return it
* stale but authentic CRL -> return it and refresh in the background
* no authentic local CRL -> use BUILD_CRL or wait for one network refresh
*/
export class LcpCrlCache {
private readonly expirationMs: number;
private readonly fetchCrl: () => Promise<Uint8Array>;
private readonly log: (message: string, error?: unknown) => void;
private readonly now: () => number;
private memoryCrl: ILcpCrlCacheEntry | undefined;
private refreshPromise: Promise<string> | undefined;

constructor(options: ILcpCrlCacheOptions) {
this.expirationMs = options.expirationMs ?? LCP_CRL_CACHE_EXPIRATION_MS;
this.fetchCrl = options.fetchCrl;
this.log = options.log ?? (() => undefined);
this.now = options.now ?? Date.now;

if (options.defaultCrlPem) {
this.memoryCrl = {
crlPem: options.defaultCrlPem,
cachedAt: options.defaultCrlCachedAt ?? this.now() - this.expirationMs,
};
}
}

/** Warms a missing or expired cache without making startup wait. */
public preload(): void {
this.log("LCP CRL PRELOAD");
const memoryCrl = this.readMemory();
if (memoryCrl?.isExpired ?? true) {
void this.refresh().catch((error) => this.log("LCP CRL preload failed", error));
}
}

public async retrieve(): Promise<string> {
this.log("LCP CRL RETRIEVE");
const memoryCrl = this.readMemory();
if (!memoryCrl) {
return this.refresh();
}

if (memoryCrl.isExpired) {
void this.refresh().catch((error) => this.log("LCP CRL background refresh failed", error));
}
return memoryCrl.crlPem;
}

/**
* Swift's CRLService.readLocal() reads and validates UserDefaults. Thorium
* deliberately keeps the cache memory-only, so the equivalent decision point
* simply exposes the current memory CRL and whether it is expired.
*/
private readMemory(): ILcpCrlMemoryEntry | undefined {
const memoryCrl = this.memoryCrl;
if (!memoryCrl) {
return undefined;
}

return {
crlPem: memoryCrl.crlPem,
isExpired: this.isExpired(memoryCrl),
};
}

private isExpired(entry: ILcpCrlCacheEntry): boolean {
return this.now() - entry.cachedAt >= this.expirationMs;
}

/** Starts a refresh, or returns the refresh already in flight. */
private refresh(): Promise<string> {
if (typeof this.refreshPromise !== "undefined") {
return this.refreshPromise;
}

this.log("LCP CRL REFRESH");
const refreshPromise = this.fetchAndSave();
this.refreshPromise = refreshPromise;
void refreshPromise.then(
() => this.clearRefreshPromise(refreshPromise),
() => this.clearRefreshPromise(refreshPromise),
);
return refreshPromise;
}

private clearRefreshPromise(refreshPromise: Promise<string>): void {
if (this.refreshPromise === refreshPromise) {
this.refreshPromise = undefined;
}
}

private async fetchAndSave(): Promise<string> {
const der = await this.fetchCrl();
if (!isX509Crl(der)) {
throw new Error("The LCP CRL response is not a valid DER-encoded X.509 CRL");
}

const entry: ILcpCrlCacheEntry = {
crlPem: encodeLcpCrlPem(der),
cachedAt: this.now(),
};
this.memoryCrl = entry;
return entry.crlPem;
}
}
Loading
Loading