narrarium-astro-reader 0.1.31 → 0.1.33
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cli-dist/lib/content-crypto.d.ts +14 -0
- package/cli-dist/lib/content-crypto.d.ts.map +1 -1
- package/cli-dist/lib/content-crypto.js +16 -0
- package/cli-dist/lib/content-crypto.js.map +1 -1
- package/cli-dist/lib/reader-mode.d.ts +0 -7
- package/cli-dist/lib/reader-mode.d.ts.map +1 -1
- package/cli-dist/lib/reader-mode.js +0 -13
- package/cli-dist/lib/reader-mode.js.map +1 -1
- package/package.json +1 -1
- package/src/layouts/BaseLayout.astro +63 -44
- package/src/lib/content-crypto.ts +18 -0
- package/src/lib/reader-mode.ts +0 -13
- package/src/styles/global.css +28 -0
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
export declare function getBuildSalt(): Buffer;
|
|
3
3
|
/** Base64-encoded build salt, ready to embed in an HTML attribute. */
|
|
4
4
|
export declare function getBuildSaltBase64(): string;
|
|
5
|
+
/**
|
|
6
|
+
* Known plaintext embedded (encrypted) in the built HTML so the browser can
|
|
7
|
+
* verify a password by attempting decryption rather than comparing a fast hash.
|
|
8
|
+
* This forces brute-force attempts to pay the full PBKDF2 cost every time.
|
|
9
|
+
*/
|
|
10
|
+
export declare const CANARY_PLAINTEXT = "narrarium-ok";
|
|
5
11
|
export interface EncryptedChunk {
|
|
6
12
|
/** Base64-encoded 12-byte random IV. */
|
|
7
13
|
iv: string;
|
|
@@ -16,4 +22,12 @@ export interface EncryptedChunk {
|
|
|
16
22
|
* verify integrity without any additional framing.
|
|
17
23
|
*/
|
|
18
24
|
export declare function encryptString(plaintext: string, password: string): EncryptedChunk;
|
|
25
|
+
/**
|
|
26
|
+
* Encrypt the canary plaintext with the same PBKDF2-derived build key.
|
|
27
|
+
*
|
|
28
|
+
* Embed `iv` and `ct` in the built HTML as `data-canary-iv` / `data-canary-ct`.
|
|
29
|
+
* The browser verifies the password by decrypting the canary and checking that
|
|
30
|
+
* the result equals `CANARY_PLAINTEXT` — no fast-hash oracle in the HTML.
|
|
31
|
+
*/
|
|
32
|
+
export declare function encryptCanary(password: string): EncryptedChunk;
|
|
19
33
|
//# sourceMappingURL=content-crypto.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"content-crypto.d.ts","sourceRoot":"","sources":["../../src/lib/content-crypto.ts"],"names":[],"mappings":"AAcA,mFAAmF;AACnF,wBAAgB,YAAY,IAAI,MAAM,CAMrC;AAED,sEAAsE;AACtE,wBAAgB,kBAAkB,IAAI,MAAM,CAE3C;AAMD,MAAM,WAAW,cAAc;IAC7B,wCAAwC;IACxC,EAAE,EAAE,MAAM,CAAC;IACX,0DAA0D;IAC1D,EAAE,EAAE,MAAM,CAAC;CACZ;AAED;;;;;;GAMG;AACH,wBAAgB,aAAa,CAAC,SAAS,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,cAAc,CAWjF"}
|
|
1
|
+
{"version":3,"file":"content-crypto.d.ts","sourceRoot":"","sources":["../../src/lib/content-crypto.ts"],"names":[],"mappings":"AAcA,mFAAmF;AACnF,wBAAgB,YAAY,IAAI,MAAM,CAMrC;AAED,sEAAsE;AACtE,wBAAgB,kBAAkB,IAAI,MAAM,CAE3C;AAMD;;;;GAIG;AACH,eAAO,MAAM,gBAAgB,iBAAiB,CAAC;AAE/C,MAAM,WAAW,cAAc;IAC7B,wCAAwC;IACxC,EAAE,EAAE,MAAM,CAAC;IACX,0DAA0D;IAC1D,EAAE,EAAE,MAAM,CAAC;CACZ;AAED;;;;;;GAMG;AACH,wBAAgB,aAAa,CAAC,SAAS,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,cAAc,CAWjF;AAED;;;;;;GAMG;AACH,wBAAgB,aAAa,CAAC,QAAQ,EAAE,MAAM,GAAG,cAAc,CAE9D"}
|
|
@@ -24,6 +24,12 @@ export function getBuildSaltBase64() {
|
|
|
24
24
|
function deriveKey(password, salt) {
|
|
25
25
|
return pbkdf2Sync(password, salt, 100_000, 32, "sha256");
|
|
26
26
|
}
|
|
27
|
+
/**
|
|
28
|
+
* Known plaintext embedded (encrypted) in the built HTML so the browser can
|
|
29
|
+
* verify a password by attempting decryption rather than comparing a fast hash.
|
|
30
|
+
* This forces brute-force attempts to pay the full PBKDF2 cost every time.
|
|
31
|
+
*/
|
|
32
|
+
export const CANARY_PLAINTEXT = "narrarium-ok";
|
|
27
33
|
/**
|
|
28
34
|
* Encrypt a UTF-8 string with AES-256-GCM using PBKDF2-derived key.
|
|
29
35
|
*
|
|
@@ -43,4 +49,14 @@ export function encryptString(plaintext, password) {
|
|
|
43
49
|
ct: Buffer.concat([body, tag]).toString("base64"),
|
|
44
50
|
};
|
|
45
51
|
}
|
|
52
|
+
/**
|
|
53
|
+
* Encrypt the canary plaintext with the same PBKDF2-derived build key.
|
|
54
|
+
*
|
|
55
|
+
* Embed `iv` and `ct` in the built HTML as `data-canary-iv` / `data-canary-ct`.
|
|
56
|
+
* The browser verifies the password by decrypting the canary and checking that
|
|
57
|
+
* the result equals `CANARY_PLAINTEXT` — no fast-hash oracle in the HTML.
|
|
58
|
+
*/
|
|
59
|
+
export function encryptCanary(password) {
|
|
60
|
+
return encryptString(CANARY_PLAINTEXT, password);
|
|
61
|
+
}
|
|
46
62
|
//# sourceMappingURL=content-crypto.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"content-crypto.js","sourceRoot":"","sources":["../../src/lib/content-crypto.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAEtE;;;;;;;;GAQG;AAEH,IAAI,UAAU,GAAkB,IAAI,CAAC;AAErC,mFAAmF;AACnF,MAAM,UAAU,YAAY;IAC1B,IAAI,CAAC,UAAU,EAAE,CAAC;QAChB,UAAU,GAAG,WAAW,CAAC,EAAE,CAAC,CAAC;QAC7B,OAAO,CAAC,IAAI,CAAC,8DAA8D,CAAC,CAAC;IAC/E,CAAC;IACD,OAAO,UAAU,CAAC;AACpB,CAAC;AAED,sEAAsE;AACtE,MAAM,UAAU,kBAAkB;IAChC,OAAO,YAAY,EAAE,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC;AAC3C,CAAC;AAED,SAAS,SAAS,CAAC,QAAgB,EAAE,IAAY;IAC/C,OAAO,UAAU,CAAC,QAAQ,EAAE,IAAI,EAAE,OAAO,EAAE,EAAE,EAAE,QAAQ,CAAC,CAAC;AAC3D,CAAC;
|
|
1
|
+
{"version":3,"file":"content-crypto.js","sourceRoot":"","sources":["../../src/lib/content-crypto.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAEtE;;;;;;;;GAQG;AAEH,IAAI,UAAU,GAAkB,IAAI,CAAC;AAErC,mFAAmF;AACnF,MAAM,UAAU,YAAY;IAC1B,IAAI,CAAC,UAAU,EAAE,CAAC;QAChB,UAAU,GAAG,WAAW,CAAC,EAAE,CAAC,CAAC;QAC7B,OAAO,CAAC,IAAI,CAAC,8DAA8D,CAAC,CAAC;IAC/E,CAAC;IACD,OAAO,UAAU,CAAC;AACpB,CAAC;AAED,sEAAsE;AACtE,MAAM,UAAU,kBAAkB;IAChC,OAAO,YAAY,EAAE,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC;AAC3C,CAAC;AAED,SAAS,SAAS,CAAC,QAAgB,EAAE,IAAY;IAC/C,OAAO,UAAU,CAAC,QAAQ,EAAE,IAAI,EAAE,OAAO,EAAE,EAAE,EAAE,QAAQ,CAAC,CAAC;AAC3D,CAAC;AAED;;;;GAIG;AACH,MAAM,CAAC,MAAM,gBAAgB,GAAG,cAAc,CAAC;AAS/C;;;;;;GAMG;AACH,MAAM,UAAU,aAAa,CAAC,SAAiB,EAAE,QAAgB;IAC/D,MAAM,IAAI,GAAG,YAAY,EAAE,CAAC;IAC5B,MAAM,GAAG,GAAG,SAAS,CAAC,QAAQ,EAAE,IAAI,CAAC,CAAC;IACtC,MAAM,EAAE,GAAG,WAAW,CAAC,EAAE,CAAC,CAAC;IAC3B,MAAM,MAAM,GAAG,cAAc,CAAC,aAAa,EAAE,GAAG,EAAE,EAAE,CAAC,CAAC;IACtD,MAAM,IAAI,GAAG,MAAM,CAAC,MAAM,CAAC,CAAC,MAAM,CAAC,MAAM,CAAC,SAAS,EAAE,MAAM,CAAC,EAAE,MAAM,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC;IAC/E,MAAM,GAAG,GAAG,MAAM,CAAC,UAAU,EAAE,CAAC,CAAC,8BAA8B;IAC/D,OAAO;QACL,EAAE,EAAE,EAAE,CAAC,QAAQ,CAAC,QAAQ,CAAC;QACzB,EAAE,EAAE,MAAM,CAAC,MAAM,CAAC,CAAC,IAAI,EAAE,GAAG,CAAC,CAAC,CAAC,QAAQ,CAAC,QAAQ,CAAC;KAClD,CAAC;AACJ,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,aAAa,CAAC,QAAgB;IAC5C,OAAO,aAAa,CAAC,gBAAgB,EAAE,QAAQ,CAAC,CAAC;AACnD,CAAC"}
|
|
@@ -5,11 +5,4 @@ export declare function isFullCanonMode(): boolean;
|
|
|
5
5
|
* content encryption. Never embedded in the built HTML.
|
|
6
6
|
*/
|
|
7
7
|
export declare function getReaderPassword(): string | null;
|
|
8
|
-
/**
|
|
9
|
-
* Returns a SHA-256 hex hash of the NARRARIUM_READER_PASSWORD env var,
|
|
10
|
-
* or null when the variable is not set. The hash is embedded in the built
|
|
11
|
-
* HTML and compared against the user's input at runtime via SubtleCrypto
|
|
12
|
-
* as a fast pre-filter before the more expensive PBKDF2 key derivation.
|
|
13
|
-
*/
|
|
14
|
-
export declare function getReaderPasswordHash(): string | null;
|
|
15
8
|
//# sourceMappingURL=reader-mode.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"reader-mode.d.ts","sourceRoot":"","sources":["../../src/lib/reader-mode.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"reader-mode.d.ts","sourceRoot":"","sources":["../../src/lib/reader-mode.ts"],"names":[],"mappings":"AAEA,wBAAgB,eAAe,IAAI,OAAO,CAMzC;AAED;;;;GAIG;AACH,wBAAgB,iBAAiB,IAAI,MAAM,GAAG,IAAI,CAEjD"}
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { createHash } from "node:crypto";
|
|
2
1
|
import { readReaderEnv } from "./env.js";
|
|
3
2
|
export function isFullCanonMode() {
|
|
4
3
|
const raw = String(readReaderEnv(["NARRARIUM_READER_CANON_MODE", "NARRARIUM_READER_ALLOW_FULL_CANON"]) ?? "")
|
|
@@ -14,16 +13,4 @@ export function isFullCanonMode() {
|
|
|
14
13
|
export function getReaderPassword() {
|
|
15
14
|
return readReaderEnv(["NARRARIUM_READER_PASSWORD"]) ?? null;
|
|
16
15
|
}
|
|
17
|
-
/**
|
|
18
|
-
* Returns a SHA-256 hex hash of the NARRARIUM_READER_PASSWORD env var,
|
|
19
|
-
* or null when the variable is not set. The hash is embedded in the built
|
|
20
|
-
* HTML and compared against the user's input at runtime via SubtleCrypto
|
|
21
|
-
* as a fast pre-filter before the more expensive PBKDF2 key derivation.
|
|
22
|
-
*/
|
|
23
|
-
export function getReaderPasswordHash() {
|
|
24
|
-
const raw = readReaderEnv(["NARRARIUM_READER_PASSWORD"]);
|
|
25
|
-
if (!raw)
|
|
26
|
-
return null;
|
|
27
|
-
return createHash("sha256").update(raw).digest("hex");
|
|
28
|
-
}
|
|
29
16
|
//# sourceMappingURL=reader-mode.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"reader-mode.js","sourceRoot":"","sources":["../../src/lib/reader-mode.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,
|
|
1
|
+
{"version":3,"file":"reader-mode.js","sourceRoot":"","sources":["../../src/lib/reader-mode.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AAEzC,MAAM,UAAU,eAAe;IAC7B,MAAM,GAAG,GAAG,MAAM,CAAC,aAAa,CAAC,CAAC,6BAA6B,EAAE,mCAAmC,CAAC,CAAC,IAAI,EAAE,CAAC;SAC1G,IAAI,EAAE;SACN,WAAW,EAAE,CAAC;IAEjB,OAAO,GAAG,KAAK,GAAG,IAAI,GAAG,KAAK,MAAM,IAAI,GAAG,KAAK,MAAM,IAAI,GAAG,KAAK,QAAQ,IAAI,GAAG,KAAK,UAAU,CAAC;AACnG,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,iBAAiB;IAC/B,OAAO,aAAa,CAAC,CAAC,2BAA2B,CAAC,CAAC,IAAI,IAAI,CAAC;AAC9D,CAAC"}
|
package/package.json
CHANGED
|
@@ -5,8 +5,8 @@ import SiteSearch from "../components/SiteSearch.astro";
|
|
|
5
5
|
import { listChapters } from "narrarium";
|
|
6
6
|
import { getBookRoot } from "../lib/book.js";
|
|
7
7
|
import { loadCanonGlossary } from "../lib/glossary.js";
|
|
8
|
-
import { isFullCanonMode,
|
|
9
|
-
import { getBuildSaltBase64 } from "../lib/content-crypto.js";
|
|
8
|
+
import { isFullCanonMode, getReaderPassword } from "../lib/reader-mode.js";
|
|
9
|
+
import { getBuildSaltBase64, encryptCanary } from "../lib/content-crypto.js";
|
|
10
10
|
import { loadSearchIndex } from "../lib/search.js";
|
|
11
11
|
|
|
12
12
|
interface Props {
|
|
@@ -30,9 +30,10 @@ const canonGlossary = await loadCanonGlossary(currentChapterNumber);
|
|
|
30
30
|
const searchIndex = await loadSearchIndex(currentChapterNumber);
|
|
31
31
|
const readerChapters = await listChapters(getBookRoot()).catch(() => []);
|
|
32
32
|
const fullCanonMode = isFullCanonMode();
|
|
33
|
-
const
|
|
34
|
-
const cryptoSalt =
|
|
35
|
-
|
|
33
|
+
const password = getReaderPassword();
|
|
34
|
+
const cryptoSalt = password ? getBuildSaltBase64() : null;
|
|
35
|
+
const canary = password ? encryptCanary(password) : null;
|
|
36
|
+
if (!password) {
|
|
36
37
|
console.info("[narrarium-reader] NARRARIUM_READER_PASSWORD not set — building without encryption.");
|
|
37
38
|
}
|
|
38
39
|
const isChapterPage = Number.isFinite(currentChapterNumber);
|
|
@@ -65,7 +66,7 @@ const isChapterPage = Number.isFinite(currentChapterNumber);
|
|
|
65
66
|
})();
|
|
66
67
|
</script>
|
|
67
68
|
</head>
|
|
68
|
-
<body class:list={[isChapterPage && "page-chapter"]} data-reader-chapter-number={currentChapterNumber} data-full-canon={fullCanonMode ? "true" : "false"} data-
|
|
69
|
+
<body class:list={[isChapterPage && "page-chapter"]} data-reader-chapter-number={currentChapterNumber} data-full-canon={fullCanonMode ? "true" : "false"} data-crypto-salt={cryptoSalt ?? undefined} data-canary-iv={canary?.iv ?? undefined} data-canary-ct={canary?.ct ?? undefined}>
|
|
69
70
|
<script is:inline>
|
|
70
71
|
(() => {
|
|
71
72
|
try {
|
|
@@ -85,6 +86,8 @@ const isChapterPage = Number.isFinite(currentChapterNumber);
|
|
|
85
86
|
"decrypted" – encrypted, auto-decrypted from stored AES key
|
|
86
87
|
──────────────────────────────────────────────────────────────────── */
|
|
87
88
|
var salt = document.body.dataset.cryptoSalt;
|
|
89
|
+
var canaryIv = document.body.dataset.canaryIv;
|
|
90
|
+
var canaryCt = document.body.dataset.canaryCt;
|
|
88
91
|
|
|
89
92
|
if (!salt) {
|
|
90
93
|
// No encryption configured — content is always available.
|
|
@@ -170,14 +173,27 @@ const isChapterPage = Number.isFinite(currentChapterNumber);
|
|
|
170
173
|
try { storedKeyB64 = localStorage.getItem("narrarium-reader-aes-key"); } catch (_) {}
|
|
171
174
|
|
|
172
175
|
if (storedKeyB64) {
|
|
176
|
+
// Show loading overlay while we verify and decrypt with the stored key.
|
|
177
|
+
document.body.classList.add("reader-decrypting");
|
|
173
178
|
crypto.subtle.importKey(
|
|
174
179
|
"raw", b64ToBytes(storedKeyB64),
|
|
175
180
|
{ name: "AES-GCM", length: 256 },
|
|
176
181
|
false, ["decrypt"]
|
|
177
182
|
)
|
|
178
|
-
.then(function (key) {
|
|
183
|
+
.then(function (key) {
|
|
184
|
+
// Verify canary — AES-GCM auth tag rejects a stale or wrong key
|
|
185
|
+
// before we touch any page content.
|
|
186
|
+
return aesDecrypt(key, canaryIv, canaryCt).then(function (plaintext) {
|
|
187
|
+
if (plaintext !== "narrarium-ok") throw new Error("stale");
|
|
188
|
+
return window._narrariumDecryptPage(key, true);
|
|
189
|
+
});
|
|
190
|
+
})
|
|
191
|
+
.then(function () {
|
|
192
|
+
document.body.classList.remove("reader-decrypting");
|
|
193
|
+
})
|
|
179
194
|
.catch(function () {
|
|
180
195
|
// Stored key is stale or corrupt — clear it and show lock screen.
|
|
196
|
+
document.body.classList.remove("reader-decrypting");
|
|
181
197
|
try { localStorage.removeItem("narrarium-reader-aes-key"); } catch (_) {}
|
|
182
198
|
document.body.classList.add("reader-auth-locked");
|
|
183
199
|
_resolveReady("locked");
|
|
@@ -188,7 +204,12 @@ const isChapterPage = Number.isFinite(currentChapterNumber);
|
|
|
188
204
|
}
|
|
189
205
|
})();
|
|
190
206
|
</script>
|
|
191
|
-
{
|
|
207
|
+
{canary && (
|
|
208
|
+
<div id="reader-loading" aria-hidden="true">
|
|
209
|
+
<div class="reader-loading__spinner"></div>
|
|
210
|
+
</div>
|
|
211
|
+
)}
|
|
212
|
+
{canary && (
|
|
192
213
|
<div id="reader-password-gate" role="dialog" aria-modal="true" aria-label="Access required">
|
|
193
214
|
<div class="reader-password-gate__inner">
|
|
194
215
|
<p class="eyebrow">Narrarium Reader</p>
|
|
@@ -233,24 +254,19 @@ const isChapterPage = Number.isFinite(currentChapterNumber);
|
|
|
233
254
|
</main>
|
|
234
255
|
<SiteSearch entries={searchIndex} />
|
|
235
256
|
<ReaderRuntime glossary={canonGlossary} chapters={readerChapters} currentChapterNumber={currentChapterNumber} />
|
|
236
|
-
{
|
|
257
|
+
{canary && (
|
|
237
258
|
<script is:inline>
|
|
238
259
|
(function initPasswordGate() {
|
|
239
260
|
var gate = document.getElementById("reader-password-gate");
|
|
240
261
|
var form = document.getElementById("reader-password-form");
|
|
241
262
|
var input = document.getElementById("reader-password-input");
|
|
242
263
|
var errorEl = document.getElementById("reader-password-error");
|
|
243
|
-
var passwordHash = document.body.dataset.readerPasswordHash;
|
|
244
264
|
var cryptoSalt = document.body.dataset.cryptoSalt;
|
|
245
|
-
|
|
265
|
+
var canaryIv = document.body.dataset.canaryIv;
|
|
266
|
+
var canaryCt = document.body.dataset.canaryCt;
|
|
267
|
+
if (!gate || !form || !cryptoSalt || !canaryIv || !canaryCt) return;
|
|
246
268
|
if (input) { input.focus(); }
|
|
247
269
|
|
|
248
|
-
function buf2hex(buffer) {
|
|
249
|
-
return Array.from(new Uint8Array(buffer))
|
|
250
|
-
.map(function (b) { return b.toString(16).padStart(2, "0"); })
|
|
251
|
-
.join("");
|
|
252
|
-
}
|
|
253
|
-
|
|
254
270
|
function b64ToBytes(b64) {
|
|
255
271
|
var bin = atob(b64);
|
|
256
272
|
var bytes = new Uint8Array(bin.length);
|
|
@@ -258,34 +274,39 @@ const isChapterPage = Number.isFinite(currentChapterNumber);
|
|
|
258
274
|
return bytes;
|
|
259
275
|
}
|
|
260
276
|
|
|
277
|
+
function aesDecrypt(cryptoKey, ivB64, ctB64) {
|
|
278
|
+
return crypto.subtle.decrypt(
|
|
279
|
+
{ name: "AES-GCM", iv: b64ToBytes(ivB64) },
|
|
280
|
+
cryptoKey,
|
|
281
|
+
b64ToBytes(ctB64)
|
|
282
|
+
).then(function (buf) { return new TextDecoder().decode(buf); });
|
|
283
|
+
}
|
|
284
|
+
|
|
261
285
|
form.addEventListener("submit", function (event) {
|
|
262
286
|
event.preventDefault();
|
|
263
287
|
var password = input ? input.value : "";
|
|
264
288
|
if (!password) return;
|
|
265
289
|
|
|
266
290
|
var encoded = new TextEncoder().encode(password);
|
|
291
|
+
var saltBytes = b64ToBytes(cryptoSalt);
|
|
267
292
|
|
|
268
|
-
//
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
293
|
+
// Derive the AES-256-GCM key via PBKDF2 — the full 100 000-iteration
|
|
294
|
+
// cost is paid on every attempt, making offline brute-force impractical.
|
|
295
|
+
crypto.subtle.importKey("raw", encoded, "PBKDF2", false, ["deriveKey"])
|
|
296
|
+
.then(function (keyMaterial) {
|
|
297
|
+
return crypto.subtle.deriveKey(
|
|
298
|
+
{ name: "PBKDF2", salt: saltBytes, iterations: 100000, hash: "SHA-256" },
|
|
299
|
+
keyMaterial,
|
|
300
|
+
{ name: "AES-GCM", length: 256 },
|
|
301
|
+
true, // extractable so we can export and store it
|
|
302
|
+
["decrypt"]
|
|
303
|
+
);
|
|
304
|
+
})
|
|
305
|
+
.then(function (aesKey) {
|
|
306
|
+
// Verify against the canary block — AES-GCM auth tag rejects wrong keys.
|
|
307
|
+
return aesDecrypt(aesKey, canaryIv, canaryCt).then(function (plaintext) {
|
|
308
|
+
if (plaintext !== "narrarium-ok") throw new Error("wrong password");
|
|
275
309
|
|
|
276
|
-
// Hash matched — derive the AES-256-GCM key via PBKDF2.
|
|
277
|
-
var saltBytes = b64ToBytes(cryptoSalt);
|
|
278
|
-
crypto.subtle.importKey("raw", encoded, "PBKDF2", false, ["deriveKey"])
|
|
279
|
-
.then(function (keyMaterial) {
|
|
280
|
-
return crypto.subtle.deriveKey(
|
|
281
|
-
{ name: "PBKDF2", salt: saltBytes, iterations: 100000, hash: "SHA-256" },
|
|
282
|
-
keyMaterial,
|
|
283
|
-
{ name: "AES-GCM", length: 256 },
|
|
284
|
-
true, // extractable so we can export and store it
|
|
285
|
-
["decrypt"]
|
|
286
|
-
);
|
|
287
|
-
})
|
|
288
|
-
.then(function (aesKey) {
|
|
289
310
|
// Persist the raw key bytes so auto-decrypt works on reload.
|
|
290
311
|
crypto.subtle.exportKey("raw", aesKey).then(function (rawBuf) {
|
|
291
312
|
var rawBytes = new Uint8Array(rawBuf);
|
|
@@ -296,14 +317,12 @@ const isChapterPage = Number.isFinite(currentChapterNumber);
|
|
|
296
317
|
// Decrypt the page — passes false = manual unlock, so the
|
|
297
318
|
// content-unlocked event will fire for late-init scripts.
|
|
298
319
|
return window._narrariumDecryptPage(aesKey, false);
|
|
299
|
-
})
|
|
300
|
-
.catch(function () {
|
|
301
|
-
if (errorEl) { errorEl.hidden = false; }
|
|
302
|
-
if (input) { input.value = ""; input.focus(); }
|
|
303
320
|
});
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
321
|
+
})
|
|
322
|
+
.catch(function () {
|
|
323
|
+
if (errorEl) { errorEl.hidden = false; }
|
|
324
|
+
if (input) { input.value = ""; input.focus(); }
|
|
325
|
+
});
|
|
307
326
|
});
|
|
308
327
|
})();
|
|
309
328
|
</script>
|
|
@@ -30,6 +30,13 @@ function deriveKey(password: string, salt: Buffer): Buffer {
|
|
|
30
30
|
return pbkdf2Sync(password, salt, 100_000, 32, "sha256");
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Known plaintext embedded (encrypted) in the built HTML so the browser can
|
|
35
|
+
* verify a password by attempting decryption rather than comparing a fast hash.
|
|
36
|
+
* This forces brute-force attempts to pay the full PBKDF2 cost every time.
|
|
37
|
+
*/
|
|
38
|
+
export const CANARY_PLAINTEXT = "narrarium-ok";
|
|
39
|
+
|
|
33
40
|
export interface EncryptedChunk {
|
|
34
41
|
/** Base64-encoded 12-byte random IV. */
|
|
35
42
|
iv: string;
|
|
@@ -56,3 +63,14 @@ export function encryptString(plaintext: string, password: string): EncryptedChu
|
|
|
56
63
|
ct: Buffer.concat([body, tag]).toString("base64"),
|
|
57
64
|
};
|
|
58
65
|
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Encrypt the canary plaintext with the same PBKDF2-derived build key.
|
|
69
|
+
*
|
|
70
|
+
* Embed `iv` and `ct` in the built HTML as `data-canary-iv` / `data-canary-ct`.
|
|
71
|
+
* The browser verifies the password by decrypting the canary and checking that
|
|
72
|
+
* the result equals `CANARY_PLAINTEXT` — no fast-hash oracle in the HTML.
|
|
73
|
+
*/
|
|
74
|
+
export function encryptCanary(password: string): EncryptedChunk {
|
|
75
|
+
return encryptString(CANARY_PLAINTEXT, password);
|
|
76
|
+
}
|
package/src/lib/reader-mode.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { createHash } from "node:crypto";
|
|
2
1
|
import { readReaderEnv } from "./env.js";
|
|
3
2
|
|
|
4
3
|
export function isFullCanonMode(): boolean {
|
|
@@ -17,15 +16,3 @@ export function isFullCanonMode(): boolean {
|
|
|
17
16
|
export function getReaderPassword(): string | null {
|
|
18
17
|
return readReaderEnv(["NARRARIUM_READER_PASSWORD"]) ?? null;
|
|
19
18
|
}
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* Returns a SHA-256 hex hash of the NARRARIUM_READER_PASSWORD env var,
|
|
23
|
-
* or null when the variable is not set. The hash is embedded in the built
|
|
24
|
-
* HTML and compared against the user's input at runtime via SubtleCrypto
|
|
25
|
-
* as a fast pre-filter before the more expensive PBKDF2 key derivation.
|
|
26
|
-
*/
|
|
27
|
-
export function getReaderPasswordHash(): string | null {
|
|
28
|
-
const raw = readReaderEnv(["NARRARIUM_READER_PASSWORD"]);
|
|
29
|
-
if (!raw) return null;
|
|
30
|
-
return createHash("sha256").update(raw).digest("hex");
|
|
31
|
-
}
|
package/src/styles/global.css
CHANGED
|
@@ -1466,6 +1466,34 @@ mark {
|
|
|
1466
1466
|
}
|
|
1467
1467
|
}
|
|
1468
1468
|
|
|
1469
|
+
/* ─── Auto-decrypt loading overlay ──────────────────────────── */
|
|
1470
|
+
#reader-loading {
|
|
1471
|
+
display: none;
|
|
1472
|
+
position: fixed;
|
|
1473
|
+
inset: 0;
|
|
1474
|
+
z-index: 199;
|
|
1475
|
+
background: var(--app-bg);
|
|
1476
|
+
align-items: center;
|
|
1477
|
+
justify-content: center;
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
body.reader-decrypting #reader-loading {
|
|
1481
|
+
display: flex;
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
.reader-loading__spinner {
|
|
1485
|
+
width: 32px;
|
|
1486
|
+
height: 32px;
|
|
1487
|
+
border: 3px solid var(--line);
|
|
1488
|
+
border-top-color: var(--accent);
|
|
1489
|
+
border-radius: 50%;
|
|
1490
|
+
animation: reader-spin 0.7s linear infinite;
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
@keyframes reader-spin {
|
|
1494
|
+
to { transform: rotate(360deg); }
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1469
1497
|
/* ─── Password gate ──────────────────────────────────────────── */
|
|
1470
1498
|
#reader-password-gate {
|
|
1471
1499
|
display: none;
|