narrarium-astro-reader 0.1.31 → 0.1.32
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 +52 -44
- package/src/lib/content-crypto.ts +18 -0
- package/src/lib/reader-mode.ts +0 -13
|
@@ -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.
|
|
@@ -175,7 +178,14 @@ const isChapterPage = Number.isFinite(currentChapterNumber);
|
|
|
175
178
|
{ name: "AES-GCM", length: 256 },
|
|
176
179
|
false, ["decrypt"]
|
|
177
180
|
)
|
|
178
|
-
.then(function (key) {
|
|
181
|
+
.then(function (key) {
|
|
182
|
+
// Verify canary — AES-GCM auth tag rejects a stale or wrong key
|
|
183
|
+
// before we touch any page content.
|
|
184
|
+
return aesDecrypt(key, canaryIv, canaryCt).then(function (plaintext) {
|
|
185
|
+
if (plaintext !== "narrarium-ok") throw new Error("stale");
|
|
186
|
+
return window._narrariumDecryptPage(key, true);
|
|
187
|
+
});
|
|
188
|
+
})
|
|
179
189
|
.catch(function () {
|
|
180
190
|
// Stored key is stale or corrupt — clear it and show lock screen.
|
|
181
191
|
try { localStorage.removeItem("narrarium-reader-aes-key"); } catch (_) {}
|
|
@@ -188,7 +198,7 @@ const isChapterPage = Number.isFinite(currentChapterNumber);
|
|
|
188
198
|
}
|
|
189
199
|
})();
|
|
190
200
|
</script>
|
|
191
|
-
{
|
|
201
|
+
{canary && (
|
|
192
202
|
<div id="reader-password-gate" role="dialog" aria-modal="true" aria-label="Access required">
|
|
193
203
|
<div class="reader-password-gate__inner">
|
|
194
204
|
<p class="eyebrow">Narrarium Reader</p>
|
|
@@ -233,24 +243,19 @@ const isChapterPage = Number.isFinite(currentChapterNumber);
|
|
|
233
243
|
</main>
|
|
234
244
|
<SiteSearch entries={searchIndex} />
|
|
235
245
|
<ReaderRuntime glossary={canonGlossary} chapters={readerChapters} currentChapterNumber={currentChapterNumber} />
|
|
236
|
-
{
|
|
246
|
+
{canary && (
|
|
237
247
|
<script is:inline>
|
|
238
248
|
(function initPasswordGate() {
|
|
239
249
|
var gate = document.getElementById("reader-password-gate");
|
|
240
250
|
var form = document.getElementById("reader-password-form");
|
|
241
251
|
var input = document.getElementById("reader-password-input");
|
|
242
252
|
var errorEl = document.getElementById("reader-password-error");
|
|
243
|
-
var passwordHash = document.body.dataset.readerPasswordHash;
|
|
244
253
|
var cryptoSalt = document.body.dataset.cryptoSalt;
|
|
245
|
-
|
|
254
|
+
var canaryIv = document.body.dataset.canaryIv;
|
|
255
|
+
var canaryCt = document.body.dataset.canaryCt;
|
|
256
|
+
if (!gate || !form || !cryptoSalt || !canaryIv || !canaryCt) return;
|
|
246
257
|
if (input) { input.focus(); }
|
|
247
258
|
|
|
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
259
|
function b64ToBytes(b64) {
|
|
255
260
|
var bin = atob(b64);
|
|
256
261
|
var bytes = new Uint8Array(bin.length);
|
|
@@ -258,34 +263,39 @@ const isChapterPage = Number.isFinite(currentChapterNumber);
|
|
|
258
263
|
return bytes;
|
|
259
264
|
}
|
|
260
265
|
|
|
266
|
+
function aesDecrypt(cryptoKey, ivB64, ctB64) {
|
|
267
|
+
return crypto.subtle.decrypt(
|
|
268
|
+
{ name: "AES-GCM", iv: b64ToBytes(ivB64) },
|
|
269
|
+
cryptoKey,
|
|
270
|
+
b64ToBytes(ctB64)
|
|
271
|
+
).then(function (buf) { return new TextDecoder().decode(buf); });
|
|
272
|
+
}
|
|
273
|
+
|
|
261
274
|
form.addEventListener("submit", function (event) {
|
|
262
275
|
event.preventDefault();
|
|
263
276
|
var password = input ? input.value : "";
|
|
264
277
|
if (!password) return;
|
|
265
278
|
|
|
266
279
|
var encoded = new TextEncoder().encode(password);
|
|
280
|
+
var saltBytes = b64ToBytes(cryptoSalt);
|
|
267
281
|
|
|
268
|
-
//
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
282
|
+
// Derive the AES-256-GCM key via PBKDF2 — the full 100 000-iteration
|
|
283
|
+
// cost is paid on every attempt, making offline brute-force impractical.
|
|
284
|
+
crypto.subtle.importKey("raw", encoded, "PBKDF2", false, ["deriveKey"])
|
|
285
|
+
.then(function (keyMaterial) {
|
|
286
|
+
return crypto.subtle.deriveKey(
|
|
287
|
+
{ name: "PBKDF2", salt: saltBytes, iterations: 100000, hash: "SHA-256" },
|
|
288
|
+
keyMaterial,
|
|
289
|
+
{ name: "AES-GCM", length: 256 },
|
|
290
|
+
true, // extractable so we can export and store it
|
|
291
|
+
["decrypt"]
|
|
292
|
+
);
|
|
293
|
+
})
|
|
294
|
+
.then(function (aesKey) {
|
|
295
|
+
// Verify against the canary block — AES-GCM auth tag rejects wrong keys.
|
|
296
|
+
return aesDecrypt(aesKey, canaryIv, canaryCt).then(function (plaintext) {
|
|
297
|
+
if (plaintext !== "narrarium-ok") throw new Error("wrong password");
|
|
275
298
|
|
|
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
299
|
// Persist the raw key bytes so auto-decrypt works on reload.
|
|
290
300
|
crypto.subtle.exportKey("raw", aesKey).then(function (rawBuf) {
|
|
291
301
|
var rawBytes = new Uint8Array(rawBuf);
|
|
@@ -296,14 +306,12 @@ const isChapterPage = Number.isFinite(currentChapterNumber);
|
|
|
296
306
|
// Decrypt the page — passes false = manual unlock, so the
|
|
297
307
|
// content-unlocked event will fire for late-init scripts.
|
|
298
308
|
return window._narrariumDecryptPage(aesKey, false);
|
|
299
|
-
})
|
|
300
|
-
.catch(function () {
|
|
301
|
-
if (errorEl) { errorEl.hidden = false; }
|
|
302
|
-
if (input) { input.value = ""; input.focus(); }
|
|
303
309
|
});
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
310
|
+
})
|
|
311
|
+
.catch(function () {
|
|
312
|
+
if (errorEl) { errorEl.hidden = false; }
|
|
313
|
+
if (input) { input.value = ""; input.focus(); }
|
|
314
|
+
});
|
|
307
315
|
});
|
|
308
316
|
})();
|
|
309
317
|
</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
|
-
}
|